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

@@ -54,7 +54,7 @@ describe("clearEncounter", () => {
maxHp: 50,
currentHp: 30,
ac: 18,
conditions: ["blinded", "poisoned"],
conditions: [{ id: "blinded" }, { id: "poisoned" }],
isConcentrating: true,
},
{
@@ -63,7 +63,7 @@ describe("clearEncounter", () => {
maxHp: 25,
currentHp: 0,
ac: 12,
conditions: ["unconscious"],
conditions: [{ id: "unconscious" }],
},
],
activeIndex: 0,

View File

@@ -26,25 +26,40 @@ describe("getConditionDescription", () => {
);
});
it("universal conditions have both descriptions", () => {
const universal = CONDITION_DEFINITIONS.filter(
(d) => d.edition === undefined,
it("returns pf2e description when edition is pf2e", () => {
const blinded = findCondition("blinded");
expect(getConditionDescription(blinded, "pf2e")).toBe(
blinded.descriptionPf2e,
);
expect(universal.length).toBeGreaterThan(0);
for (const def of universal) {
expect(def.description).toBeTruthy();
expect(def.description5e).toBeTruthy();
});
it("falls back to default description for pf2e when no pf2e text", () => {
const paralyzed = findCondition("paralyzed");
expect(getConditionDescription(paralyzed, "pf2e")).toBe(
paralyzed.descriptionPf2e,
);
});
it("shared D&D conditions have both description and description5e", () => {
const sharedDndConditions = CONDITION_DEFINITIONS.filter(
(d) =>
d.systems === undefined ||
(d.systems.includes("5e") && d.systems.includes("5.5e")),
);
for (const def of sharedDndConditions) {
expect(def.description, `${def.id} missing description`).toBeTruthy();
expect(def.description5e, `${def.id} missing description5e`).toBeTruthy();
}
});
it("edition-specific conditions have their edition description", () => {
it("system-specific conditions use the systems field", () => {
const sapped = findCondition("sapped");
expect(sapped.description).toBeTruthy();
expect(sapped.edition).toBe("5.5e");
expect(sapped.systems).toContain("5.5e");
const slowed = findCondition("slowed");
expect(slowed.description).toBeTruthy();
expect(slowed.edition).toBe("5.5e");
expect(slowed.systems).toContain("5.5e");
});
it("conditions with identical rules share the same text", () => {
@@ -79,4 +94,34 @@ describe("getConditionsForEdition", () => {
expect(ids5e).toContain("blinded");
expect(ids55e).toContain("blinded");
});
it("returns PF2e conditions for pf2e edition", () => {
const conditions = getConditionsForEdition("pf2e");
const ids = conditions.map((d) => d.id);
expect(ids).toContain("clumsy");
expect(ids).toContain("drained");
expect(ids).toContain("off-guard");
expect(ids).toContain("sickened");
expect(ids).not.toContain("charmed");
expect(ids).not.toContain("exhaustion");
expect(ids).not.toContain("grappled");
});
it("returns D&D conditions for 5.5e", () => {
const conditions = getConditionsForEdition("5.5e");
const ids = conditions.map((d) => d.id);
expect(ids).toContain("charmed");
expect(ids).toContain("exhaustion");
expect(ids).not.toContain("clumsy");
expect(ids).not.toContain("off-guard");
});
it("shared conditions appear in both D&D and PF2e", () => {
const dndIds = getConditionsForEdition("5.5e").map((d) => d.id);
const pf2eIds = getConditionsForEdition("pf2e").map((d) => d.id);
expect(dndIds).toContain("blinded");
expect(pf2eIds).toContain("blinded");
expect(dndIds).toContain("prone");
expect(pf2eIds).toContain("prone");
});
});

View File

@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import {
calculateInitiative,
calculatePf2eInitiative,
formatInitiativeModifier,
} from "../initiative.js";
@@ -93,6 +94,26 @@ describe("calculateInitiative", () => {
});
});
describe("calculatePf2eInitiative", () => {
it("returns perception as both modifier and passive", () => {
const result = calculatePf2eInitiative(11);
expect(result.modifier).toBe(11);
expect(result.passive).toBe(11);
});
it("handles zero perception", () => {
const result = calculatePf2eInitiative(0);
expect(result.modifier).toBe(0);
expect(result.passive).toBe(0);
});
it("handles negative perception", () => {
const result = calculatePf2eInitiative(-2);
expect(result.modifier).toBe(-2);
expect(result.passive).toBe(-2);
});
});
describe("formatInitiativeModifier", () => {
it("formats positive modifier with plus sign", () => {
expect(formatInitiativeModifier(7)).toBe("+7");

View File

@@ -35,7 +35,7 @@ describe("rehydrateCombatant", () => {
expect(result?.maxHp).toBe(7);
expect(result?.currentHp).toBe(5);
expect(result?.tempHp).toBe(3);
expect(result?.conditions).toEqual(["poisoned"]);
expect(result?.conditions).toEqual([{ id: "poisoned" }]);
expect(result?.isConcentrating).toBe(true);
expect(result?.creatureId).toBe("creature-goblin");
expect(result?.color).toBe("red");
@@ -165,7 +165,45 @@ describe("rehydrateCombatant", () => {
...minimalCombatant(),
conditions: ["poisoned", "fake", "blinded"],
});
expect(result?.conditions).toEqual(["poisoned", "blinded"]);
expect(result?.conditions).toEqual([
{ id: "poisoned" },
{ id: "blinded" },
]);
});
it("converts old bare string format to ConditionEntry", () => {
const result = rehydrateCombatant({
...minimalCombatant(),
conditions: ["blinded", "prone"],
});
expect(result?.conditions).toEqual([{ id: "blinded" }, { id: "prone" }]);
});
it("passes through new ConditionEntry format with values", () => {
const result = rehydrateCombatant({
...minimalCombatant(),
conditions: [{ id: "blinded" }, { id: "frightened", value: 2 }],
});
expect(result?.conditions).toEqual([
{ id: "blinded" },
{ id: "frightened", value: 2 },
]);
});
it("handles mixed old and new format entries", () => {
const result = rehydrateCombatant({
...minimalCombatant(),
conditions: ["blinded", { id: "prone" }],
});
expect(result?.conditions).toEqual([{ id: "blinded" }, { id: "prone" }]);
});
it("drops ConditionEntry with invalid value", () => {
const result = rehydrateCombatant({
...minimalCombatant(),
conditions: [{ id: "blinded", value: -1 }],
});
expect(result?.conditions).toEqual([{ id: "blinded" }]);
});
it("drops invalid color — keeps combatant", () => {

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");
});
});

View File

@@ -1,21 +1,48 @@
export type ConditionId =
| "blinded"
| "charmed"
| "clumsy"
| "concealed"
| "confused"
| "controlled"
| "dazzled"
| "deafened"
| "doomed"
| "drained"
| "dying"
| "enfeebled"
| "exhaustion"
| "fascinated"
| "fatigued"
| "fleeing"
| "frightened"
| "grabbed"
| "grappled"
| "hidden"
| "immobilized"
| "incapacitated"
| "invisible"
| "off-guard"
| "paralyzed"
| "petrified"
| "poisoned"
| "prone"
| "quickened"
| "restrained"
| "sapped"
| "sickened"
| "slowed"
| "slowed-pf2e"
| "stunned"
| "unconscious";
| "stupefied"
| "unconscious"
| "undetected"
| "wounded";
export interface ConditionEntry {
readonly id: ConditionId;
readonly value?: number;
}
import type { RulesEdition } from "./rules-edition.js";
@@ -24,20 +51,24 @@ export interface ConditionDefinition {
readonly label: string;
readonly description: string;
readonly description5e: string;
readonly descriptionPf2e?: string;
readonly iconName: string;
readonly color: string;
/** When set, the condition only appears in this edition's picker. */
readonly edition?: RulesEdition;
/** When set, the condition only appears in these systems' pickers. */
readonly systems?: readonly RulesEdition[];
readonly valued?: boolean;
}
export function getConditionDescription(
def: ConditionDefinition,
edition: RulesEdition,
): string {
if (edition === "pf2e" && def.descriptionPf2e) return def.descriptionPf2e;
return edition === "5e" ? def.description5e : def.description;
}
export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
// ── Shared conditions (D&D + PF2e) ──
{
id: "blinded",
label: "Blinded",
@@ -45,6 +76,8 @@ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
"Can't see. Auto-fail sight checks. Attacks have Disadvantage; attacks against have Advantage.",
description5e:
"Can't see. Auto-fail sight checks. Attacks have Disadvantage; attacks against have Advantage.",
descriptionPf2e:
"Can't see. All terrain is difficult terrain. 4 status penalty to Perception checks involving sight. Immune to visual effects. Auto-fail checks requiring sight. Off-guard.",
iconName: "EyeOff",
color: "neutral",
},
@@ -57,12 +90,15 @@ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
"Can't attack or target the charmer with harmful abilities. Charmer has Advantage on social checks.",
iconName: "Heart",
color: "pink",
systems: ["5e", "5.5e"],
},
{
id: "deafened",
label: "Deafened",
description: "Can't hear. Auto-fail hearing checks.",
description5e: "Can't hear. Auto-fail hearing checks.",
descriptionPf2e:
"Can't hear. 2 status penalty to Perception checks and Initiative. Auto-fail hearing checks. Immune to auditory effects.",
iconName: "EarOff",
color: "neutral",
},
@@ -75,6 +111,7 @@ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
"L1: Disadvantage on ability checks\nL2: Speed halved\nL3: Disadvantage on attacks and saves\nL4: HP max halved\nL5: Speed 0\nL6: Death\nLong rest removes 1 level.",
iconName: "BatteryLow",
color: "amber",
systems: ["5e", "5.5e"],
},
{
id: "frightened",
@@ -83,8 +120,11 @@ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
"Disadvantage on ability checks and attacks while source of fear is in line of sight. Can't willingly move closer to the source.",
description5e:
"Disadvantage on ability checks and attacks while source of fear is in line of sight. Can't willingly move closer to the source.",
descriptionPf2e:
"X status penalty to all checks and DCs (X = value). Can't willingly approach the source.",
iconName: "Siren",
color: "orange",
valued: true,
},
{
id: "grappled",
@@ -95,6 +135,7 @@ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
"Speed 0. Ends if grappler is Incapacitated or moved out of reach.",
iconName: "Hand",
color: "neutral",
systems: ["5e", "5.5e"],
},
{
id: "incapacitated",
@@ -104,6 +145,7 @@ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
description5e: "Can't take Actions or Reactions.",
iconName: "Ban",
color: "gray",
systems: ["5e", "5.5e"],
},
{
id: "invisible",
@@ -112,6 +154,8 @@ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
"Can't be seen. Advantage on Initiative. Not affected by effects requiring sight (unless caster sees you). Attacks have Advantage; attacks against have Disadvantage.",
description5e:
"Impossible to see without magic or special sense. Heavily Obscured. Attacks have Advantage; attacks against have Disadvantage.",
descriptionPf2e:
"Can't be seen except by special senses. Undetected to everyone. Can't be targeted except by effects that don't require sight.",
iconName: "Ghost",
color: "violet",
},
@@ -122,6 +166,7 @@ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
"Incapacitated. Can't move or speak. Auto-fail Str/Dex saves. Attacks against have Advantage. Hits within 5 ft. are critical.",
description5e:
"Incapacitated. Can't move or speak. Auto-fail Str/Dex saves. Attacks against have Advantage. Hits within 5 ft. are critical.",
descriptionPf2e: "Can't act. Off-guard. 4 status penalty to AC.",
iconName: "ZapOff",
color: "yellow",
},
@@ -132,6 +177,8 @@ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
"Turned to stone. Weight \u00D710. Incapacitated. Can't move or speak. Attacks against have Advantage. Auto-fail Str/Dex saves. Resistant to all damage. Immune to poison and disease.",
description5e:
"Turned to stone. Weight \u00D710. Incapacitated. Can't move or speak. Attacks against have Advantage. Auto-fail Str/Dex saves. Resistant to all damage. Immune to poison and disease.",
descriptionPf2e:
"Can't act. Can't sense anything. AC = 9. Hardness 8. Immune to most effects.",
iconName: "Gem",
color: "slate",
},
@@ -142,6 +189,7 @@ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
description5e: "Disadvantage on attack rolls and ability checks.",
iconName: "Droplet",
color: "green",
systems: ["5e", "5.5e"],
},
{
id: "prone",
@@ -150,6 +198,8 @@ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
"Can only crawl (costs extra movement). Disadvantage on attacks. Attacks within 5 ft. have Advantage; ranged attacks have Disadvantage. Standing up costs half movement.",
description5e:
"Can only crawl (costs extra movement). Disadvantage on attacks. Attacks within 5 ft. have Advantage; ranged attacks have Disadvantage. Standing up costs half movement.",
descriptionPf2e:
"Off-guard. 2 circumstance penalty to attack rolls. Only movement is Crawl and Stand. +1 circumstance bonus to AC vs. ranged attacks, 2 vs. melee.",
iconName: "ArrowDown",
color: "neutral",
},
@@ -160,6 +210,8 @@ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
"Speed is 0. Attacks have Disadvantage. Attacks against have Advantage. Disadvantage on Dex saves.",
description5e:
"Speed is 0. Attacks have Disadvantage. Attacks against have Advantage. Disadvantage on Dex saves.",
descriptionPf2e:
"Off-guard. Immobilized. Can't use any actions with the attack trait except to attempt to Escape.",
iconName: "Link",
color: "neutral",
},
@@ -171,7 +223,7 @@ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
description5e: "",
iconName: "ShieldMinus",
color: "amber",
edition: "5.5e",
systems: ["5.5e"],
},
{
id: "slowed",
@@ -181,7 +233,7 @@ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
description5e: "",
iconName: "Snail",
color: "sky",
edition: "5.5e",
systems: ["5.5e"],
},
{
id: "stunned",
@@ -190,8 +242,11 @@ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
"Incapacitated (can't act or speak). Auto-fail Str/Dex saves. Attacks against have Advantage.",
description5e:
"Incapacitated. Can't move. Can speak only falteringly. Auto-fail Str/Dex saves. Attacks against have Advantage.",
descriptionPf2e:
"Can't act. X value to actions per turn while the value counts down.",
iconName: "Sparkles",
color: "yellow",
valued: true,
},
{
id: "unconscious",
@@ -200,9 +255,261 @@ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
"Incapacitated. Speed 0. Can't move or speak. Unaware of surroundings. Drops held items, falls Prone. Auto-fail Str/Dex saves. Attacks against have Advantage. Hits within 5 ft. are critical.",
description5e:
"Incapacitated. Speed 0. Can't move or speak. Unaware of surroundings. Drops held items, falls Prone. Auto-fail Str/Dex saves. Attacks against have Advantage. Hits within 5 ft. are critical.",
descriptionPf2e:
"Can't act. Off-guard. 4 status penalty to AC. 3 to Perception. Fall prone, drop items.",
iconName: "Moon",
color: "indigo",
},
// ── PF2e-only conditions ──
{
id: "clumsy",
label: "Clumsy",
description: "",
description5e: "",
descriptionPf2e:
"X status penalty to Dex-based checks and DCs, including AC, Reflex saves, and ranged attack rolls.",
iconName: "Footprints",
color: "amber",
systems: ["pf2e"],
valued: true,
},
{
id: "concealed",
label: "Concealed",
description: "",
description5e: "",
descriptionPf2e:
"DC 5 flat check for targeted attacks. Doesn't change which creatures can see you.",
iconName: "CloudFog",
color: "slate",
systems: ["pf2e"],
},
{
id: "confused",
label: "Confused",
description: "",
description5e: "",
descriptionPf2e:
"Off-guard. Can't Delay, Ready, or use reactions. GM determines targets randomly. Flat check DC 11 to act normally each turn.",
iconName: "CircleHelp",
color: "pink",
systems: ["pf2e"],
},
{
id: "controlled",
label: "Controlled",
description: "",
description5e: "",
descriptionPf2e:
"Another creature determines your actions. You gain no actions of your own.",
iconName: "Drama",
color: "pink",
systems: ["pf2e"],
},
{
id: "dazzled",
label: "Dazzled",
description: "",
description5e: "",
descriptionPf2e:
"All creatures and objects are concealed to you. DC 5 flat check for targeted attacks requiring sight.",
iconName: "Sun",
color: "yellow",
systems: ["pf2e"],
},
{
id: "doomed",
label: "Doomed",
description: "",
description5e: "",
descriptionPf2e:
"Die at dying X (where X = 4 doomed value instead of dying 4). Decreases by 1 on full night's rest.",
iconName: "Skull",
color: "red",
systems: ["pf2e"],
valued: true,
},
{
id: "drained",
label: "Drained",
description: "",
description5e: "",
descriptionPf2e:
"X status penalty to Con-based checks and DCs. Lose X × Hit Die in max HP. Decreases by 1 on full night's rest.",
iconName: "Droplets",
color: "red",
systems: ["pf2e"],
valued: true,
},
{
id: "dying",
label: "Dying",
description: "",
description5e: "",
descriptionPf2e:
"Unconscious. Make recovery checks at start of turn. At dying 4 (or 4 doomed), you die.",
iconName: "HeartPulse",
color: "red",
systems: ["pf2e"],
valued: true,
},
{
id: "enfeebled",
label: "Enfeebled",
description: "",
description5e: "",
descriptionPf2e:
"X status penalty to Str-based rolls, including melee attack and damage rolls.",
iconName: "TrendingDown",
color: "amber",
systems: ["pf2e"],
valued: true,
},
{
id: "fascinated",
label: "Fascinated",
description: "",
description5e: "",
descriptionPf2e:
"2 status penalty to all checks. Can't use hostile actions. Ends if hostile action is used against you.",
iconName: "Eye",
color: "violet",
systems: ["pf2e"],
},
{
id: "fatigued",
label: "Fatigued",
description: "",
description5e: "",
descriptionPf2e:
"1 status penalty to AC and saves. Can't use exploration activities while traveling. Recover after a full night's rest.",
iconName: "BatteryLow",
color: "amber",
systems: ["pf2e"],
},
{
id: "fleeing",
label: "Fleeing",
description: "",
description5e: "",
descriptionPf2e:
"Must spend actions to move away from the source. Can't Delay or Ready.",
iconName: "PersonStanding",
color: "orange",
systems: ["pf2e"],
},
{
id: "grabbed",
label: "Grabbed",
description: "",
description5e: "",
descriptionPf2e:
"Immobilized. Off-guard. Can't use actions with the move trait unless to Break Grapple.",
iconName: "Hand",
color: "neutral",
systems: ["pf2e"],
},
{
id: "hidden",
label: "Hidden",
description: "",
description5e: "",
descriptionPf2e:
"Known location but can't be seen. DC 11 flat check to target. Can use Seek to find.",
iconName: "EyeOff",
color: "slate",
systems: ["pf2e"],
},
{
id: "immobilized",
label: "Immobilized",
description: "",
description5e: "",
descriptionPf2e:
"Can't use any action with the move trait to change position.",
iconName: "Anchor",
color: "neutral",
systems: ["pf2e"],
},
{
id: "off-guard",
label: "Off-Guard",
description: "",
description5e: "",
descriptionPf2e: "2 circumstance penalty to AC. (Formerly flat-footed.)",
iconName: "ShieldOff",
color: "amber",
systems: ["pf2e"],
},
{
id: "quickened",
label: "Quickened",
description: "",
description5e: "",
descriptionPf2e:
"Gain 1 extra action at the start of your turn each round (limited uses specified by the effect).",
iconName: "Zap",
color: "green",
systems: ["pf2e"],
},
{
id: "sickened",
label: "Sickened",
description: "",
description5e: "",
descriptionPf2e:
"X status penalty to all checks and DCs. Can't willingly ingest anything. Reduce by retching (Fortitude save).",
iconName: "Droplet",
color: "green",
systems: ["pf2e"],
valued: true,
},
{
id: "slowed-pf2e",
label: "Slowed",
description: "",
description5e: "",
descriptionPf2e: "Lose X actions at the start of your turn each round.",
iconName: "Snail",
color: "sky",
systems: ["pf2e"],
valued: true,
},
{
id: "stupefied",
label: "Stupefied",
description: "",
description5e: "",
descriptionPf2e:
"X status penalty to Int/Wis/Cha-based checks and DCs, including spell attack rolls and spell DCs. DC 5 + X flat check to cast spells or lose the spell.",
iconName: "BrainCog",
color: "violet",
systems: ["pf2e"],
valued: true,
},
{
id: "undetected",
label: "Undetected",
description: "",
description5e: "",
descriptionPf2e:
"Location unknown. Must pick a square to target; DC 11 flat check. Attacker is off-guard against your attacks.",
iconName: "Ghost",
color: "violet",
systems: ["pf2e"],
},
{
id: "wounded",
label: "Wounded",
description: "",
description5e: "",
descriptionPf2e:
"Next time you gain dying, add wounded value to dying value. Wounded 1 when you recover from dying; increases if already wounded.",
iconName: "HeartCrack",
color: "red",
systems: ["pf2e"],
valued: true,
},
] as const;
export const VALID_CONDITION_IDS: ReadonlySet<string> = new Set(
@@ -213,6 +520,6 @@ export function getConditionsForEdition(
edition: RulesEdition,
): readonly ConditionDefinition[] {
return CONDITION_DEFINITIONS.filter(
(d) => d.edition === undefined || d.edition === edition,
(d) => d.systems === undefined || d.systems.includes(edition),
);
}

View File

@@ -101,6 +101,62 @@ export interface BestiaryIndex {
readonly creatures: readonly BestiaryIndexEntry[];
}
export interface Pf2eCreature {
readonly system: "pf2e";
readonly id: CreatureId;
readonly name: string;
readonly source: string;
readonly sourceDisplayName: string;
readonly level: number;
readonly traits: readonly string[];
readonly perception: number;
readonly senses?: string;
readonly languages?: string;
readonly skills?: string;
readonly abilityMods: {
readonly str: number;
readonly dex: number;
readonly con: number;
readonly int: number;
readonly wis: number;
readonly cha: number;
};
readonly items?: string;
readonly ac: number;
readonly acConditional?: string;
readonly saveFort: number;
readonly saveRef: number;
readonly saveWill: number;
readonly hp: number;
readonly immunities?: string;
readonly resistances?: string;
readonly weaknesses?: string;
readonly speed: string;
readonly attacks?: readonly TraitBlock[];
readonly abilitiesTop?: readonly TraitBlock[];
readonly abilitiesMid?: readonly TraitBlock[];
readonly abilitiesBot?: readonly TraitBlock[];
readonly spellcasting?: readonly SpellcastingBlock[];
}
export type AnyCreature = Creature | Pf2eCreature;
export interface Pf2eBestiaryIndexEntry {
readonly name: string;
readonly source: string;
readonly level: number;
readonly ac: number;
readonly hp: number;
readonly perception: number;
readonly size: string;
readonly type: string;
}
export interface Pf2eBestiaryIndex {
readonly sources: Readonly<Record<string, string>>;
readonly creatures: readonly Pf2eBestiaryIndexEntry[];
}
/** Maps a CR string to the corresponding proficiency bonus. */
export function proficiencyBonus(cr: string): number {
const numericCr = cr.includes("/")

View File

@@ -112,12 +112,14 @@ export interface ConditionAdded {
readonly type: "ConditionAdded";
readonly combatantId: CombatantId;
readonly condition: ConditionId;
readonly value?: number;
}
export interface ConditionRemoved {
readonly type: "ConditionRemoved";
readonly combatantId: CombatantId;
readonly condition: ConditionId;
readonly value?: number;
}
export interface ConcentrationStarted {

View File

@@ -13,6 +13,7 @@ export {
export {
CONDITION_DEFINITIONS,
type ConditionDefinition,
type ConditionEntry,
type ConditionId,
getConditionDescription,
getConditionsForEdition,
@@ -23,6 +24,7 @@ export {
createPlayerCharacter,
} from "./create-player-character.js";
export {
type AnyCreature,
type BestiaryIndex,
type BestiaryIndexEntry,
type BestiarySource,
@@ -31,6 +33,9 @@ export {
creatureId,
type DailySpells,
type LegendaryBlock,
type Pf2eBestiaryIndex,
type Pf2eBestiaryIndexEntry,
type Pf2eCreature,
proficiencyBonus,
type SpellcastingBlock,
type TraitBlock,
@@ -87,6 +92,7 @@ export type { ExportBundle } from "./export-bundle.js";
export { deriveHpStatus, type HpStatus } from "./hp-status.js";
export {
calculateInitiative,
calculatePf2eInitiative,
formatInitiativeModifier,
type InitiativeResult,
} from "./initiative.js";
@@ -127,6 +133,8 @@ export {
toggleConcentration,
} from "./toggle-concentration.js";
export {
decrementCondition,
setConditionValue,
type ToggleConditionSuccess,
toggleCondition,
} from "./toggle-condition.js";

View File

@@ -20,6 +20,14 @@ export function calculateInitiative(creature: {
return { modifier, passive: 10 + modifier };
}
/**
* Returns the PF2e initiative result directly from the Perception modifier.
* No proficiency bonus calculation — PF2e uses Perception as-is.
*/
export function calculatePf2eInitiative(perception: number): InitiativeResult {
return { modifier: perception, passive: perception };
}
/**
* Formats an initiative modifier with explicit sign.
* Uses U+2212 () for negative values.

View File

@@ -1,4 +1,4 @@
import type { ConditionId } from "./conditions.js";
import type { ConditionEntry, ConditionId } from "./conditions.js";
import { VALID_CONDITION_IDS } from "./conditions.js";
import { creatureId } from "./creature-types.js";
import { VALID_CR_VALUES } from "./encounter-difficulty.js";
@@ -16,13 +16,30 @@ function validateAc(value: unknown): number | undefined {
: undefined;
}
function validateConditions(value: unknown): ConditionId[] | undefined {
function validateConditions(value: unknown): ConditionEntry[] | undefined {
if (!Array.isArray(value)) return undefined;
const valid = value.filter(
(v): v is ConditionId =>
typeof v === "string" && VALID_CONDITION_IDS.has(v),
);
return valid.length > 0 ? valid : undefined;
const entries: ConditionEntry[] = [];
for (const item of value) {
if (typeof item === "string" && VALID_CONDITION_IDS.has(item)) {
entries.push({ id: item as ConditionId });
} else if (
typeof item === "object" &&
item !== null &&
typeof (item as Record<string, unknown>).id === "string" &&
VALID_CONDITION_IDS.has((item as Record<string, unknown>).id as string)
) {
const id = (item as Record<string, unknown>).id as ConditionId;
const rawValue = (item as Record<string, unknown>).value;
const entry: ConditionEntry =
typeof rawValue === "number" &&
Number.isInteger(rawValue) &&
rawValue > 0
? { id, value: rawValue }
: { id };
entries.push(entry);
}
}
return entries.length > 0 ? entries : undefined;
}
function validateHp(

View File

@@ -1 +1 @@
export type RulesEdition = "5e" | "5.5e";
export type RulesEdition = "5e" | "5.5e" | "pf2e";

View File

@@ -1,4 +1,4 @@
import type { ConditionId } from "./conditions.js";
import type { ConditionEntry, ConditionId } from "./conditions.js";
import { CONDITION_DEFINITIONS, VALID_CONDITION_IDS } from "./conditions.js";
import type { DomainEvent } from "./events.js";
import {
@@ -14,11 +14,13 @@ export interface ToggleConditionSuccess {
readonly events: DomainEvent[];
}
export function toggleCondition(
encounter: Encounter,
combatantId: CombatantId,
conditionId: ConditionId,
): ToggleConditionSuccess | DomainError {
function sortByDefinitionOrder(entries: ConditionEntry[]): ConditionEntry[] {
const order = CONDITION_DEFINITIONS.map((d) => d.id);
entries.sort((a, b) => order.indexOf(a.id) - order.indexOf(b.id));
return entries;
}
function validateConditionId(conditionId: ConditionId): DomainError | null {
if (!VALID_CONDITION_IDS.has(conditionId)) {
return {
kind: "domain-error",
@@ -26,38 +28,157 @@ export function toggleCondition(
message: `Unknown condition "${conditionId}"`,
};
}
return null;
}
function applyConditions(
encounter: Encounter,
combatantId: CombatantId,
newConditions: readonly ConditionEntry[] | undefined,
): Encounter {
return {
combatants: encounter.combatants.map((c) =>
c.id === combatantId ? { ...c, conditions: newConditions } : c,
),
activeIndex: encounter.activeIndex,
roundNumber: encounter.roundNumber,
};
}
export function toggleCondition(
encounter: Encounter,
combatantId: CombatantId,
conditionId: ConditionId,
): ToggleConditionSuccess | DomainError {
const err = validateConditionId(conditionId);
if (err) return err;
const found = findCombatant(encounter, combatantId);
if (isDomainError(found)) return found;
const { combatant: target } = found;
const current = target.conditions ?? [];
const isActive = current.includes(conditionId);
const isActive = current.some((c) => c.id === conditionId);
let newConditions: readonly ConditionId[] | undefined;
let newConditions: readonly ConditionEntry[] | undefined;
let event: DomainEvent;
if (isActive) {
const filtered = current.filter((c) => c !== conditionId);
const filtered = current.filter((c) => c.id !== conditionId);
newConditions = filtered.length > 0 ? filtered : undefined;
event = { type: "ConditionRemoved", combatantId, condition: conditionId };
} else {
const added = [...current, conditionId];
const order = CONDITION_DEFINITIONS.map((d) => d.id);
added.sort((a, b) => order.indexOf(a) - order.indexOf(b));
const added = sortByDefinitionOrder([...current, { id: conditionId }]);
newConditions = added;
event = { type: "ConditionAdded", combatantId, condition: conditionId };
}
const updatedCombatants = encounter.combatants.map((c) =>
c.id === combatantId ? { ...c, conditions: newConditions } : c,
);
return {
encounter: {
combatants: updatedCombatants,
activeIndex: encounter.activeIndex,
roundNumber: encounter.roundNumber,
},
encounter: applyConditions(encounter, combatantId, newConditions),
events: [event],
};
}
export function setConditionValue(
encounter: Encounter,
combatantId: CombatantId,
conditionId: ConditionId,
value: number,
): ToggleConditionSuccess | DomainError {
const err = validateConditionId(conditionId);
if (err) return err;
const found = findCombatant(encounter, combatantId);
if (isDomainError(found)) return found;
const { combatant: target } = found;
const current = target.conditions ?? [];
if (value <= 0) {
const filtered = current.filter((c) => c.id !== conditionId);
const newConditions = filtered.length > 0 ? filtered : undefined;
return {
encounter: applyConditions(encounter, combatantId, newConditions),
events: [
{ type: "ConditionRemoved", combatantId, condition: conditionId },
],
};
}
const existing = current.find((c) => c.id === conditionId);
if (existing) {
const updated = current.map((c) =>
c.id === conditionId ? { ...c, value } : c,
);
return {
encounter: applyConditions(encounter, combatantId, updated),
events: [
{
type: "ConditionAdded",
combatantId,
condition: conditionId,
value,
},
],
};
}
const added = sortByDefinitionOrder([...current, { id: conditionId, value }]);
return {
encounter: applyConditions(encounter, combatantId, added),
events: [
{ type: "ConditionAdded", combatantId, condition: conditionId, value },
],
};
}
export function decrementCondition(
encounter: Encounter,
combatantId: CombatantId,
conditionId: ConditionId,
): ToggleConditionSuccess | DomainError {
const err = validateConditionId(conditionId);
if (err) return err;
const found = findCombatant(encounter, combatantId);
if (isDomainError(found)) return found;
const { combatant: target } = found;
const current = target.conditions ?? [];
const existing = current.find((c) => c.id === conditionId);
if (!existing) {
return {
kind: "domain-error",
code: "condition-not-active",
message: `Condition "${conditionId}" is not active`,
};
}
const newValue = (existing.value ?? 1) - 1;
if (newValue <= 0) {
const filtered = current.filter((c) => c.id !== conditionId);
return {
encounter: applyConditions(
encounter,
combatantId,
filtered.length > 0 ? filtered : undefined,
),
events: [
{ type: "ConditionRemoved", combatantId, condition: conditionId },
],
};
}
const updated = current.map((c) =>
c.id === conditionId ? { ...c, value: newValue } : c,
);
return {
encounter: applyConditions(encounter, combatantId, updated),
events: [
{
type: "ConditionAdded",
combatantId,
condition: conditionId,
value: newValue,
},
],
};
}

View File

@@ -5,7 +5,7 @@ export function combatantId(id: string): CombatantId {
return id as CombatantId;
}
import type { ConditionId } from "./conditions.js";
import type { ConditionEntry } from "./conditions.js";
import type { CreatureId } from "./creature-types.js";
import type { PlayerCharacterId } from "./player-character-types.js";
@@ -17,7 +17,7 @@ export interface Combatant {
readonly currentHp?: number;
readonly tempHp?: number;
readonly ac?: number;
readonly conditions?: readonly ConditionId[];
readonly conditions?: readonly ConditionEntry[];
readonly isConcentrating?: boolean;
readonly creatureId?: CreatureId;
readonly cr?: string;