Add Pathfinder 2e game system mode
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:
@@ -46,17 +46,17 @@ describe("bestiary-cache fallback (IndexedDB unavailable)", () => {
|
||||
|
||||
it("cacheSource falls back to in-memory store", async () => {
|
||||
const creatures = [makeCreature("mm:goblin", "Goblin")];
|
||||
await cacheSource("MM", "Monster Manual", creatures);
|
||||
await cacheSource("dnd", "MM", "Monster Manual", creatures);
|
||||
|
||||
expect(await isSourceCached("MM")).toBe(true);
|
||||
expect(await isSourceCached("dnd", "MM")).toBe(true);
|
||||
});
|
||||
|
||||
it("isSourceCached returns false for uncached source", async () => {
|
||||
expect(await isSourceCached("XGE")).toBe(false);
|
||||
expect(await isSourceCached("dnd", "XGE")).toBe(false);
|
||||
});
|
||||
|
||||
it("getCachedSources returns sources from in-memory store", async () => {
|
||||
await cacheSource("MM", "Monster Manual", [
|
||||
await cacheSource("dnd", "MM", "Monster Manual", [
|
||||
makeCreature("mm:goblin", "Goblin"),
|
||||
]);
|
||||
|
||||
@@ -68,7 +68,7 @@ describe("bestiary-cache fallback (IndexedDB unavailable)", () => {
|
||||
|
||||
it("loadAllCachedCreatures assembles creatures from in-memory store", async () => {
|
||||
const goblin = makeCreature("mm:goblin", "Goblin");
|
||||
await cacheSource("MM", "Monster Manual", [goblin]);
|
||||
await cacheSource("dnd", "MM", "Monster Manual", [goblin]);
|
||||
|
||||
const map = await loadAllCachedCreatures();
|
||||
expect(map.size).toBe(1);
|
||||
@@ -76,17 +76,17 @@ describe("bestiary-cache fallback (IndexedDB unavailable)", () => {
|
||||
});
|
||||
|
||||
it("clearSource removes a single source from in-memory store", async () => {
|
||||
await cacheSource("MM", "Monster Manual", []);
|
||||
await cacheSource("VGM", "Volo's Guide", []);
|
||||
await cacheSource("dnd", "MM", "Monster Manual", []);
|
||||
await cacheSource("dnd", "VGM", "Volo's Guide", []);
|
||||
|
||||
await clearSource("MM");
|
||||
await clearSource("dnd", "MM");
|
||||
|
||||
expect(await isSourceCached("MM")).toBe(false);
|
||||
expect(await isSourceCached("VGM")).toBe(true);
|
||||
expect(await isSourceCached("dnd", "MM")).toBe(false);
|
||||
expect(await isSourceCached("dnd", "VGM")).toBe(true);
|
||||
});
|
||||
|
||||
it("clearAll removes all data from in-memory store", async () => {
|
||||
await cacheSource("MM", "Monster Manual", []);
|
||||
await cacheSource("dnd", "MM", "Monster Manual", []);
|
||||
await clearAll();
|
||||
|
||||
const sources = await getCachedSources();
|
||||
|
||||
@@ -69,17 +69,17 @@ describe("bestiary-cache", () => {
|
||||
describe("cacheSource", () => {
|
||||
it("stores creatures and metadata", async () => {
|
||||
const creatures = [makeCreature("mm:goblin", "Goblin")];
|
||||
await cacheSource("MM", "Monster Manual", creatures);
|
||||
await cacheSource("dnd", "MM", "Monster Manual", creatures);
|
||||
|
||||
expect(fakeStore.has("MM")).toBe(true);
|
||||
const record = fakeStore.get("MM") as {
|
||||
expect(fakeStore.has("dnd:MM")).toBe(true);
|
||||
const record = fakeStore.get("dnd:MM") as {
|
||||
sourceCode: string;
|
||||
displayName: string;
|
||||
creatures: Creature[];
|
||||
creatureCount: number;
|
||||
cachedAt: number;
|
||||
};
|
||||
expect(record.sourceCode).toBe("MM");
|
||||
expect(record.sourceCode).toBe("dnd:MM");
|
||||
expect(record.displayName).toBe("Monster Manual");
|
||||
expect(record.creatures).toHaveLength(1);
|
||||
expect(record.creatureCount).toBe(1);
|
||||
@@ -89,12 +89,12 @@ describe("bestiary-cache", () => {
|
||||
|
||||
describe("isSourceCached", () => {
|
||||
it("returns false for uncached source", async () => {
|
||||
expect(await isSourceCached("XGE")).toBe(false);
|
||||
expect(await isSourceCached("dnd", "XGE")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true after caching", async () => {
|
||||
await cacheSource("MM", "Monster Manual", []);
|
||||
expect(await isSourceCached("MM")).toBe(true);
|
||||
await cacheSource("dnd", "MM", "Monster Manual", []);
|
||||
expect(await isSourceCached("dnd", "MM")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -105,11 +105,11 @@ describe("bestiary-cache", () => {
|
||||
});
|
||||
|
||||
it("returns source info with creature counts", async () => {
|
||||
await cacheSource("MM", "Monster Manual", [
|
||||
await cacheSource("dnd", "MM", "Monster Manual", [
|
||||
makeCreature("mm:goblin", "Goblin"),
|
||||
makeCreature("mm:orc", "Orc"),
|
||||
]);
|
||||
await cacheSource("VGM", "Volo's Guide", [
|
||||
await cacheSource("dnd", "VGM", "Volo's Guide", [
|
||||
makeCreature("vgm:flind", "Flind"),
|
||||
]);
|
||||
|
||||
@@ -137,8 +137,8 @@ describe("bestiary-cache", () => {
|
||||
const orc = makeCreature("mm:orc", "Orc");
|
||||
const flind = makeCreature("vgm:flind", "Flind");
|
||||
|
||||
await cacheSource("MM", "Monster Manual", [goblin, orc]);
|
||||
await cacheSource("VGM", "Volo's Guide", [flind]);
|
||||
await cacheSource("dnd", "MM", "Monster Manual", [goblin, orc]);
|
||||
await cacheSource("dnd", "VGM", "Volo's Guide", [flind]);
|
||||
|
||||
const map = await loadAllCachedCreatures();
|
||||
expect(map.size).toBe(3);
|
||||
@@ -150,20 +150,20 @@ describe("bestiary-cache", () => {
|
||||
|
||||
describe("clearSource", () => {
|
||||
it("removes a single source", async () => {
|
||||
await cacheSource("MM", "Monster Manual", []);
|
||||
await cacheSource("VGM", "Volo's Guide", []);
|
||||
await cacheSource("dnd", "MM", "Monster Manual", []);
|
||||
await cacheSource("dnd", "VGM", "Volo's Guide", []);
|
||||
|
||||
await clearSource("MM");
|
||||
await clearSource("dnd", "MM");
|
||||
|
||||
expect(await isSourceCached("MM")).toBe(false);
|
||||
expect(await isSourceCached("VGM")).toBe(true);
|
||||
expect(await isSourceCached("dnd", "MM")).toBe(false);
|
||||
expect(await isSourceCached("dnd", "VGM")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("clearAll", () => {
|
||||
it("removes all cached data", async () => {
|
||||
await cacheSource("MM", "Monster Manual", []);
|
||||
await cacheSource("VGM", "Volo's Guide", []);
|
||||
await cacheSource("dnd", "MM", "Monster Manual", []);
|
||||
await cacheSource("dnd", "VGM", "Volo's Guide", []);
|
||||
|
||||
await clearAll();
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
getAllPf2eSourceCodes,
|
||||
getDefaultPf2eFetchUrl,
|
||||
getPf2eSourceDisplayName,
|
||||
loadPf2eBestiaryIndex,
|
||||
} from "../pf2e-bestiary-index-adapter.js";
|
||||
|
||||
describe("loadPf2eBestiaryIndex", () => {
|
||||
it("returns an object with sources and creatures", () => {
|
||||
const index = loadPf2eBestiaryIndex();
|
||||
expect(index.sources).toBeDefined();
|
||||
expect(index.creatures).toBeDefined();
|
||||
expect(Array.isArray(index.creatures)).toBe(true);
|
||||
});
|
||||
|
||||
it("creatures have the expected PF2e shape", () => {
|
||||
const index = loadPf2eBestiaryIndex();
|
||||
expect(index.creatures.length).toBeGreaterThan(0);
|
||||
const first = index.creatures[0];
|
||||
expect(first).toHaveProperty("name");
|
||||
expect(first).toHaveProperty("source");
|
||||
expect(first).toHaveProperty("level");
|
||||
expect(first).toHaveProperty("ac");
|
||||
expect(first).toHaveProperty("hp");
|
||||
expect(first).toHaveProperty("perception");
|
||||
expect(first).toHaveProperty("size");
|
||||
expect(first).toHaveProperty("type");
|
||||
});
|
||||
|
||||
it("contains a substantial number of creatures", () => {
|
||||
const index = loadPf2eBestiaryIndex();
|
||||
expect(index.creatures.length).toBeGreaterThan(2000);
|
||||
});
|
||||
|
||||
it("returns the same cached instance on subsequent calls", () => {
|
||||
const a = loadPf2eBestiaryIndex();
|
||||
const b = loadPf2eBestiaryIndex();
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAllPf2eSourceCodes", () => {
|
||||
it("returns all keys from the index sources", () => {
|
||||
const codes = getAllPf2eSourceCodes();
|
||||
const index = loadPf2eBestiaryIndex();
|
||||
expect(codes).toEqual(Object.keys(index.sources));
|
||||
});
|
||||
});
|
||||
|
||||
describe("getDefaultPf2eFetchUrl", () => {
|
||||
it("returns Pf2eTools GitHub URL with lowercase source code", () => {
|
||||
const url = getDefaultPf2eFetchUrl("B1");
|
||||
expect(url).toBe(
|
||||
"https://raw.githubusercontent.com/Pf2eToolsOrg/Pf2eTools/dev/data/bestiary/creatures-b1.json",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPf2eSourceDisplayName", () => {
|
||||
it("returns display name for a known source", () => {
|
||||
expect(getPf2eSourceDisplayName("B1")).toBe("Bestiary");
|
||||
});
|
||||
|
||||
it("falls back to source code for unknown source", () => {
|
||||
expect(getPf2eSourceDisplayName("UNKNOWN")).toBe("UNKNOWN");
|
||||
});
|
||||
});
|
||||
@@ -1,23 +1,25 @@
|
||||
import type { Creature, CreatureId } from "@initiative/domain";
|
||||
import type { AnyCreature, CreatureId } from "@initiative/domain";
|
||||
import { type IDBPDatabase, openDB } from "idb";
|
||||
|
||||
const DB_NAME = "initiative-bestiary";
|
||||
const STORE_NAME = "sources";
|
||||
const DB_VERSION = 3;
|
||||
const DB_VERSION = 4;
|
||||
|
||||
interface CachedSourceInfo {
|
||||
readonly sourceCode: string;
|
||||
readonly displayName: string;
|
||||
readonly creatureCount: number;
|
||||
readonly cachedAt: number;
|
||||
readonly system?: string;
|
||||
}
|
||||
|
||||
interface CachedSourceRecord {
|
||||
sourceCode: string;
|
||||
displayName: string;
|
||||
creatures: Creature[];
|
||||
creatures: AnyCreature[];
|
||||
cachedAt: number;
|
||||
creatureCount: number;
|
||||
system?: string;
|
||||
}
|
||||
|
||||
let db: IDBPDatabase | null = null;
|
||||
@@ -26,6 +28,10 @@ let dbFailed = false;
|
||||
// In-memory fallback when IndexedDB is unavailable
|
||||
const memoryStore = new Map<string, CachedSourceRecord>();
|
||||
|
||||
function scopedKey(system: string, sourceCode: string): string {
|
||||
return `${system}:${sourceCode}`;
|
||||
}
|
||||
|
||||
async function getDb(): Promise<IDBPDatabase | null> {
|
||||
if (db) return db;
|
||||
if (dbFailed) return null;
|
||||
@@ -58,60 +64,77 @@ async function getDb(): Promise<IDBPDatabase | null> {
|
||||
}
|
||||
|
||||
export async function cacheSource(
|
||||
system: string,
|
||||
sourceCode: string,
|
||||
displayName: string,
|
||||
creatures: Creature[],
|
||||
creatures: AnyCreature[],
|
||||
): Promise<void> {
|
||||
const key = scopedKey(system, sourceCode);
|
||||
const record: CachedSourceRecord = {
|
||||
sourceCode,
|
||||
sourceCode: key,
|
||||
displayName,
|
||||
creatures,
|
||||
cachedAt: Date.now(),
|
||||
creatureCount: creatures.length,
|
||||
system,
|
||||
};
|
||||
|
||||
const database = await getDb();
|
||||
if (database) {
|
||||
await database.put(STORE_NAME, record);
|
||||
} else {
|
||||
memoryStore.set(sourceCode, record);
|
||||
memoryStore.set(key, record);
|
||||
}
|
||||
}
|
||||
|
||||
export async function isSourceCached(sourceCode: string): Promise<boolean> {
|
||||
export async function isSourceCached(
|
||||
system: string,
|
||||
sourceCode: string,
|
||||
): Promise<boolean> {
|
||||
const key = scopedKey(system, sourceCode);
|
||||
const database = await getDb();
|
||||
if (database) {
|
||||
const record = await database.get(STORE_NAME, sourceCode);
|
||||
const record = await database.get(STORE_NAME, key);
|
||||
return record !== undefined;
|
||||
}
|
||||
return memoryStore.has(sourceCode);
|
||||
return memoryStore.has(key);
|
||||
}
|
||||
|
||||
export async function getCachedSources(): Promise<CachedSourceInfo[]> {
|
||||
export async function getCachedSources(
|
||||
system?: string,
|
||||
): Promise<CachedSourceInfo[]> {
|
||||
const database = await getDb();
|
||||
let records: CachedSourceRecord[];
|
||||
if (database) {
|
||||
const all: CachedSourceRecord[] = await database.getAll(STORE_NAME);
|
||||
return all.map((r) => ({
|
||||
sourceCode: r.sourceCode,
|
||||
displayName: r.displayName,
|
||||
creatureCount: r.creatureCount,
|
||||
cachedAt: r.cachedAt,
|
||||
}));
|
||||
records = await database.getAll(STORE_NAME);
|
||||
} else {
|
||||
records = [...memoryStore.values()];
|
||||
}
|
||||
return [...memoryStore.values()].map((r) => ({
|
||||
sourceCode: r.sourceCode,
|
||||
|
||||
const filtered = system
|
||||
? records.filter((r) => r.system === system)
|
||||
: records;
|
||||
return filtered.map((r) => ({
|
||||
sourceCode: r.system
|
||||
? r.sourceCode.slice(r.system.length + 1)
|
||||
: r.sourceCode,
|
||||
displayName: r.displayName,
|
||||
creatureCount: r.creatureCount,
|
||||
cachedAt: r.cachedAt,
|
||||
system: r.system,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function clearSource(sourceCode: string): Promise<void> {
|
||||
export async function clearSource(
|
||||
system: string,
|
||||
sourceCode: string,
|
||||
): Promise<void> {
|
||||
const key = scopedKey(system, sourceCode);
|
||||
const database = await getDb();
|
||||
if (database) {
|
||||
await database.delete(STORE_NAME, sourceCode);
|
||||
await database.delete(STORE_NAME, key);
|
||||
} else {
|
||||
memoryStore.delete(sourceCode);
|
||||
memoryStore.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,9 +148,9 @@ export async function clearAll(): Promise<void> {
|
||||
}
|
||||
|
||||
export async function loadAllCachedCreatures(): Promise<
|
||||
Map<CreatureId, Creature>
|
||||
Map<CreatureId, AnyCreature>
|
||||
> {
|
||||
const map = new Map<CreatureId, Creature>();
|
||||
const map = new Map<CreatureId, AnyCreature>();
|
||||
const database = await getDb();
|
||||
|
||||
let records: CachedSourceRecord[];
|
||||
|
||||
334
apps/web/src/adapters/pf2e-bestiary-adapter.ts
Normal file
334
apps/web/src/adapters/pf2e-bestiary-adapter.ts
Normal file
@@ -0,0 +1,334 @@
|
||||
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));
|
||||
}
|
||||
70
apps/web/src/adapters/pf2e-bestiary-index-adapter.ts
Normal file
70
apps/web/src/adapters/pf2e-bestiary-index-adapter.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import type {
|
||||
Pf2eBestiaryIndex,
|
||||
Pf2eBestiaryIndexEntry,
|
||||
} from "@initiative/domain";
|
||||
|
||||
import rawIndex from "../../../../data/bestiary/pf2e-index.json";
|
||||
|
||||
interface CompactCreature {
|
||||
readonly n: string;
|
||||
readonly s: string;
|
||||
readonly lv: number;
|
||||
readonly ac: number;
|
||||
readonly hp: number;
|
||||
readonly pc: number;
|
||||
readonly sz: string;
|
||||
readonly tp: string;
|
||||
}
|
||||
|
||||
interface CompactIndex {
|
||||
readonly sources: Record<string, string>;
|
||||
readonly creatures: readonly CompactCreature[];
|
||||
}
|
||||
|
||||
function mapCreature(c: CompactCreature): Pf2eBestiaryIndexEntry {
|
||||
return {
|
||||
name: c.n,
|
||||
source: c.s,
|
||||
level: c.lv,
|
||||
ac: c.ac,
|
||||
hp: c.hp,
|
||||
perception: c.pc,
|
||||
size: c.sz,
|
||||
type: c.tp,
|
||||
};
|
||||
}
|
||||
|
||||
let cachedIndex: Pf2eBestiaryIndex | undefined;
|
||||
|
||||
export function loadPf2eBestiaryIndex(): Pf2eBestiaryIndex {
|
||||
if (cachedIndex) return cachedIndex;
|
||||
|
||||
const compact = rawIndex as unknown as CompactIndex;
|
||||
cachedIndex = {
|
||||
sources: compact.sources,
|
||||
creatures: compact.creatures.map(mapCreature),
|
||||
};
|
||||
return cachedIndex;
|
||||
}
|
||||
|
||||
export function getAllPf2eSourceCodes(): string[] {
|
||||
const index = loadPf2eBestiaryIndex();
|
||||
return Object.keys(index.sources);
|
||||
}
|
||||
|
||||
export function getDefaultPf2eFetchUrl(
|
||||
sourceCode: string,
|
||||
baseUrl?: string,
|
||||
): string {
|
||||
const filename = `creatures-${sourceCode.toLowerCase()}.json`;
|
||||
if (baseUrl !== undefined) {
|
||||
const normalized = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
|
||||
return `${normalized}${filename}`;
|
||||
}
|
||||
return `https://raw.githubusercontent.com/Pf2eToolsOrg/Pf2eTools/dev/data/bestiary/${filename}`;
|
||||
}
|
||||
|
||||
export function getPf2eSourceDisplayName(sourceCode: string): string {
|
||||
const index = loadPf2eBestiaryIndex();
|
||||
return index.sources[sourceCode] ?? sourceCode;
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import type {
|
||||
AnyCreature,
|
||||
BestiaryIndex,
|
||||
Creature,
|
||||
CreatureId,
|
||||
Encounter,
|
||||
Pf2eBestiaryIndex,
|
||||
PlayerCharacter,
|
||||
UndoRedoState,
|
||||
} from "@initiative/domain";
|
||||
@@ -31,15 +32,16 @@ export interface CachedSourceInfo {
|
||||
|
||||
export interface BestiaryCachePort {
|
||||
cacheSource(
|
||||
system: string,
|
||||
sourceCode: string,
|
||||
displayName: string,
|
||||
creatures: Creature[],
|
||||
creatures: AnyCreature[],
|
||||
): Promise<void>;
|
||||
isSourceCached(sourceCode: string): Promise<boolean>;
|
||||
getCachedSources(): Promise<CachedSourceInfo[]>;
|
||||
clearSource(sourceCode: string): Promise<void>;
|
||||
isSourceCached(system: string, sourceCode: string): Promise<boolean>;
|
||||
getCachedSources(system?: string): Promise<CachedSourceInfo[]>;
|
||||
clearSource(system: string, sourceCode: string): Promise<void>;
|
||||
clearAll(): Promise<void>;
|
||||
loadAllCachedCreatures(): Promise<Map<CreatureId, Creature>>;
|
||||
loadAllCachedCreatures(): Promise<Map<CreatureId, AnyCreature>>;
|
||||
}
|
||||
|
||||
export interface BestiaryIndexPort {
|
||||
@@ -48,3 +50,10 @@ export interface BestiaryIndexPort {
|
||||
getDefaultFetchUrl(sourceCode: string, baseUrl?: string): string;
|
||||
getSourceDisplayName(sourceCode: string): string;
|
||||
}
|
||||
|
||||
export interface Pf2eBestiaryIndexPort {
|
||||
loadIndex(): Pf2eBestiaryIndex;
|
||||
getAllSourceCodes(): string[];
|
||||
getDefaultFetchUrl(sourceCode: string, baseUrl?: string): string;
|
||||
getSourceDisplayName(sourceCode: string): string;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from "../persistence/undo-redo-storage.js";
|
||||
import * as bestiaryCache from "./bestiary-cache.js";
|
||||
import * as bestiaryIndex from "./bestiary-index-adapter.js";
|
||||
import * as pf2eBestiaryIndex from "./pf2e-bestiary-index-adapter.js";
|
||||
|
||||
export const productionAdapters: Adapters = {
|
||||
encounterPersistence: {
|
||||
@@ -41,4 +42,10 @@ export const productionAdapters: Adapters = {
|
||||
getDefaultFetchUrl: bestiaryIndex.getDefaultFetchUrl,
|
||||
getSourceDisplayName: bestiaryIndex.getSourceDisplayName,
|
||||
},
|
||||
pf2eBestiaryIndex: {
|
||||
loadIndex: pf2eBestiaryIndex.loadPf2eBestiaryIndex,
|
||||
getAllSourceCodes: pf2eBestiaryIndex.getAllPf2eSourceCodes,
|
||||
getDefaultFetchUrl: pf2eBestiaryIndex.getDefaultPf2eFetchUrl,
|
||||
getSourceDisplayName: pf2eBestiaryIndex.getPf2eSourceDisplayName,
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user