Add Pathfinder 2e game system mode
All checks were successful
CI / check (push) Successful in 2m21s
CI / build-image (push) Successful in 24s

Implements PF2e as an alternative game system alongside D&D 5e/5.5e.
Settings modal "Game System" selector switches conditions, bestiary,
stat block layout, and initiative calculation between systems.

- Valued conditions with increment/decrement UX (Clumsy 2, Frightened 3)
- 2,502 PF2e creatures from bundled search index (77 sources)
- PF2e stat block: level, traits, Perception, Fort/Ref/Will, ability mods
- Perception-based initiative rolling
- System-scoped source cache (D&D and PF2e sources don't collide)
- Backwards-compatible condition rehydration (ConditionId[] → ConditionEntry[])
- Difficulty indicator hidden in PF2e mode (excluded from MVP)

Closes dostulata/initiative#19

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lukas
2026-04-07 01:26:22 +02:00
parent 8f6eebc43b
commit e62c49434c
67 changed files with 27758 additions and 527 deletions

View File

@@ -1,14 +1,18 @@
import { describe, expect, it } from "vitest";
import type { ConditionId } from "../conditions.js";
import type { ConditionEntry, ConditionId } from "../conditions.js";
import { CONDITION_DEFINITIONS } from "../conditions.js";
import { toggleCondition } from "../toggle-condition.js";
import {
decrementCondition,
setConditionValue,
toggleCondition,
} from "../toggle-condition.js";
import type { Combatant, Encounter } from "../types.js";
import { combatantId, isDomainError } from "../types.js";
import { expectDomainError } from "./test-helpers.js";
function makeCombatant(
name: string,
conditions?: readonly ConditionId[],
conditions?: readonly ConditionEntry[],
): Combatant {
return conditions
? { id: combatantId(name), name, conditions }
@@ -32,7 +36,7 @@ describe("toggleCondition", () => {
const e = enc([makeCombatant("A")]);
const { encounter, events } = success(e, "A", "blinded");
expect(encounter.combatants[0].conditions).toEqual(["blinded"]);
expect(encounter.combatants[0].conditions).toEqual([{ id: "blinded" }]);
expect(events).toEqual([
{
type: "ConditionAdded",
@@ -43,7 +47,7 @@ describe("toggleCondition", () => {
});
it("removes a condition when already present", () => {
const e = enc([makeCombatant("A", ["blinded"])]);
const e = enc([makeCombatant("A", [{ id: "blinded" }])]);
const { encounter, events } = success(e, "A", "blinded");
expect(encounter.combatants[0].conditions).toBeUndefined();
@@ -57,14 +61,17 @@ describe("toggleCondition", () => {
});
it("maintains definition order when adding conditions", () => {
const e = enc([makeCombatant("A", ["poisoned"])]);
const e = enc([makeCombatant("A", [{ id: "poisoned" }])]);
const { encounter } = success(e, "A", "blinded");
expect(encounter.combatants[0].conditions).toEqual(["blinded", "poisoned"]);
expect(encounter.combatants[0].conditions).toEqual([
{ id: "blinded" },
{ id: "poisoned" },
]);
});
it("prevents duplicate conditions", () => {
const e = enc([makeCombatant("A", ["blinded"])]);
const e = enc([makeCombatant("A", [{ id: "blinded" }])]);
// Toggling blinded again removes it, not duplicates
const { encounter } = success(e, "A", "blinded");
expect(encounter.combatants[0].conditions).toBeUndefined();
@@ -96,7 +103,7 @@ describe("toggleCondition", () => {
});
it("normalizes empty array to undefined on removal", () => {
const e = enc([makeCombatant("A", ["charmed"])]);
const e = enc([makeCombatant("A", [{ id: "charmed" }])]);
const { encounter } = success(e, "A", "charmed");
expect(encounter.combatants[0].conditions).toBeUndefined();
@@ -110,6 +117,91 @@ describe("toggleCondition", () => {
const result = success(e, "A", cond);
e = result.encounter;
}
expect(e.combatants[0].conditions).toEqual(order);
expect(e.combatants[0].conditions).toEqual(order.map((id) => ({ id })));
});
});
describe("setConditionValue", () => {
it("adds a valued condition at the specified value", () => {
const e = enc([makeCombatant("A")]);
const result = setConditionValue(e, combatantId("A"), "frightened", 2);
if (isDomainError(result)) throw new Error(result.message);
expect(result.encounter.combatants[0].conditions).toEqual([
{ id: "frightened", value: 2 },
]);
expect(result.events).toEqual([
{
type: "ConditionAdded",
combatantId: combatantId("A"),
condition: "frightened",
value: 2,
},
]);
});
it("updates the value of an existing condition", () => {
const e = enc([makeCombatant("A", [{ id: "frightened", value: 1 }])]);
const result = setConditionValue(e, combatantId("A"), "frightened", 3);
if (isDomainError(result)) throw new Error(result.message);
expect(result.encounter.combatants[0].conditions).toEqual([
{ id: "frightened", value: 3 },
]);
});
it("removes condition when value is 0", () => {
const e = enc([makeCombatant("A", [{ id: "frightened", value: 2 }])]);
const result = setConditionValue(e, combatantId("A"), "frightened", 0);
if (isDomainError(result)) throw new Error(result.message);
expect(result.encounter.combatants[0].conditions).toBeUndefined();
expect(result.events[0].type).toBe("ConditionRemoved");
});
it("rejects unknown condition", () => {
const e = enc([makeCombatant("A")]);
const result = setConditionValue(
e,
combatantId("A"),
"flying" as ConditionId,
1,
);
expectDomainError(result, "unknown-condition");
});
});
describe("decrementCondition", () => {
it("decrements value by 1", () => {
const e = enc([makeCombatant("A", [{ id: "frightened", value: 3 }])]);
const result = decrementCondition(e, combatantId("A"), "frightened");
if (isDomainError(result)) throw new Error(result.message);
expect(result.encounter.combatants[0].conditions).toEqual([
{ id: "frightened", value: 2 },
]);
});
it("removes condition when value reaches 0", () => {
const e = enc([makeCombatant("A", [{ id: "frightened", value: 1 }])]);
const result = decrementCondition(e, combatantId("A"), "frightened");
if (isDomainError(result)) throw new Error(result.message);
expect(result.encounter.combatants[0].conditions).toBeUndefined();
expect(result.events[0].type).toBe("ConditionRemoved");
});
it("removes non-valued condition (value undefined treated as 1)", () => {
const e = enc([makeCombatant("A", [{ id: "blinded" }])]);
const result = decrementCondition(e, combatantId("A"), "blinded");
if (isDomainError(result)) throw new Error(result.message);
expect(result.encounter.combatants[0].conditions).toBeUndefined();
});
it("returns error for inactive condition", () => {
const e = enc([makeCombatant("A")]);
const result = decrementCondition(e, combatantId("A"), "frightened");
expectDomainError(result, "condition-not-active");
});
});