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>
335 lines
8.8 KiB
TypeScript
335 lines
8.8 KiB
TypeScript
import type {
|
|
CreatureId,
|
|
Pf2eCreature,
|
|
TraitBlock,
|
|
TraitSegment,
|
|
} from "@initiative/domain";
|
|
import { creatureId } from "@initiative/domain";
|
|
import { stripTags } from "./strip-tags.js";
|
|
|
|
// -- Raw Pf2eTools types (minimal, for parsing) --
|
|
|
|
interface RawPf2eCreature {
|
|
name: string;
|
|
source: string;
|
|
level?: number;
|
|
traits?: string[];
|
|
perception?: { std?: number };
|
|
senses?: { name?: string; type?: string }[];
|
|
languages?: { languages?: string[] };
|
|
skills?: Record<string, { std?: number }>;
|
|
abilityMods?: Record<string, number>;
|
|
items?: string[];
|
|
defenses?: RawDefenses;
|
|
speed?: Record<string, number | { number: number }>;
|
|
attacks?: RawAttack[];
|
|
abilities?: {
|
|
top?: RawAbility[];
|
|
mid?: RawAbility[];
|
|
bot?: RawAbility[];
|
|
};
|
|
_copy?: unknown;
|
|
}
|
|
|
|
interface RawDefenses {
|
|
ac?: Record<string, unknown>;
|
|
savingThrows?: {
|
|
fort?: { std?: number };
|
|
ref?: { std?: number };
|
|
will?: { std?: number };
|
|
};
|
|
hp?: { hp?: number }[];
|
|
immunities?: (string | { name: string })[];
|
|
resistances?: { amount: number; name: string; note?: string }[];
|
|
weaknesses?: { amount: number; name: string; note?: string }[];
|
|
}
|
|
|
|
interface RawAbility {
|
|
name?: string;
|
|
entries?: RawEntry[];
|
|
}
|
|
|
|
interface RawAttack {
|
|
range?: string;
|
|
name: string;
|
|
attack?: number;
|
|
traits?: string[];
|
|
damage?: string;
|
|
}
|
|
|
|
type RawEntry = string | RawEntryObject;
|
|
|
|
interface RawEntryObject {
|
|
type?: string;
|
|
items?: (string | { name?: string; entry?: string })[];
|
|
entries?: RawEntry[];
|
|
}
|
|
|
|
// -- Module state --
|
|
|
|
let sourceDisplayNames: Record<string, string> = {};
|
|
|
|
export function setPf2eSourceDisplayNames(names: Record<string, string>): void {
|
|
sourceDisplayNames = names;
|
|
}
|
|
|
|
// -- Helpers --
|
|
|
|
function capitalize(s: string): string {
|
|
return s.charAt(0).toUpperCase() + s.slice(1);
|
|
}
|
|
|
|
function makeCreatureId(source: string, name: string): CreatureId {
|
|
const slug = name
|
|
.toLowerCase()
|
|
.replaceAll(/[^a-z0-9]+/g, "-")
|
|
.replaceAll(/(^-|-$)/g, "");
|
|
return creatureId(`${source.toLowerCase()}:${slug}`);
|
|
}
|
|
|
|
function formatSpeed(
|
|
speed: Record<string, number | { number: number }> | undefined,
|
|
): string {
|
|
if (!speed) return "";
|
|
const parts: string[] = [];
|
|
for (const [mode, value] of Object.entries(speed)) {
|
|
if (typeof value === "number") {
|
|
parts.push(
|
|
mode === "walk" ? `${value} feet` : `${capitalize(mode)} ${value} feet`,
|
|
);
|
|
} else if (typeof value === "object" && "number" in value) {
|
|
parts.push(
|
|
mode === "walk"
|
|
? `${value.number} feet`
|
|
: `${capitalize(mode)} ${value.number} feet`,
|
|
);
|
|
}
|
|
}
|
|
return parts.join(", ");
|
|
}
|
|
|
|
function formatSkills(
|
|
skills: Record<string, { std?: number }> | undefined,
|
|
): string | undefined {
|
|
if (!skills) return undefined;
|
|
const parts = Object.entries(skills)
|
|
.map(([name, val]) => `${capitalize(name)} +${val.std ?? 0}`)
|
|
.sort();
|
|
return parts.length > 0 ? parts.join(", ") : undefined;
|
|
}
|
|
|
|
function formatSenses(
|
|
senses: readonly { name?: string; type?: string }[] | undefined,
|
|
): string | undefined {
|
|
if (!senses || senses.length === 0) return undefined;
|
|
return senses
|
|
.map((s) => {
|
|
const label = s.name ?? s.type ?? "";
|
|
return label ? capitalize(label) : "";
|
|
})
|
|
.filter(Boolean)
|
|
.join(", ");
|
|
}
|
|
|
|
function formatLanguages(
|
|
languages: { languages?: string[] } | undefined,
|
|
): string | undefined {
|
|
if (!languages?.languages || languages.languages.length === 0)
|
|
return undefined;
|
|
return languages.languages.map(capitalize).join(", ");
|
|
}
|
|
|
|
function formatImmunities(
|
|
immunities: readonly (string | { name: string })[] | undefined,
|
|
): string | undefined {
|
|
if (!immunities || immunities.length === 0) return undefined;
|
|
return immunities
|
|
.map((i) => capitalize(typeof i === "string" ? i : i.name))
|
|
.join(", ");
|
|
}
|
|
|
|
function formatResistances(
|
|
resistances:
|
|
| readonly { amount: number; name: string; note?: string }[]
|
|
| undefined,
|
|
): string | undefined {
|
|
if (!resistances || resistances.length === 0) return undefined;
|
|
return resistances
|
|
.map((r) =>
|
|
r.note
|
|
? `${capitalize(r.name)} ${r.amount} (${r.note})`
|
|
: `${capitalize(r.name)} ${r.amount}`,
|
|
)
|
|
.join(", ");
|
|
}
|
|
|
|
function formatWeaknesses(
|
|
weaknesses:
|
|
| readonly { amount: number; name: string; note?: string }[]
|
|
| undefined,
|
|
): string | undefined {
|
|
if (!weaknesses || weaknesses.length === 0) return undefined;
|
|
return weaknesses
|
|
.map((w) =>
|
|
w.note
|
|
? `${capitalize(w.name)} ${w.amount} (${w.note})`
|
|
: `${capitalize(w.name)} ${w.amount}`,
|
|
)
|
|
.join(", ");
|
|
}
|
|
|
|
// -- Entry parsing --
|
|
|
|
function segmentizeEntries(entries: unknown): TraitSegment[] {
|
|
if (!Array.isArray(entries)) return [];
|
|
const segments: TraitSegment[] = [];
|
|
for (const entry of entries) {
|
|
if (typeof entry === "string") {
|
|
segments.push({ type: "text", value: stripTags(entry) });
|
|
} else if (typeof entry === "object" && entry !== null) {
|
|
const obj = entry as RawEntryObject;
|
|
if (obj.type === "list" && Array.isArray(obj.items)) {
|
|
segments.push({
|
|
type: "list",
|
|
items: obj.items.map((item) => {
|
|
if (typeof item === "string") {
|
|
return { text: stripTags(item) };
|
|
}
|
|
return { label: item.name, text: stripTags(item.entry ?? "") };
|
|
}),
|
|
});
|
|
} else if (Array.isArray(obj.entries)) {
|
|
segments.push(...segmentizeEntries(obj.entries));
|
|
}
|
|
}
|
|
}
|
|
return segments;
|
|
}
|
|
|
|
function formatAffliction(a: Record<string, unknown>): TraitSegment[] {
|
|
const parts: string[] = [];
|
|
if (a.note) parts.push(stripTags(String(a.note)));
|
|
if (a.DC) parts.push(`DC ${a.DC}`);
|
|
if (a.savingThrow) parts.push(String(a.savingThrow));
|
|
const stages = a.stages as
|
|
| { stage: number; entry: string; duration: string }[]
|
|
| undefined;
|
|
if (stages) {
|
|
for (const s of stages) {
|
|
parts.push(`Stage ${s.stage}: ${stripTags(s.entry)} (${s.duration})`);
|
|
}
|
|
}
|
|
return parts.length > 0 ? [{ type: "text", value: parts.join("; ") }] : [];
|
|
}
|
|
|
|
function normalizeAbilities(
|
|
abilities: readonly RawAbility[] | undefined,
|
|
): TraitBlock[] | undefined {
|
|
if (!abilities || abilities.length === 0) return undefined;
|
|
return abilities
|
|
.filter((a) => a.name)
|
|
.map((a) => {
|
|
const raw = a as Record<string, unknown>;
|
|
return {
|
|
name: stripTags(a.name as string),
|
|
segments: Array.isArray(a.entries)
|
|
? segmentizeEntries(a.entries)
|
|
: formatAffliction(raw),
|
|
};
|
|
});
|
|
}
|
|
|
|
function normalizeAttacks(
|
|
attacks: readonly RawAttack[] | undefined,
|
|
): TraitBlock[] | undefined {
|
|
if (!attacks || attacks.length === 0) return undefined;
|
|
return attacks.map((a) => {
|
|
const parts: string[] = [];
|
|
if (a.range) parts.push(a.range);
|
|
const attackMod = a.attack == null ? "" : ` +${a.attack}`;
|
|
const traits =
|
|
a.traits && a.traits.length > 0
|
|
? ` (${a.traits.map((t) => stripTags(t)).join(", ")})`
|
|
: "";
|
|
const damage = a.damage ? `, ${stripTags(a.damage)}` : "";
|
|
return {
|
|
name: capitalize(stripTags(a.name)),
|
|
segments: [
|
|
{
|
|
type: "text" as const,
|
|
value: `${parts.join(" ")}${attackMod}${traits}${damage}`,
|
|
},
|
|
],
|
|
};
|
|
});
|
|
}
|
|
|
|
// -- Defenses extraction --
|
|
|
|
function extractDefenses(defenses: RawDefenses | undefined) {
|
|
const acRecord = defenses?.ac ?? {};
|
|
const acStd = (acRecord.std as number | undefined) ?? 0;
|
|
const acEntries = Object.entries(acRecord).filter(([k]) => k !== "std");
|
|
return {
|
|
ac: acStd,
|
|
acConditional:
|
|
acEntries.length > 0
|
|
? acEntries.map(([k, v]) => `${v} ${k}`).join(", ")
|
|
: undefined,
|
|
saveFort: defenses?.savingThrows?.fort?.std ?? 0,
|
|
saveRef: defenses?.savingThrows?.ref?.std ?? 0,
|
|
saveWill: defenses?.savingThrows?.will?.std ?? 0,
|
|
hp: defenses?.hp?.[0]?.hp ?? 0,
|
|
immunities: formatImmunities(defenses?.immunities),
|
|
resistances: formatResistances(defenses?.resistances),
|
|
weaknesses: formatWeaknesses(defenses?.weaknesses),
|
|
};
|
|
}
|
|
|
|
// -- Main normalization --
|
|
|
|
function normalizeCreature(raw: RawPf2eCreature): Pf2eCreature {
|
|
const source = raw.source ?? "";
|
|
const defenses = extractDefenses(raw.defenses);
|
|
const mods = raw.abilityMods ?? {};
|
|
|
|
return {
|
|
system: "pf2e",
|
|
id: makeCreatureId(source, raw.name),
|
|
name: raw.name,
|
|
source,
|
|
sourceDisplayName: sourceDisplayNames[source] ?? source,
|
|
level: raw.level ?? 0,
|
|
traits: raw.traits ?? [],
|
|
perception: raw.perception?.std ?? 0,
|
|
senses: formatSenses(raw.senses),
|
|
languages: formatLanguages(raw.languages),
|
|
skills: formatSkills(raw.skills),
|
|
abilityMods: {
|
|
str: mods.str ?? 0,
|
|
dex: mods.dex ?? 0,
|
|
con: mods.con ?? 0,
|
|
int: mods.int ?? 0,
|
|
wis: mods.wis ?? 0,
|
|
cha: mods.cha ?? 0,
|
|
},
|
|
...defenses,
|
|
speed: formatSpeed(raw.speed),
|
|
attacks: normalizeAttacks(raw.attacks),
|
|
abilitiesTop: normalizeAbilities(raw.abilities?.top),
|
|
abilitiesMid: normalizeAbilities(raw.abilities?.mid),
|
|
abilitiesBot: normalizeAbilities(raw.abilities?.bot),
|
|
};
|
|
}
|
|
|
|
export function normalizePf2eBestiary(raw: {
|
|
creature: unknown[];
|
|
}): Pf2eCreature[] {
|
|
return (raw.creature ?? [])
|
|
.filter((c: unknown) => {
|
|
const obj = c as { _copy?: unknown };
|
|
return !obj._copy;
|
|
})
|
|
.map((c) => normalizeCreature(c as RawPf2eCreature));
|
|
}
|