Add PF2e persistent damage condition tags
Persistent damage displayed as compact tags with damage type icon and formula (e.g., Flame + "2d6"). Supports fire, bleed, acid, cold, electricity, poison, and mental types. One instance per type, added via sub-picker in the condition picker. PF2e only, persists across reload. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
237
packages/domain/src/__tests__/persistent-damage.test.ts
Normal file
237
packages/domain/src/__tests__/persistent-damage.test.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
addPersistentDamage,
|
||||
type PersistentDamageType,
|
||||
removePersistentDamage,
|
||||
} from "../persistent-damage.js";
|
||||
import type { Encounter } from "../types.js";
|
||||
import { combatantId } from "../types.js";
|
||||
|
||||
const goblinId = combatantId("goblin-1");
|
||||
|
||||
function buildEncounter(overrides: Partial<Encounter> = {}): Encounter {
|
||||
return {
|
||||
combatants: [
|
||||
{
|
||||
id: goblinId,
|
||||
name: "Goblin",
|
||||
...overrides.combatants?.[0],
|
||||
},
|
||||
],
|
||||
activeIndex: overrides.activeIndex ?? 0,
|
||||
roundNumber: overrides.roundNumber ?? 1,
|
||||
};
|
||||
}
|
||||
|
||||
describe("addPersistentDamage", () => {
|
||||
it("adds persistent fire damage to combatant", () => {
|
||||
const encounter = buildEncounter();
|
||||
const result = addPersistentDamage(encounter, goblinId, "fire", "2d6");
|
||||
|
||||
expect(result).not.toHaveProperty("kind");
|
||||
if ("kind" in result) return;
|
||||
|
||||
const target = result.encounter.combatants[0];
|
||||
expect(target.persistentDamage).toEqual([{ type: "fire", formula: "2d6" }]);
|
||||
expect(result.events).toEqual([
|
||||
{
|
||||
type: "PersistentDamageAdded",
|
||||
combatantId: goblinId,
|
||||
damageType: "fire",
|
||||
formula: "2d6",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("replaces existing entry of same type with new formula", () => {
|
||||
const encounter = buildEncounter({
|
||||
combatants: [
|
||||
{
|
||||
id: goblinId,
|
||||
name: "Goblin",
|
||||
persistentDamage: [{ type: "fire", formula: "2d6" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
const result = addPersistentDamage(encounter, goblinId, "fire", "3d6");
|
||||
|
||||
expect(result).not.toHaveProperty("kind");
|
||||
if ("kind" in result) return;
|
||||
|
||||
expect(result.encounter.combatants[0].persistentDamage).toEqual([
|
||||
{ type: "fire", formula: "3d6" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("allows multiple different damage types", () => {
|
||||
const encounter = buildEncounter({
|
||||
combatants: [
|
||||
{
|
||||
id: goblinId,
|
||||
name: "Goblin",
|
||||
persistentDamage: [{ type: "fire", formula: "2d6" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
const result = addPersistentDamage(encounter, goblinId, "bleed", "1d4");
|
||||
|
||||
expect(result).not.toHaveProperty("kind");
|
||||
if ("kind" in result) return;
|
||||
|
||||
expect(result.encounter.combatants[0].persistentDamage).toEqual([
|
||||
{ type: "fire", formula: "2d6" },
|
||||
{ type: "bleed", formula: "1d4" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("sorts entries by definition order", () => {
|
||||
const encounter = buildEncounter({
|
||||
combatants: [
|
||||
{
|
||||
id: goblinId,
|
||||
name: "Goblin",
|
||||
persistentDamage: [{ type: "cold", formula: "1d6" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
const result = addPersistentDamage(encounter, goblinId, "fire", "2d6");
|
||||
|
||||
expect(result).not.toHaveProperty("kind");
|
||||
if ("kind" in result) return;
|
||||
|
||||
const types = result.encounter.combatants[0].persistentDamage?.map(
|
||||
(e) => e.type,
|
||||
);
|
||||
expect(types).toEqual(["fire", "cold"]);
|
||||
});
|
||||
|
||||
it("returns domain error for empty formula", () => {
|
||||
const encounter = buildEncounter();
|
||||
const result = addPersistentDamage(encounter, goblinId, "fire", " ");
|
||||
|
||||
expect(result).toHaveProperty("kind", "domain-error");
|
||||
if (!("kind" in result)) return;
|
||||
expect(result.code).toBe("empty-formula");
|
||||
});
|
||||
|
||||
it("returns domain error for unknown damage type", () => {
|
||||
const encounter = buildEncounter();
|
||||
const result = addPersistentDamage(
|
||||
encounter,
|
||||
goblinId,
|
||||
"radiant" as PersistentDamageType,
|
||||
"2d6",
|
||||
);
|
||||
|
||||
expect(result).toHaveProperty("kind", "domain-error");
|
||||
if (!("kind" in result)) return;
|
||||
expect(result.code).toBe("unknown-damage-type");
|
||||
});
|
||||
|
||||
it("returns domain error for unknown combatant", () => {
|
||||
const encounter = buildEncounter();
|
||||
const result = addPersistentDamage(
|
||||
encounter,
|
||||
combatantId("nonexistent"),
|
||||
"fire",
|
||||
"2d6",
|
||||
);
|
||||
|
||||
expect(result).toHaveProperty("kind", "domain-error");
|
||||
if (!("kind" in result)) return;
|
||||
expect(result.code).toBe("combatant-not-found");
|
||||
});
|
||||
|
||||
it("trims formula whitespace", () => {
|
||||
const encounter = buildEncounter();
|
||||
const result = addPersistentDamage(encounter, goblinId, "fire", " 2d6 ");
|
||||
|
||||
expect(result).not.toHaveProperty("kind");
|
||||
if ("kind" in result) return;
|
||||
|
||||
expect(result.encounter.combatants[0].persistentDamage?.[0].formula).toBe(
|
||||
"2d6",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not mutate input encounter", () => {
|
||||
const encounter = buildEncounter();
|
||||
const originalCombatants = encounter.combatants;
|
||||
addPersistentDamage(encounter, goblinId, "fire", "2d6");
|
||||
|
||||
expect(encounter.combatants).toBe(originalCombatants);
|
||||
expect(encounter.combatants[0].persistentDamage).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("removePersistentDamage", () => {
|
||||
it("removes existing persistent damage entry", () => {
|
||||
const encounter = buildEncounter({
|
||||
combatants: [
|
||||
{
|
||||
id: goblinId,
|
||||
name: "Goblin",
|
||||
persistentDamage: [
|
||||
{ type: "fire", formula: "2d6" },
|
||||
{ type: "bleed", formula: "1d4" },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
const result = removePersistentDamage(encounter, goblinId, "fire");
|
||||
|
||||
expect(result).not.toHaveProperty("kind");
|
||||
if ("kind" in result) return;
|
||||
|
||||
expect(result.encounter.combatants[0].persistentDamage).toEqual([
|
||||
{ type: "bleed", formula: "1d4" },
|
||||
]);
|
||||
expect(result.events).toEqual([
|
||||
{
|
||||
type: "PersistentDamageRemoved",
|
||||
combatantId: goblinId,
|
||||
damageType: "fire",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("sets persistentDamage to undefined when last entry removed", () => {
|
||||
const encounter = buildEncounter({
|
||||
combatants: [
|
||||
{
|
||||
id: goblinId,
|
||||
name: "Goblin",
|
||||
persistentDamage: [{ type: "fire", formula: "2d6" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
const result = removePersistentDamage(encounter, goblinId, "fire");
|
||||
|
||||
expect(result).not.toHaveProperty("kind");
|
||||
if ("kind" in result) return;
|
||||
|
||||
expect(result.encounter.combatants[0].persistentDamage).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns domain error when damage type not active", () => {
|
||||
const encounter = buildEncounter();
|
||||
const result = removePersistentDamage(encounter, goblinId, "fire");
|
||||
|
||||
expect(result).toHaveProperty("kind", "domain-error");
|
||||
if (!("kind" in result)) return;
|
||||
expect(result.code).toBe("persistent-damage-not-active");
|
||||
});
|
||||
|
||||
it("returns domain error for unknown combatant", () => {
|
||||
const encounter = buildEncounter();
|
||||
const result = removePersistentDamage(
|
||||
encounter,
|
||||
combatantId("nonexistent"),
|
||||
"fire",
|
||||
);
|
||||
|
||||
expect(result).toHaveProperty("kind", "domain-error");
|
||||
if (!("kind" in result)) return;
|
||||
expect(result.code).toBe("combatant-not-found");
|
||||
});
|
||||
});
|
||||
@@ -301,6 +301,52 @@ describe("rehydrateCombatant", () => {
|
||||
expect(result?.side).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves valid persistent damage entries", () => {
|
||||
const result = rehydrateCombatant({
|
||||
...minimalCombatant(),
|
||||
persistentDamage: [
|
||||
{ type: "fire", formula: "2d6" },
|
||||
{ type: "bleed", formula: "1d4" },
|
||||
],
|
||||
});
|
||||
expect(result?.persistentDamage).toEqual([
|
||||
{ type: "fire", formula: "2d6" },
|
||||
{ type: "bleed", formula: "1d4" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("filters out invalid persistent damage entries", () => {
|
||||
const result = rehydrateCombatant({
|
||||
...minimalCombatant(),
|
||||
persistentDamage: [
|
||||
{ type: "fire", formula: "2d6" },
|
||||
{ type: "radiant", formula: "1d4" },
|
||||
{ type: "bleed", formula: "" },
|
||||
{ type: "acid" },
|
||||
{ formula: "1d6" },
|
||||
],
|
||||
});
|
||||
expect(result?.persistentDamage).toEqual([
|
||||
{ type: "fire", formula: "2d6" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns undefined persistentDamage for non-array value", () => {
|
||||
const result = rehydrateCombatant({
|
||||
...minimalCombatant(),
|
||||
persistentDamage: "fire",
|
||||
});
|
||||
expect(result?.persistentDamage).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined persistentDamage for empty array", () => {
|
||||
const result = rehydrateCombatant({
|
||||
...minimalCombatant(),
|
||||
persistentDamage: [],
|
||||
});
|
||||
expect(result?.persistentDamage).toBeUndefined();
|
||||
});
|
||||
|
||||
it("drops invalid tempHp — keeps combatant", () => {
|
||||
for (const tempHp of [-1, 1.5, "3"]) {
|
||||
const result = rehydrateCombatant({
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ConditionId } from "./conditions.js";
|
||||
import type { CreatureId } from "./creature-types.js";
|
||||
import type { PersistentDamageType } from "./persistent-damage.js";
|
||||
import type { PlayerCharacterId } from "./player-character-types.js";
|
||||
import type { CombatantId } from "./types.js";
|
||||
|
||||
@@ -132,6 +133,19 @@ export interface ConcentrationEnded {
|
||||
readonly combatantId: CombatantId;
|
||||
}
|
||||
|
||||
export interface PersistentDamageAdded {
|
||||
readonly type: "PersistentDamageAdded";
|
||||
readonly combatantId: CombatantId;
|
||||
readonly damageType: PersistentDamageType;
|
||||
readonly formula: string;
|
||||
}
|
||||
|
||||
export interface PersistentDamageRemoved {
|
||||
readonly type: "PersistentDamageRemoved";
|
||||
readonly combatantId: CombatantId;
|
||||
readonly damageType: PersistentDamageType;
|
||||
}
|
||||
|
||||
export interface CreatureAdjustmentSet {
|
||||
readonly type: "CreatureAdjustmentSet";
|
||||
readonly combatantId: CombatantId;
|
||||
@@ -181,6 +195,8 @@ export type DomainEvent =
|
||||
| ConditionRemoved
|
||||
| ConcentrationStarted
|
||||
| ConcentrationEnded
|
||||
| PersistentDamageAdded
|
||||
| PersistentDamageRemoved
|
||||
| CreatureAdjustmentSet
|
||||
| EncounterCleared
|
||||
| PlayerCharacterCreated
|
||||
|
||||
@@ -82,6 +82,8 @@ export type {
|
||||
EncounterCleared,
|
||||
InitiativeSet,
|
||||
MaxHpSet,
|
||||
PersistentDamageAdded,
|
||||
PersistentDamageRemoved,
|
||||
PlayerCharacterCreated,
|
||||
PlayerCharacterDeleted,
|
||||
PlayerCharacterUpdated,
|
||||
@@ -100,6 +102,17 @@ export {
|
||||
formatInitiativeModifier,
|
||||
type InitiativeResult,
|
||||
} from "./initiative.js";
|
||||
export {
|
||||
addPersistentDamage,
|
||||
PERSISTENT_DAMAGE_DEFINITIONS,
|
||||
PERSISTENT_DAMAGE_TYPES,
|
||||
type PersistentDamageDefinition,
|
||||
type PersistentDamageEntry,
|
||||
type PersistentDamageSuccess,
|
||||
type PersistentDamageType,
|
||||
removePersistentDamage,
|
||||
VALID_PERSISTENT_DAMAGE_TYPES,
|
||||
} from "./persistent-damage.js";
|
||||
export {
|
||||
acDelta,
|
||||
adjustedLevel,
|
||||
|
||||
165
packages/domain/src/persistent-damage.ts
Normal file
165
packages/domain/src/persistent-damage.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import type { DomainEvent } from "./events.js";
|
||||
import {
|
||||
type CombatantId,
|
||||
type DomainError,
|
||||
type Encounter,
|
||||
findCombatant,
|
||||
isDomainError,
|
||||
} from "./types.js";
|
||||
|
||||
export const PERSISTENT_DAMAGE_TYPES = [
|
||||
"fire",
|
||||
"bleed",
|
||||
"acid",
|
||||
"cold",
|
||||
"electricity",
|
||||
"poison",
|
||||
"mental",
|
||||
] as const;
|
||||
|
||||
export type PersistentDamageType = (typeof PERSISTENT_DAMAGE_TYPES)[number];
|
||||
|
||||
export const VALID_PERSISTENT_DAMAGE_TYPES: ReadonlySet<string> = new Set(
|
||||
PERSISTENT_DAMAGE_TYPES,
|
||||
);
|
||||
|
||||
export interface PersistentDamageEntry {
|
||||
readonly type: PersistentDamageType;
|
||||
readonly formula: string;
|
||||
}
|
||||
|
||||
export interface PersistentDamageDefinition {
|
||||
readonly type: PersistentDamageType;
|
||||
readonly label: string;
|
||||
readonly iconName: string;
|
||||
readonly color: string;
|
||||
}
|
||||
|
||||
export const PERSISTENT_DAMAGE_DEFINITIONS: readonly PersistentDamageDefinition[] =
|
||||
[
|
||||
{ type: "fire", label: "Fire", iconName: "Flame", color: "orange" },
|
||||
{ type: "bleed", label: "Bleed", iconName: "Droplets", color: "red" },
|
||||
{
|
||||
type: "acid",
|
||||
label: "Acid",
|
||||
iconName: "FlaskConical",
|
||||
color: "lime",
|
||||
},
|
||||
{ type: "cold", label: "Cold", iconName: "Snowflake", color: "sky" },
|
||||
{
|
||||
type: "electricity",
|
||||
label: "Electricity",
|
||||
iconName: "Zap",
|
||||
color: "yellow",
|
||||
},
|
||||
{
|
||||
type: "poison",
|
||||
label: "Poison",
|
||||
iconName: "Droplet",
|
||||
color: "green",
|
||||
},
|
||||
{
|
||||
type: "mental",
|
||||
label: "Mental",
|
||||
iconName: "BrainCog",
|
||||
color: "pink",
|
||||
},
|
||||
];
|
||||
|
||||
export interface PersistentDamageSuccess {
|
||||
readonly encounter: Encounter;
|
||||
readonly events: DomainEvent[];
|
||||
}
|
||||
|
||||
function applyPersistentDamage(
|
||||
encounter: Encounter,
|
||||
combatantId: CombatantId,
|
||||
newEntries: readonly PersistentDamageEntry[] | undefined,
|
||||
): Encounter {
|
||||
return {
|
||||
combatants: encounter.combatants.map((c) =>
|
||||
c.id === combatantId ? { ...c, persistentDamage: newEntries } : c,
|
||||
),
|
||||
activeIndex: encounter.activeIndex,
|
||||
roundNumber: encounter.roundNumber,
|
||||
};
|
||||
}
|
||||
|
||||
export function addPersistentDamage(
|
||||
encounter: Encounter,
|
||||
combatantId: CombatantId,
|
||||
damageType: PersistentDamageType,
|
||||
formula: string,
|
||||
): PersistentDamageSuccess | DomainError {
|
||||
if (!VALID_PERSISTENT_DAMAGE_TYPES.has(damageType)) {
|
||||
return {
|
||||
kind: "domain-error",
|
||||
code: "unknown-damage-type",
|
||||
message: `Unknown persistent damage type "${damageType}"`,
|
||||
};
|
||||
}
|
||||
if (formula.trim().length === 0) {
|
||||
return {
|
||||
kind: "domain-error",
|
||||
code: "empty-formula",
|
||||
message: "Persistent damage formula must not be empty",
|
||||
};
|
||||
}
|
||||
|
||||
const found = findCombatant(encounter, combatantId);
|
||||
if (isDomainError(found)) return found;
|
||||
const { combatant: target } = found;
|
||||
const current = target.persistentDamage ?? [];
|
||||
|
||||
// Replace existing entry of same type, or append
|
||||
const filtered = current.filter((e) => e.type !== damageType);
|
||||
const newEntries = [
|
||||
...filtered,
|
||||
{ type: damageType, formula: formula.trim() },
|
||||
];
|
||||
|
||||
// Sort by definition order
|
||||
const order = PERSISTENT_DAMAGE_DEFINITIONS.map((d) => d.type);
|
||||
newEntries.sort((a, b) => order.indexOf(a.type) - order.indexOf(b.type));
|
||||
|
||||
return {
|
||||
encounter: applyPersistentDamage(encounter, combatantId, newEntries),
|
||||
events: [
|
||||
{
|
||||
type: "PersistentDamageAdded",
|
||||
combatantId,
|
||||
damageType,
|
||||
formula: formula.trim(),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function removePersistentDamage(
|
||||
encounter: Encounter,
|
||||
combatantId: CombatantId,
|
||||
damageType: PersistentDamageType,
|
||||
): PersistentDamageSuccess | DomainError {
|
||||
const found = findCombatant(encounter, combatantId);
|
||||
if (isDomainError(found)) return found;
|
||||
const { combatant: target } = found;
|
||||
const current = target.persistentDamage ?? [];
|
||||
|
||||
if (!current.some((e) => e.type === damageType)) {
|
||||
return {
|
||||
kind: "domain-error",
|
||||
code: "persistent-damage-not-active",
|
||||
message: `Persistent ${damageType} damage is not active`,
|
||||
};
|
||||
}
|
||||
|
||||
const filtered = current.filter((e) => e.type !== damageType);
|
||||
return {
|
||||
encounter: applyPersistentDamage(
|
||||
encounter,
|
||||
combatantId,
|
||||
filtered.length > 0 ? filtered : undefined,
|
||||
),
|
||||
events: [{ type: "PersistentDamageRemoved", combatantId, damageType }],
|
||||
};
|
||||
}
|
||||
@@ -2,6 +2,8 @@ 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";
|
||||
import type { PersistentDamageEntry } from "./persistent-damage.js";
|
||||
import { VALID_PERSISTENT_DAMAGE_TYPES } from "./persistent-damage.js";
|
||||
import {
|
||||
playerCharacterId,
|
||||
VALID_PLAYER_COLORS,
|
||||
@@ -42,6 +44,32 @@ function validateConditions(value: unknown): ConditionEntry[] | undefined {
|
||||
return entries.length > 0 ? entries : undefined;
|
||||
}
|
||||
|
||||
function validatePersistentDamage(
|
||||
value: unknown,
|
||||
): PersistentDamageEntry[] | undefined {
|
||||
if (!Array.isArray(value)) return undefined;
|
||||
const entries: PersistentDamageEntry[] = [];
|
||||
for (const item of value) {
|
||||
if (
|
||||
typeof item === "object" &&
|
||||
item !== null &&
|
||||
typeof (item as Record<string, unknown>).type === "string" &&
|
||||
VALID_PERSISTENT_DAMAGE_TYPES.has(
|
||||
(item as Record<string, unknown>).type as string,
|
||||
) &&
|
||||
typeof (item as Record<string, unknown>).formula === "string" &&
|
||||
((item as Record<string, unknown>).formula as string).length > 0
|
||||
) {
|
||||
entries.push({
|
||||
type: (item as Record<string, unknown>)
|
||||
.type as PersistentDamageEntry["type"],
|
||||
formula: (item as Record<string, unknown>).formula as string,
|
||||
});
|
||||
}
|
||||
}
|
||||
return entries.length > 0 ? entries : undefined;
|
||||
}
|
||||
|
||||
function validateHp(
|
||||
rawMaxHp: unknown,
|
||||
rawCurrentHp: unknown,
|
||||
@@ -107,6 +135,7 @@ function parseOptionalFields(entry: Record<string, unknown>) {
|
||||
initiative: validateInteger(entry.initiative),
|
||||
ac: validateAc(entry.ac),
|
||||
conditions: validateConditions(entry.conditions),
|
||||
persistentDamage: validatePersistentDamage(entry.persistentDamage),
|
||||
isConcentrating: entry.isConcentrating === true ? true : undefined,
|
||||
creatureId: validateNonEmptyString(entry.creatureId)
|
||||
? creatureId(entry.creatureId as string)
|
||||
|
||||
@@ -7,6 +7,7 @@ export function combatantId(id: string): CombatantId {
|
||||
|
||||
import type { ConditionEntry } from "./conditions.js";
|
||||
import type { CreatureId } from "./creature-types.js";
|
||||
import type { PersistentDamageEntry } from "./persistent-damage.js";
|
||||
import type { PlayerCharacterId } from "./player-character-types.js";
|
||||
|
||||
export interface Combatant {
|
||||
@@ -18,6 +19,7 @@ export interface Combatant {
|
||||
readonly tempHp?: number;
|
||||
readonly ac?: number;
|
||||
readonly conditions?: readonly ConditionEntry[];
|
||||
readonly persistentDamage?: readonly PersistentDamageEntry[];
|
||||
readonly isConcentrating?: boolean;
|
||||
readonly creatureId?: CreatureId;
|
||||
readonly creatureAdjustment?: "weak" | "elite";
|
||||
|
||||
Reference in New Issue
Block a user