Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 87 additions & 0 deletions app/campaigns/[id]/settings/components/HouseRulesForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,9 @@ function renderControl(meta: ToggleMeta, value: unknown, onChange: (value: unkno
);

case "string-array":
if (meta.id === "disabledEdgeActionIds") {
return <EdgeActionToggleList value={value as string[]} onChange={onChange} />;
}
return (
<p className="text-xs italic text-zinc-400 dark:text-zinc-500">
Configured per-item (not yet editable here)
Expand All @@ -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 (
<div className="space-y-2">
{EDGE_ACTION_ITEMS.map((item) => {
const isDisabled = disabled.has(item.id);
return (
<label key={item.id} className="flex items-start gap-2">
<input
type="checkbox"
checked={isDisabled}
onChange={() => toggle(item.id)}
className="mt-0.5 h-4 w-4 rounded border-zinc-300 text-indigo-600 focus:ring-indigo-500"
/>
<span className="text-sm">
<span
className={`font-medium ${
isDisabled
? "text-zinc-400 line-through dark:text-zinc-500"
: "text-zinc-700 dark:text-zinc-300"
}`}
>
{item.label}
</span>
<span className="ml-2 text-xs text-zinc-500 dark:text-zinc-400">
{item.description}
</span>
</span>
</label>
);
})}
<p className="text-xs italic text-zinc-500 dark:text-zinc-400">
Checked actions are disabled for this campaign.
</p>
</div>
);
}
5 changes: 5 additions & 0 deletions components/action-resolution/EdgeActionSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<EdgeActionType>;
}

interface EdgeButtonConfig {
Expand Down Expand Up @@ -81,6 +83,7 @@ export function EdgeActionSelector({
hasGlitch = false,
hasRerolled = false,
size = "sm",
disabledActionIds,
}: EdgeActionSelectorProps) {
const noEdge = currentEdge <= 0;

Expand All @@ -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;
Expand Down
40 changes: 40 additions & 0 deletions components/action-resolution/__tests__/EdgeActionSelector.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<EdgeActionSelector
{...defaultProps}
timing="pre-roll"
disabledActionIds={["push-the-limit"]}
/>
);

expect(screen.queryByText("Push the Limit")).toBeNull();
expect(screen.getByText("Blitz")).toBeDefined();
});

it("hides non-roll actions listed in disabledActionIds", () => {
render(
<EdgeActionSelector
{...defaultProps}
timing="non-roll"
disabledActionIds={["seize-the-initiative"]}
/>
);

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(
<EdgeActionSelector
{...defaultProps}
timing="pre-roll"
disabledActionIds={["push-the-limit", "blitz"]}
/>
);

expect(container.firstChild).toBeNull();
});
});
});
31 changes: 31 additions & 0 deletions lib/rules/action-resolution/__tests__/edge-actions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});

// =============================================================================
Expand Down
7 changes: 7 additions & 0 deletions lib/rules/action-resolution/edge-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>;
},
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) {
Expand Down
Loading