diff --git a/app/campaigns/[id]/settings/components/HouseRulesForm.tsx b/app/campaigns/[id]/settings/components/HouseRulesForm.tsx
index 4bf68cb8..443f8c15 100644
--- a/app/campaigns/[id]/settings/components/HouseRulesForm.tsx
+++ b/app/campaigns/[id]/settings/components/HouseRulesForm.tsx
@@ -189,6 +189,9 @@ function renderControl(meta: ToggleMeta, value: unknown, onChange: (value: unkno
);
case "string-array":
+ if (meta.id === "disabledEdgeActionIds") {
+ return ;
+ }
return (
Configured per-item (not yet editable here)
@@ -199,3 +202,87 @@ function renderControl(meta: ToggleMeta, value: unknown, onChange: (value: unkno
return null;
}
}
+
+// =============================================================================
+// EDGE ACTION PER-ITEM CHECKBOXES (#863)
+// =============================================================================
+
+const EDGE_ACTION_ITEMS: ReadonlyArray<{ id: string; label: string; description: string }> = [
+ {
+ id: "push-the-limit",
+ label: "Push the Limit",
+ description: "Add Edge dice, ignore limits, exploding 6s.",
+ },
+ {
+ id: "second-chance",
+ label: "Second Chance",
+ description: "Reroll all dice that did not score a hit.",
+ },
+ {
+ id: "seize-the-initiative",
+ label: "Seize the Initiative",
+ description: "Go first in an Initiative Pass.",
+ },
+ { id: "blitz", label: "Blitz", description: "Roll 5d6 for Initiative." },
+ { id: "close-call", label: "Close Call", description: "Negate a glitch or critical glitch." },
+ {
+ id: "dead-mans-trigger",
+ label: "Dead Man's Trigger",
+ description: "Act when incapacitated.",
+ },
+];
+
+interface EdgeActionToggleListProps {
+ value: string[];
+ onChange: (value: string[] | undefined) => void;
+}
+
+function EdgeActionToggleList({ value, onChange }: EdgeActionToggleListProps) {
+ const disabled = new Set(value ?? []);
+
+ const toggle = (id: string) => {
+ const next = new Set(disabled);
+ if (next.has(id)) {
+ next.delete(id);
+ } else {
+ next.add(id);
+ }
+ const nextArr = Array.from(next);
+ onChange(nextArr.length === 0 ? undefined : nextArr);
+ };
+
+ return (
+
+ {EDGE_ACTION_ITEMS.map((item) => {
+ const isDisabled = disabled.has(item.id);
+ return (
+
+ );
+ })}
+
+ Checked actions are disabled for this campaign.
+
+
+ );
+}
diff --git a/components/action-resolution/EdgeActionSelector.tsx b/components/action-resolution/EdgeActionSelector.tsx
index daed616b..1f502dae 100644
--- a/components/action-resolution/EdgeActionSelector.tsx
+++ b/components/action-resolution/EdgeActionSelector.tsx
@@ -24,6 +24,8 @@ interface EdgeActionSelectorProps {
hasRerolled?: boolean;
/** Button size variant */
size?: "sm" | "md";
+ /** Edge action IDs disabled by the GM for this campaign */
+ disabledActionIds?: ReadonlyArray;
}
interface EdgeButtonConfig {
@@ -81,6 +83,7 @@ export function EdgeActionSelector({
hasGlitch = false,
hasRerolled = false,
size = "sm",
+ disabledActionIds,
}: EdgeActionSelectorProps) {
const noEdge = currentEdge <= 0;
@@ -105,6 +108,8 @@ export function EdgeActionSelector({
};
const isActionVisible = (action: EdgeActionType): boolean => {
+ // GM disabled this action for the campaign
+ if (disabledActionIds?.includes(action)) return false;
// Close Call only visible when there's a glitch
if (action === "close-call" && !hasGlitch) return false;
return true;
diff --git a/components/action-resolution/__tests__/EdgeActionSelector.test.tsx b/components/action-resolution/__tests__/EdgeActionSelector.test.tsx
index 37e3d720..fe4c58f7 100644
--- a/components/action-resolution/__tests__/EdgeActionSelector.test.tsx
+++ b/components/action-resolution/__tests__/EdgeActionSelector.test.tsx
@@ -156,4 +156,44 @@ describe("EdgeActionSelector", () => {
expect(screen.getByText("Second Chance")).toBeDefined();
});
});
+
+ describe("disabledActionIds (GM house rule)", () => {
+ it("hides pre-roll actions listed in disabledActionIds", () => {
+ render(
+
+ );
+
+ expect(screen.queryByText("Push the Limit")).toBeNull();
+ expect(screen.getByText("Blitz")).toBeDefined();
+ });
+
+ it("hides non-roll actions listed in disabledActionIds", () => {
+ render(
+
+ );
+
+ expect(screen.queryByText("Seize Initiative")).toBeNull();
+ expect(screen.getByText("Dead Man's Trigger")).toBeDefined();
+ });
+
+ it("returns null when every action for the timing is disabled", () => {
+ const { container } = render(
+
+ );
+
+ expect(container.firstChild).toBeNull();
+ });
+ });
});
diff --git a/lib/rules/action-resolution/__tests__/edge-actions.test.ts b/lib/rules/action-resolution/__tests__/edge-actions.test.ts
index 71b4fc8c..d1410300 100644
--- a/lib/rules/action-resolution/__tests__/edge-actions.test.ts
+++ b/lib/rules/action-resolution/__tests__/edge-actions.test.ts
@@ -406,6 +406,37 @@ describe("canUseEdgeAction", () => {
expect(result.canUse).toBe(false);
expect(result.reason).toContain("not available");
});
+
+ it("should reject an edge action disabled by GM house rule", () => {
+ const character = createMockCharacter();
+ const result = canUseEdgeAction(character, "push-the-limit", {
+ isPreRoll: true,
+ disabledActionIds: ["push-the-limit"],
+ });
+
+ expect(result.canUse).toBe(false);
+ expect(result.reason).toContain("disabled by the GM");
+ });
+
+ it("should allow an edge action not in the disabled list", () => {
+ const character = createMockCharacter();
+ const result = canUseEdgeAction(character, "blitz", {
+ isPreRoll: true,
+ disabledActionIds: ["push-the-limit", "second-chance"],
+ });
+
+ expect(result.canUse).toBe(true);
+ });
+
+ it("should allow actions when the disabled list is empty", () => {
+ const character = createMockCharacter();
+ const result = canUseEdgeAction(character, "push-the-limit", {
+ isPreRoll: true,
+ disabledActionIds: [],
+ });
+
+ expect(result.canUse).toBe(true);
+ });
});
// =============================================================================
diff --git a/lib/rules/action-resolution/edge-actions.ts b/lib/rules/action-resolution/edge-actions.ts
index ae6a4419..145f8a06 100644
--- a/lib/rules/action-resolution/edge-actions.ts
+++ b/lib/rules/action-resolution/edge-actions.ts
@@ -65,9 +65,16 @@ export function canUseEdgeAction(
isPostRoll?: boolean;
hasGlitch?: boolean;
currentResult?: ActionResult;
+ /** Edge action IDs disabled by the GM for this campaign */
+ disabledActionIds?: ReadonlyArray;
},
rules: EditionDiceRules = DEFAULT_DICE_RULES
): { canUse: boolean; reason?: string } {
+ // GM house rule: action disabled for this campaign
+ if (context.disabledActionIds?.includes(action)) {
+ return { canUse: false, reason: "This Edge action is disabled by the GM" };
+ }
+
const edgeConfig = rules.edgeActions?.[action];
if (!edgeConfig) {