From dd4cf5c95e36360fa0052d42748e59baf7552d82 Mon Sep 17 00:00:00 2001 From: Jason Ragsdale Date: Tue, 14 Apr 2026 10:40:45 -0500 Subject: [PATCH] feat: GM toggle to disable individual Edge actions (#863) Wire the existing `disabledEdgeActionIds` house rule into the rule engine and UI: - `canUseEdgeAction` now rejects actions listed in `context.disabledActionIds` with a clear GM-disabled reason. - `EdgeActionSelector` accepts `disabledActionIds` and hides disabled buttons (returning null when every action for the timing is disabled). - `HouseRulesForm` renders per-action checkboxes for the six SR5 Edge actions (Push the Limit, Second Chance, Seize the Initiative, Blitz, Close Call, Dead Man's Trigger), replacing the "not yet editable" placeholder. - Add tests covering the disabled-action branch in both the rule engine and the selector component. --- .../settings/components/HouseRulesForm.tsx | 87 +++++++++++++++++++ .../action-resolution/EdgeActionSelector.tsx | 5 ++ .../__tests__/EdgeActionSelector.test.tsx | 40 +++++++++ .../__tests__/edge-actions.test.ts | 31 +++++++ lib/rules/action-resolution/edge-actions.ts | 7 ++ 5 files changed, 170 insertions(+) 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) {