Enforce maximum values for PF2e numbered conditions

Cap dying (4), doomed (3), wounded (3), and slowed (3) at their
rule-defined maximums. The domain clamps values in setConditionValue
and the condition picker disables the [+] button at the cap.

Closes #31

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lukas
2026-04-09 00:04:47 +02:00
parent 1c107a500b
commit 553e09f280
5 changed files with 120 additions and 20 deletions

View File

@@ -162,20 +162,35 @@ export function ConditionPicker({
<span className="min-w-5 rounded-full bg-accent px-1.5 py-0.5 text-center font-medium text-primary-foreground text-xs"> <span className="min-w-5 rounded-full bg-accent px-1.5 py-0.5 text-center font-medium text-primary-foreground text-xs">
{editing.value} {editing.value}
</span> </span>
{(() => {
const atMax =
def.maxValue !== undefined &&
editing.value >= def.maxValue;
return (
<button <button
type="button" type="button"
className="rounded p-0.5 text-foreground hover:bg-accent/40" className={cn(
"rounded p-0.5",
atMax
? "cursor-not-allowed text-muted-foreground opacity-50"
: "text-foreground hover:bg-accent/40",
)}
disabled={atMax}
onMouseDown={(e) => e.preventDefault()} onMouseDown={(e) => e.preventDefault()}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
if (!atMax) {
setEditing({ setEditing({
...editing, ...editing,
value: editing.value + 1, value: editing.value + 1,
}); });
}
}} }}
> >
<Plus className="h-3 w-3" /> <Plus className="h-3 w-3" />
</button> </button>
);
})()}
<button <button
type="button" type="button"
className="ml-0.5 rounded p-0.5 text-foreground hover:bg-accent/40" className="ml-0.5 rounded p-0.5 text-foreground hover:bg-accent/40"

View File

@@ -169,6 +169,60 @@ describe("setConditionValue", () => {
); );
expectDomainError(result, "unknown-condition"); expectDomainError(result, "unknown-condition");
}); });
it("clamps value to maxValue for capped conditions", () => {
const e = enc([makeCombatant("A")]);
const result = setConditionValue(e, combatantId("A"), "dying", 6);
if (isDomainError(result)) throw new Error(result.message);
expect(result.encounter.combatants[0].conditions).toEqual([
{ id: "dying", value: 4 },
]);
expect(result.events[0]).toMatchObject({
type: "ConditionAdded",
value: 4,
});
});
it("allows value at exactly the max", () => {
const e = enc([makeCombatant("A")]);
const result = setConditionValue(e, combatantId("A"), "doomed", 3);
if (isDomainError(result)) throw new Error(result.message);
expect(result.encounter.combatants[0].conditions).toEqual([
{ id: "doomed", value: 3 },
]);
});
it("allows value below the max", () => {
const e = enc([makeCombatant("A")]);
const result = setConditionValue(e, combatantId("A"), "wounded", 2);
if (isDomainError(result)) throw new Error(result.message);
expect(result.encounter.combatants[0].conditions).toEqual([
{ id: "wounded", value: 2 },
]);
});
it("does not cap conditions without a maxValue", () => {
const e = enc([makeCombatant("A")]);
const result = setConditionValue(e, combatantId("A"), "frightened", 10);
if (isDomainError(result)) throw new Error(result.message);
expect(result.encounter.combatants[0].conditions).toEqual([
{ id: "frightened", value: 10 },
]);
});
it("clamps when updating an existing capped condition", () => {
const e = enc([makeCombatant("A", [{ id: "slowed-pf2e", value: 2 }])]);
const result = setConditionValue(e, combatantId("A"), "slowed-pf2e", 5);
if (isDomainError(result)) throw new Error(result.message);
expect(result.encounter.combatants[0].conditions).toEqual([
{ id: "slowed-pf2e", value: 3 },
]);
});
}); });
describe("decrementCondition", () => { describe("decrementCondition", () => {

View File

@@ -57,6 +57,8 @@ export interface ConditionDefinition {
/** When set, the condition only appears in these systems' pickers. */ /** When set, the condition only appears in these systems' pickers. */
readonly systems?: readonly RulesEdition[]; readonly systems?: readonly RulesEdition[];
readonly valued?: boolean; readonly valued?: boolean;
/** Rule-defined maximum value for PF2e valued conditions. */
readonly maxValue?: number;
} }
export function getConditionDescription( export function getConditionDescription(
@@ -329,6 +331,7 @@ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
color: "red", color: "red",
systems: ["pf2e"], systems: ["pf2e"],
valued: true, valued: true,
maxValue: 3,
}, },
{ {
id: "drained", id: "drained",
@@ -353,6 +356,7 @@ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
color: "red", color: "red",
systems: ["pf2e"], systems: ["pf2e"],
valued: true, valued: true,
maxValue: 4,
}, },
{ {
id: "enfeebled", id: "enfeebled",
@@ -475,6 +479,7 @@ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
color: "sky", color: "sky",
systems: ["pf2e"], systems: ["pf2e"],
valued: true, valued: true,
maxValue: 3,
}, },
{ {
id: "stupefied", id: "stupefied",
@@ -510,6 +515,7 @@ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
color: "red", color: "red",
systems: ["pf2e"], systems: ["pf2e"],
valued: true, valued: true,
maxValue: 3,
}, },
] as const; ] as const;

View File

@@ -92,7 +92,11 @@ export function setConditionValue(
const { combatant: target } = found; const { combatant: target } = found;
const current = target.conditions ?? []; const current = target.conditions ?? [];
if (value <= 0) { const def = CONDITION_DEFINITIONS.find((d) => d.id === conditionId);
const clampedValue =
def?.maxValue === undefined ? value : Math.min(value, def.maxValue);
if (clampedValue <= 0) {
const filtered = current.filter((c) => c.id !== conditionId); const filtered = current.filter((c) => c.id !== conditionId);
const newConditions = filtered.length > 0 ? filtered : undefined; const newConditions = filtered.length > 0 ? filtered : undefined;
return { return {
@@ -106,7 +110,7 @@ export function setConditionValue(
const existing = current.find((c) => c.id === conditionId); const existing = current.find((c) => c.id === conditionId);
if (existing) { if (existing) {
const updated = current.map((c) => const updated = current.map((c) =>
c.id === conditionId ? { ...c, value } : c, c.id === conditionId ? { ...c, value: clampedValue } : c,
); );
return { return {
encounter: applyConditions(encounter, combatantId, updated), encounter: applyConditions(encounter, combatantId, updated),
@@ -115,17 +119,25 @@ export function setConditionValue(
type: "ConditionAdded", type: "ConditionAdded",
combatantId, combatantId,
condition: conditionId, condition: conditionId,
value, value: clampedValue,
}, },
], ],
}; };
} }
const added = sortByDefinitionOrder([...current, { id: conditionId, value }]); const added = sortByDefinitionOrder([
...current,
{ id: conditionId, value: clampedValue },
]);
return { return {
encounter: applyConditions(encounter, combatantId, added), encounter: applyConditions(encounter, combatantId, added),
events: [ events: [
{ type: "ConditionAdded", combatantId, condition: conditionId, value }, {
type: "ConditionAdded",
combatantId,
condition: conditionId,
value: clampedValue,
},
], ],
}; };
} }

View File

@@ -310,6 +310,16 @@ Acceptance scenarios:
9. **Given** a combatant has Clumsy 3, **When** the user hovers over the condition icon, **Then** the tooltip shows the condition name, the current value, and the PF2e rules description. 9. **Given** a combatant has Clumsy 3, **When** the user hovers over the condition icon, **Then** the tooltip shows the condition name, the current value, and the PF2e rules description.
10. **Given** a valued condition counter is showing, **When** the user clicks a different valued condition, **Then** the previous counter is replaced (only one counter at a time). 10. **Given** a valued condition counter is showing, **When** the user clicks a different valued condition, **Then** the previous counter is replaced (only one counter at a time).
**Story CC-10 — Condition Value Maximums (P2)**
As a DM running a PF2e encounter, I want valued conditions to be capped at their rule-defined maximum so I cannot accidentally increment them beyond their meaningful range.
Acceptance scenarios:
1. **Given** the game system is Pathfinder 2e, **When** a valued condition reaches its maximum (dying 4, doomed 3, wounded 3, slowed 3), **Then** the `[+]` button in the condition picker counter is disabled.
2. **Given** a combatant has Dying 4, **When** the user opens the condition picker, **Then** the counter shows 4 and `[+]` is disabled; `[-]` and `[✓]` remain active.
3. **Given** a combatant has Slowed 3, **When** the user clicks the Slowed icon tag on the row, **Then** the value decrements to 2 (decrement is unaffected by the cap).
4. **Given** the game system is D&D (5e or 5.5e), **When** interacting with conditions, **Then** no maximum enforcement is applied.
5. **Given** a PF2e valued condition without a defined maximum (e.g., Frightened, Clumsy), **When** incrementing, **Then** no cap is enforced — the value can increase without limit.
### Requirements ### Requirements
- **FR-032**: When a D&D game system is active, the system MUST support the following 15 standard D&D 5e/5.5e conditions: blinded, charmed, deafened, exhaustion, frightened, grappled, incapacitated, invisible, paralyzed, petrified, poisoned, prone, restrained, stunned, unconscious. When Pathfinder 2e is active, the system MUST support the PF2e condition set (see FR-103). - **FR-032**: When a D&D game system is active, the system MUST support the following 15 standard D&D 5e/5.5e conditions: blinded, charmed, deafened, exhaustion, frightened, grappled, incapacitated, invisible, paralyzed, petrified, poisoned, prone, restrained, stunned, unconscious. When Pathfinder 2e is active, the system MUST support the PF2e condition set (see FR-103).
@@ -360,6 +370,9 @@ Acceptance scenarios:
- **FR-105**: For PF2e valued conditions, the condition icon tag MUST display the current value as a small numeric badge (e.g., "2" next to the Frightened icon). Non-valued PF2e conditions display without a badge. - **FR-105**: For PF2e valued conditions, the condition icon tag MUST display the current value as a small numeric badge (e.g., "2" next to the Frightened icon). Non-valued PF2e conditions display without a badge.
- **FR-106**: The condition data model MUST use `ConditionEntry` objects (`{ id: ConditionId, value?: number }`) instead of bare `ConditionId` values. D&D conditions MUST be stored without a `value` field (backwards-compatible). - **FR-106**: The condition data model MUST use `ConditionEntry` objects (`{ id: ConditionId, value?: number }`) instead of bare `ConditionId` values. D&D conditions MUST be stored without a `value` field (backwards-compatible).
- **FR-107**: Switching the game system MUST NOT clear existing combatant conditions. Conditions from the previous game system that are not valid in the new system remain stored but are hidden from display until the user switches back. - **FR-107**: Switching the game system MUST NOT clear existing combatant conditions. Conditions from the previous game system that are not valid in the new system remain stored but are hidden from display until the user switches back.
- **FR-108**: The following PF2e valued conditions MUST have maximum values enforced: dying (max 4), doomed (max 3), wounded (max 3), slowed (max 3). All other valued conditions have no enforced maximum.
- **FR-109**: When a PF2e valued condition is at its maximum value, the `[+]` increment button in the condition picker counter MUST be disabled (visually dimmed and non-interactive).
- **FR-110**: Maximum value enforcement MUST only apply when the Pathfinder 2e game system is active. D&D conditions are unaffected.
### Edge Cases ### Edge Cases
@@ -375,7 +388,7 @@ Acceptance scenarios:
- The settings modal is app-level UI; it does not interact with encounter state. - The settings modal is app-level UI; it does not interact with encounter state.
- When the game system is switched from D&D to PF2e, existing D&D conditions on combatants are hidden (not deleted). Switching back to D&D restores them. - When the game system is switched from D&D to PF2e, existing D&D conditions on combatants are hidden (not deleted). Switching back to D&D restores them.
- PF2e valued condition at value 0 is treated as removed — it MUST NOT appear on the row. - PF2e valued condition at value 0 is treated as removed — it MUST NOT appear on the row.
- Dying 4 in PF2e has special mechanical significance (death), but the system does not enforce this automatically — it displays the value only. - Dying, doomed, wounded, and slowed have enforced maximum values in PF2e (4, 3, 3, 3 respectively). The `[+]` button is disabled at the cap. The dynamic dying cap based on doomed value (dying max = 4 doomed) is not enforced — only the static maximum applies.
- Persistent damage is excluded from the PF2e MVP condition set. It can be added as a follow-up feature. - Persistent damage is excluded from the PF2e MVP condition set. It can be added as a follow-up feature.
--- ---