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

@@ -29,6 +29,6 @@
"@vitejs/plugin-react": "^6.0.1",
"jsdom": "^29.0.1",
"tailwindcss": "^4.2.2",
"vite": "^8.0.1"
"vite": "^8.0.5"
}
}

View File

@@ -1,5 +1,5 @@
import {
type Creature,
type AnyCreature,
type CreatureId,
EMPTY_UNDO_REDO_STATE,
type Encounter,
@@ -12,10 +12,10 @@ export function createTestAdapters(options?: {
encounter?: Encounter | null;
undoRedoState?: UndoRedoState;
playerCharacters?: PlayerCharacter[];
creatures?: Map<CreatureId, Creature>;
creatures?: Map<CreatureId, AnyCreature>;
sources?: Map<
string,
{ displayName: string; creatures: Creature[]; cachedAt: number }
{ displayName: string; creatures: AnyCreature[]; cachedAt: number }
>;
}): Adapters {
let storedEncounter = options?.encounter ?? null;
@@ -25,7 +25,7 @@ export function createTestAdapters(options?: {
options?.sources ??
new Map<
string,
{ displayName: string; creatures: Creature[]; cachedAt: number }
{ displayName: string; creatures: AnyCreature[]; cachedAt: number }
>();
// Pre-populate sourceStore from creatures map if provided
@@ -33,7 +33,7 @@ export function createTestAdapters(options?: {
// No-op: creatures are accessed directly from the map
}
const creatureMap = options?.creatures ?? new Map<CreatureId, Creature>();
const creatureMap = options?.creatures ?? new Map<CreatureId, AnyCreature>();
return {
encounterPersistence: {
@@ -55,8 +55,9 @@ export function createTestAdapters(options?: {
},
},
bestiaryCache: {
cacheSource(sourceCode, displayName, creatures) {
sourceStore.set(sourceCode, {
cacheSource(system, sourceCode, displayName, creatures) {
const key = `${system}:${sourceCode}`;
sourceStore.set(key, {
displayName,
creatures,
cachedAt: Date.now(),
@@ -66,21 +67,25 @@ export function createTestAdapters(options?: {
}
return Promise.resolve();
},
isSourceCached(sourceCode) {
return Promise.resolve(sourceStore.has(sourceCode));
isSourceCached(system, sourceCode) {
return Promise.resolve(sourceStore.has(`${system}:${sourceCode}`));
},
getCachedSources() {
getCachedSources(system) {
return Promise.resolve(
[...sourceStore.entries()].map(([sourceCode, info]) => ({
sourceCode,
displayName: info.displayName,
creatureCount: info.creatures.length,
cachedAt: info.cachedAt,
})),
[...sourceStore.entries()]
.filter(([key]) => !system || key.startsWith(`${system}:`))
.map(([key, info]) => ({
sourceCode: key.includes(":")
? key.slice(key.indexOf(":") + 1)
: key,
displayName: info.displayName,
creatureCount: info.creatures.length,
cachedAt: info.cachedAt,
})),
);
},
clearSource(sourceCode) {
sourceStore.delete(sourceCode);
clearSource(system, sourceCode) {
sourceStore.delete(`${system}:${sourceCode}`);
return Promise.resolve();
},
clearAll() {
@@ -104,5 +109,12 @@ export function createTestAdapters(options?: {
},
getSourceDisplayName: (sourceCode) => sourceCode,
},
pf2eBestiaryIndex: {
loadIndex: () => ({ sources: {}, creatures: [] }),
getAllSourceCodes: () => [],
getDefaultFetchUrl: (sourceCode) =>
`https://example.com/creatures-${sourceCode.toLowerCase()}.json`,
getSourceDisplayName: (sourceCode) => sourceCode,
},
};
}

View File

@@ -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();

View File

@@ -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();

View File

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

View File

@@ -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[];

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

View 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;
}

View File

@@ -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;
}

View File

@@ -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,
},
};

View File

@@ -6,6 +6,7 @@ import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createTestAdapters } from "../../__tests__/adapters/in-memory-adapters.js";
import { AdapterProvider } from "../../contexts/adapter-context.js";
import { RulesEditionProvider } from "../../contexts/rules-edition-context.js";
import { BulkImportPrompt } from "../bulk-import-prompt.js";
const THREE_SOURCES_REGEX = /3 sources/;
@@ -68,9 +69,11 @@ function createAdaptersWithSources() {
function renderWithAdapters() {
const adapters = createAdaptersWithSources();
return render(
<AdapterProvider adapters={adapters}>
<BulkImportPrompt />
</AdapterProvider>,
<RulesEditionProvider>
<AdapterProvider adapters={adapters}>
<BulkImportPrompt />
</AdapterProvider>
</RulesEditionProvider>,
);
}

View File

@@ -1,7 +1,11 @@
// @vitest-environment jsdom
import "@testing-library/jest-dom/vitest";
import { CONDITION_DEFINITIONS, type ConditionId } from "@initiative/domain";
import {
type ConditionEntry,
type ConditionId,
getConditionsForEdition,
} from "@initiative/domain";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { createRef, type RefObject } from "react";
@@ -13,12 +17,14 @@ afterEach(cleanup);
function renderPicker(
overrides: Partial<{
activeConditions: readonly ConditionId[];
activeConditions: readonly ConditionEntry[];
onToggle: (conditionId: ConditionId) => void;
onSetValue: (conditionId: ConditionId, value: number) => void;
onClose: () => void;
}> = {},
) {
const onToggle = overrides.onToggle ?? vi.fn();
const onSetValue = overrides.onSetValue ?? vi.fn();
const onClose = overrides.onClose ?? vi.fn();
const anchorRef = createRef<HTMLElement>() as RefObject<HTMLElement>;
const anchor = document.createElement("div");
@@ -30,25 +36,27 @@ function renderPicker(
anchorRef={anchorRef}
activeConditions={overrides.activeConditions ?? []}
onToggle={onToggle}
onSetValue={onSetValue}
onClose={onClose}
/>
</RulesEditionProvider>,
);
return { ...result, onToggle, onClose };
return { ...result, onToggle, onSetValue, onClose };
}
describe("ConditionPicker", () => {
it("renders all condition definitions from domain", () => {
it("renders edition-specific conditions from domain", () => {
renderPicker();
for (const def of CONDITION_DEFINITIONS) {
const editionConditions = getConditionsForEdition("5.5e");
for (const def of editionConditions) {
expect(screen.getByText(def.label)).toBeInTheDocument();
}
});
it("active conditions are visually distinguished", () => {
renderPicker({ activeConditions: ["blinded"] });
const blindedButton = screen.getByText("Blinded").closest("button");
expect(blindedButton?.className).toContain("bg-card/50");
renderPicker({ activeConditions: [{ id: "blinded" }] });
const row = screen.getByText("Blinded").closest("div[class]");
expect(row?.className).toContain("bg-card/50");
});
it("clicking a condition calls onToggle with that condition's ID", async () => {
@@ -65,7 +73,7 @@ describe("ConditionPicker", () => {
});
it("active condition labels use foreground color", () => {
renderPicker({ activeConditions: ["charmed"] });
renderPicker({ activeConditions: [{ id: "charmed" }] });
const label = screen.getByText("Charmed");
expect(label.className).toContain("text-foreground");
});

View File

@@ -1,5 +1,5 @@
// @vitest-environment jsdom
import type { ConditionId } from "@initiative/domain";
import type { ConditionEntry } from "@initiative/domain";
import { cleanup, render, screen } from "@testing-library/react";
import { userEvent } from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
@@ -14,6 +14,7 @@ function renderTags(props: Partial<Parameters<typeof ConditionTags>[0]> = {}) {
<ConditionTags
conditions={props.conditions}
onRemove={props.onRemove ?? (() => {})}
onDecrement={props.onDecrement ?? (() => {})}
onOpenPicker={props.onOpenPicker ?? (() => {})}
/>
</RulesEditionProvider>,
@@ -28,7 +29,7 @@ describe("ConditionTags", () => {
});
it("renders a button per condition", () => {
const conditions: ConditionId[] = ["blinded", "prone"];
const conditions: ConditionEntry[] = [{ id: "blinded" }, { id: "prone" }];
renderTags({ conditions });
expect(
screen.getByRole("button", { name: "Remove Blinded" }),
@@ -39,7 +40,7 @@ describe("ConditionTags", () => {
it("calls onRemove with condition id when clicked", async () => {
const onRemove = vi.fn();
renderTags({
conditions: ["blinded"] as ConditionId[],
conditions: [{ id: "blinded" }] as ConditionEntry[],
onRemove,
});
@@ -66,4 +67,37 @@ describe("ConditionTags", () => {
// Only add button
expect(screen.getByRole("button", { name: "Add condition" })).toBeDefined();
});
it("displays value badge for valued conditions", () => {
renderTags({ conditions: [{ id: "frightened", value: 3 }] });
expect(screen.getByText("3")).toBeDefined();
});
it("calls onDecrement for valued condition click", async () => {
const onDecrement = vi.fn();
renderTags({
conditions: [{ id: "frightened", value: 2 }],
onDecrement,
});
await userEvent.click(
screen.getByRole("button", { name: "Remove Frightened" }),
);
expect(onDecrement).toHaveBeenCalledWith("frightened");
});
it("calls onRemove for non-valued condition click", async () => {
const onRemove = vi.fn();
renderTags({
conditions: [{ id: "blinded" }],
onRemove,
});
await userEvent.click(
screen.getByRole("button", { name: "Remove Blinded" }),
);
expect(onRemove).toHaveBeenCalledWith("blinded");
});
});

View File

@@ -37,15 +37,18 @@ function renderModal(open = true) {
}
describe("SettingsModal", () => {
it("renders edition section with 'Rules Edition' label", () => {
it("renders game system section with all three options", () => {
renderModal();
expect(screen.getByText("Rules Edition")).toBeInTheDocument();
expect(screen.getByText("Game System")).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "5e (2014)" }),
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "5.5e (2024)" }),
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Pathfinder 2e" }),
).toBeInTheDocument();
});
it("renders theme toggle buttons", () => {

View File

@@ -6,6 +6,7 @@ import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createTestAdapters } from "../../__tests__/adapters/in-memory-adapters.js";
import { AdapterProvider } from "../../contexts/adapter-context.js";
import { RulesEditionProvider } from "../../contexts/rules-edition-context.js";
import { SourceFetchPrompt } from "../source-fetch-prompt.js";
const MONSTER_MANUAL_REGEX = /Monster Manual/;
@@ -36,12 +37,14 @@ function renderPrompt(sourceCode = "MM") {
code === "MM" ? "Monster Manual" : code,
};
const result = render(
<AdapterProvider adapters={adapters}>
<SourceFetchPrompt
sourceCode={sourceCode}
onSourceLoaded={onSourceLoaded}
/>
</AdapterProvider>,
<RulesEditionProvider>
<AdapterProvider adapters={adapters}>
<SourceFetchPrompt
sourceCode={sourceCode}
onSourceLoaded={onSourceLoaded}
/>
</AdapterProvider>
</RulesEditionProvider>,
);
return { ...result, onSourceLoaded };
}

View File

@@ -36,7 +36,7 @@ function renderWithSources(sources: CachedSourceInfo[] = []) {
adapters.bestiaryCache = {
...adapters.bestiaryCache,
getCachedSources: () => Promise.resolve(currentSources),
clearSource(sourceCode) {
clearSource(_system, sourceCode) {
currentSources = currentSources.filter(
(s) => s.sourceCode !== sourceCode,
);

View File

@@ -5,7 +5,7 @@ import type { Creature } from "@initiative/domain";
import { creatureId } from "@initiative/domain";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import { StatBlock } from "../stat-block.js";
import { DndStatBlock as StatBlock } from "../dnd-stat-block.js";
afterEach(cleanup);

View File

@@ -3,23 +3,30 @@ import { useId, useState } from "react";
import { useAdapters } from "../contexts/adapter-context.js";
import { useBestiaryContext } from "../contexts/bestiary-context.js";
import { useBulkImportContext } from "../contexts/bulk-import-context.js";
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
import { useSidePanelContext } from "../contexts/side-panel-context.js";
import { Button } from "./ui/button.js";
import { Input } from "./ui/input.js";
const DEFAULT_BASE_URL =
const DND_BASE_URL =
"https://raw.githubusercontent.com/5etools-mirror-3/5etools-src/main/data/bestiary/";
const PF2E_BASE_URL =
"https://raw.githubusercontent.com/Pf2eToolsOrg/Pf2eTools/dev/data/bestiary/";
export function BulkImportPrompt() {
const { bestiaryIndex } = useAdapters();
const { bestiaryIndex, pf2eBestiaryIndex } = useAdapters();
const { fetchAndCacheSource, isSourceCached, refreshCache } =
useBestiaryContext();
const { state: importState, startImport, reset } = useBulkImportContext();
const { dismissPanel } = useSidePanelContext();
const { edition } = useRulesEditionContext();
const [baseUrl, setBaseUrl] = useState(DEFAULT_BASE_URL);
const indexPort = edition === "pf2e" ? pf2eBestiaryIndex : bestiaryIndex;
const defaultUrl = edition === "pf2e" ? PF2E_BASE_URL : DND_BASE_URL;
const [baseUrl, setBaseUrl] = useState(defaultUrl);
const baseUrlId = useId();
const totalSources = bestiaryIndex.getAllSourceCodes().length;
const totalSources = indexPort.getAllSourceCodes().length;
const handleStart = (url: string) => {
startImport(url, fetchAndCacheSource, isSourceCached, refreshCache);

View File

@@ -1,6 +1,6 @@
import {
type CombatantId,
type ConditionId,
type ConditionEntry,
type CreatureId,
deriveHpStatus,
type PlayerIcon,
@@ -31,7 +31,7 @@ interface Combatant {
readonly currentHp?: number;
readonly tempHp?: number;
readonly ac?: number;
readonly conditions?: readonly ConditionId[];
readonly conditions?: readonly ConditionEntry[];
readonly isConcentrating?: boolean;
readonly color?: string;
readonly icon?: string;
@@ -448,6 +448,8 @@ export function CombatantRow({
setTempHp,
setAc,
toggleCondition,
setConditionValue,
decrementCondition,
toggleConcentration,
} = useEncounterContext();
const { selectedCreatureId, showCreature, toggleCollapse } =
@@ -585,6 +587,7 @@ export function CombatantRow({
<ConditionTags
conditions={combatant.conditions}
onRemove={(conditionId) => toggleCondition(id, conditionId)}
onDecrement={(conditionId) => decrementCondition(id, conditionId)}
onOpenPicker={() => setPickerOpen((prev) => !prev)}
/>
</div>
@@ -593,6 +596,9 @@ export function CombatantRow({
anchorRef={conditionAnchorRef}
activeConditions={combatant.conditions}
onToggle={(conditionId) => toggleCondition(id, conditionId)}
onSetValue={(conditionId, value) =>
setConditionValue(id, conditionId, value)
}
onClose={() => setPickerOpen(false)}
/>
)}

View File

@@ -1,8 +1,10 @@
import {
type ConditionEntry,
type ConditionId,
getConditionDescription,
getConditionsForEdition,
} from "@initiative/domain";
import { Check, Minus, Plus } from "lucide-react";
import { useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
@@ -16,8 +18,9 @@ import { Tooltip } from "./ui/tooltip.js";
interface ConditionPickerProps {
anchorRef: React.RefObject<HTMLElement | null>;
activeConditions: readonly ConditionId[] | undefined;
activeConditions: readonly ConditionEntry[] | undefined;
onToggle: (conditionId: ConditionId) => void;
onSetValue: (conditionId: ConditionId, value: number) => void;
onClose: () => void;
}
@@ -25,6 +28,7 @@ export function ConditionPicker({
anchorRef,
activeConditions,
onToggle,
onSetValue,
onClose,
}: Readonly<ConditionPickerProps>) {
const ref = useRef<HTMLDivElement>(null);
@@ -34,6 +38,11 @@ export function ConditionPicker({
maxHeight: number;
} | null>(null);
const [editing, setEditing] = useState<{
id: ConditionId;
value: number;
} | null>(null);
useLayoutEffect(() => {
const anchor = anchorRef.current;
const el = ref.current;
@@ -59,7 +68,9 @@ export function ConditionPicker({
const { edition } = useRulesEditionContext();
const conditions = getConditionsForEdition(edition);
const active = new Set(activeConditions ?? []);
const activeMap = new Map(
(activeConditions ?? []).map((e) => [e.id, e.value]),
);
return createPortal(
<div
@@ -74,35 +85,112 @@ export function ConditionPicker({
{conditions.map((def) => {
const Icon = CONDITION_ICON_MAP[def.iconName];
if (!Icon) return null;
const isActive = active.has(def.id);
const isActive = activeMap.has(def.id);
const activeValue = activeMap.get(def.id);
const isEditing = editing?.id === def.id;
const colorClass =
CONDITION_COLOR_CLASSES[def.color] ?? "text-muted-foreground";
const handleClick = () => {
if (def.valued && edition === "pf2e") {
const current = activeMap.get(def.id);
setEditing({
id: def.id,
value: current ?? 1,
});
} else {
onToggle(def.id);
}
};
return (
<Tooltip
key={def.id}
content={getConditionDescription(def, edition)}
className="block"
>
<button
type="button"
<div
className={cn(
"flex w-full items-center gap-2 rounded px-2 py-1 text-sm transition-colors hover:bg-hover-neutral-bg",
isActive && "bg-card/50",
"flex w-full items-center gap-2 rounded px-2 py-1 text-sm transition-colors",
(isActive || isEditing) && "bg-card/50",
)}
onClick={() => onToggle(def.id)}
>
<Icon
size={14}
className={isActive ? colorClass : "text-muted-foreground"}
/>
<span
className={
isActive ? "text-foreground" : "text-muted-foreground"
}
<button
type="button"
className="flex flex-1 items-center gap-2"
onClick={handleClick}
>
{def.label}
</span>
</button>
<Icon
size={14}
className={
isActive || isEditing ? colorClass : "text-muted-foreground"
}
/>
<span
className={
isActive || isEditing
? "text-foreground"
: "text-muted-foreground"
}
>
{def.label}
</span>
</button>
{isActive && def.valued && edition === "pf2e" && !isEditing && (
<span className="min-w-5 rounded-full bg-accent px-1.5 py-0.5 text-center font-medium text-primary-foreground text-xs">
{activeValue}
</span>
)}
{isEditing && (
<div className="flex items-center gap-0.5">
<button
type="button"
className="rounded p-0.5 text-foreground hover:bg-accent/40"
onMouseDown={(e) => e.preventDefault()}
onClick={(e) => {
e.stopPropagation();
if (editing.value > 1) {
setEditing({
...editing,
value: editing.value - 1,
});
}
}}
>
<Minus className="h-3 w-3" />
</button>
<span className="min-w-5 rounded-full bg-accent px-1.5 py-0.5 text-center font-medium text-primary-foreground text-xs">
{editing.value}
</span>
<button
type="button"
className="rounded p-0.5 text-foreground hover:bg-accent/40"
onMouseDown={(e) => e.preventDefault()}
onClick={(e) => {
e.stopPropagation();
setEditing({
...editing,
value: editing.value + 1,
});
}}
>
<Plus className="h-3 w-3" />
</button>
<button
type="button"
className="ml-0.5 rounded p-0.5 text-foreground hover:bg-accent/40"
onMouseDown={(e) => e.preventDefault()}
onClick={(e) => {
e.stopPropagation();
onSetValue(editing.id, editing.value);
setEditing(null);
}}
>
<Check className="h-3.5 w-3.5" />
</button>
</div>
)}
</div>
</Tooltip>
);
})}

View File

@@ -1,42 +1,74 @@
import type { LucideIcon } from "lucide-react";
import {
Anchor,
ArrowDown,
Ban,
BatteryLow,
BrainCog,
CircleHelp,
CloudFog,
Drama,
Droplet,
Droplets,
EarOff,
Eye,
EyeOff,
Footprints,
Gem,
Ghost,
Hand,
Heart,
HeartCrack,
HeartPulse,
Link,
Moon,
PersonStanding,
ShieldMinus,
ShieldOff,
Siren,
Skull,
Snail,
Sparkles,
Sun,
TrendingDown,
Zap,
ZapOff,
} from "lucide-react";
export const CONDITION_ICON_MAP: Record<string, LucideIcon> = {
EyeOff,
Heart,
EarOff,
BatteryLow,
Siren,
Hand,
Ban,
Ghost,
ZapOff,
Gem,
Droplet,
Anchor,
ArrowDown,
Ban,
BatteryLow,
BrainCog,
CircleHelp,
CloudFog,
Drama,
Droplet,
Droplets,
EarOff,
Eye,
EyeOff,
Footprints,
Gem,
Ghost,
Hand,
Heart,
HeartCrack,
HeartPulse,
Link,
Moon,
PersonStanding,
ShieldMinus,
ShieldOff,
Siren,
Skull,
Snail,
Sparkles,
Moon,
Sun,
TrendingDown,
Zap,
ZapOff,
};
export const CONDITION_COLOR_CLASSES: Record<string, string> = {
@@ -51,4 +83,5 @@ export const CONDITION_COLOR_CLASSES: Record<string, string> = {
green: "text-green-400",
indigo: "text-indigo-400",
sky: "text-sky-400",
red: "text-red-400",
};

View File

@@ -1,5 +1,6 @@
import {
CONDITION_DEFINITIONS,
type ConditionEntry,
type ConditionId,
getConditionDescription,
} from "@initiative/domain";
@@ -13,44 +14,57 @@ import {
import { Tooltip } from "./ui/tooltip.js";
interface ConditionTagsProps {
conditions: readonly ConditionId[] | undefined;
conditions: readonly ConditionEntry[] | undefined;
onRemove: (conditionId: ConditionId) => void;
onDecrement: (conditionId: ConditionId) => void;
onOpenPicker: () => void;
}
export function ConditionTags({
conditions,
onRemove,
onDecrement,
onOpenPicker,
}: Readonly<ConditionTagsProps>) {
const { edition } = useRulesEditionContext();
return (
<div className="flex flex-wrap items-center gap-0.5">
{conditions?.map((condId) => {
const def = CONDITION_DEFINITIONS.find((d) => d.id === condId);
{conditions?.map((entry) => {
const def = CONDITION_DEFINITIONS.find((d) => d.id === entry.id);
if (!def) return null;
const Icon = CONDITION_ICON_MAP[def.iconName];
if (!Icon) return null;
const colorClass =
CONDITION_COLOR_CLASSES[def.color] ?? "text-muted-foreground";
const tooltipLabel =
entry.value === undefined ? def.label : `${def.label} ${entry.value}`;
return (
<Tooltip
key={condId}
content={`${def.label}:\n${getConditionDescription(def, edition)}`}
key={entry.id}
content={`${tooltipLabel}:\n${getConditionDescription(def, edition)}`}
>
<button
type="button"
aria-label={`Remove ${def.label}`}
className={cn(
"inline-flex items-center rounded p-0.5 transition-colors hover:bg-hover-neutral-bg",
"inline-flex items-center gap-0.5 rounded p-0.5 transition-colors hover:bg-hover-neutral-bg",
colorClass,
)}
onClick={(e) => {
e.stopPropagation();
onRemove(condId);
if (entry.value === undefined) {
onRemove(entry.id);
} else {
onDecrement(entry.id);
}
}}
>
<Icon size={14} />
{entry.value !== undefined && (
<span className="font-medium text-xs leading-none">
{entry.value}
</span>
)}
</button>
</Tooltip>
);

View File

@@ -11,9 +11,8 @@ import {
import { CrPicker } from "./cr-picker.js";
import { Button } from "./ui/button.js";
const TIER_LABEL_MAP: Record<
RulesEdition,
Record<DifficultyTier, { label: string; color: string }>
const TIER_LABEL_MAP: Partial<
Record<RulesEdition, Record<DifficultyTier, { label: string; color: string }>>
> = {
"5.5e": {
0: { label: "Trivial", color: "text-muted-foreground" },
@@ -117,7 +116,9 @@ export function DifficultyBreakdownPanel({ onClose }: { onClose: () => void }) {
const breakdown = useDifficultyBreakdown();
if (!breakdown) return null;
const tierConfig = TIER_LABEL_MAP[edition][breakdown.tier];
const tierLabels = TIER_LABEL_MAP[edition];
if (!tierLabels) return null;
const tierConfig = tierLabels[breakdown.tier];
const handleToggle = (entry: BreakdownCombatant) => {
const newSide = entry.side === "party" ? "enemy" : "party";

View File

@@ -1,10 +1,16 @@
import type { Creature, TraitBlock, TraitSegment } from "@initiative/domain";
import type { Creature } from "@initiative/domain";
import {
calculateInitiative,
formatInitiativeModifier,
} from "@initiative/domain";
import {
PropertyLine,
SectionDivider,
TraitEntry,
TraitSection,
} from "./stat-block-parts.js";
interface StatBlockProps {
interface DndStatBlockProps {
creature: Creature;
}
@@ -13,96 +19,7 @@ function abilityMod(score: number): string {
return mod >= 0 ? `+${mod}` : `${mod}`;
}
function PropertyLine({
label,
value,
}: Readonly<{
label: string;
value: string | undefined;
}>) {
if (!value) return null;
return (
<div className="text-sm">
<span className="font-semibold">{label}</span> {value}
</div>
);
}
function SectionDivider() {
return (
<div className="my-2 h-px bg-gradient-to-r from-stat-divider-from via-stat-divider-via to-transparent" />
);
}
function segmentKey(seg: TraitSegment): string {
return seg.type === "text"
? seg.value.slice(0, 40)
: seg.items.map((i) => i.label ?? i.text.slice(0, 20)).join(",");
}
function TraitSegments({
segments,
}: Readonly<{ segments: readonly TraitSegment[] }>) {
return (
<>
{segments.map((seg, i) => {
if (seg.type === "text") {
return (
<span key={segmentKey(seg)}>
{i === 0 ? ` ${seg.value}` : seg.value}
</span>
);
}
return (
<div key={segmentKey(seg)} className="mt-1 space-y-0.5">
{seg.items.map((item) => (
<p key={item.label ?? item.text}>
{item.label != null && (
<span className="font-semibold">{item.label}. </span>
)}
{item.text}
</p>
))}
</div>
);
})}
</>
);
}
function TraitEntry({ trait }: Readonly<{ trait: TraitBlock }>) {
return (
<div className="text-sm">
<span className="font-semibold italic">{trait.name}.</span>
<TraitSegments segments={trait.segments} />
</div>
);
}
function TraitSection({
entries,
heading,
}: Readonly<{
entries: readonly TraitBlock[] | undefined;
heading?: string;
}>) {
if (!entries || entries.length === 0) return null;
return (
<>
<SectionDivider />
{heading ? (
<h3 className="font-bold text-base text-stat-heading">{heading}</h3>
) : null}
<div className="space-y-2">
{entries.map((e) => (
<TraitEntry key={e.name} trait={e} />
))}
</div>
</>
);
}
export function StatBlock({ creature }: Readonly<StatBlockProps>) {
export function DndStatBlock({ creature }: Readonly<DndStatBlockProps>) {
const abilities = [
{ label: "STR", score: creature.abilities.str },
{ label: "DEX", score: creature.abilities.dex },

View File

@@ -0,0 +1,143 @@
import type { Pf2eCreature } from "@initiative/domain";
import { formatInitiativeModifier } from "@initiative/domain";
import {
PropertyLine,
SectionDivider,
TraitSection,
} from "./stat-block-parts.js";
interface Pf2eStatBlockProps {
creature: Pf2eCreature;
}
const ALIGNMENTS = new Set([
"lg",
"ng",
"cg",
"ln",
"n",
"cn",
"le",
"ne",
"ce",
]);
function capitalize(s: string): string {
return s.charAt(0).toUpperCase() + s.slice(1);
}
function displayTraits(traits: readonly string[]): string[] {
return traits.filter((t) => !ALIGNMENTS.has(t)).map(capitalize);
}
function formatMod(mod: number): string {
return mod >= 0 ? `+${mod}` : `${mod}`;
}
export function Pf2eStatBlock({ creature }: Readonly<Pf2eStatBlockProps>) {
const abilityEntries = [
{ label: "Str", mod: creature.abilityMods.str },
{ label: "Dex", mod: creature.abilityMods.dex },
{ label: "Con", mod: creature.abilityMods.con },
{ label: "Int", mod: creature.abilityMods.int },
{ label: "Wis", mod: creature.abilityMods.wis },
{ label: "Cha", mod: creature.abilityMods.cha },
];
return (
<div className="space-y-1 text-foreground">
{/* Header */}
<div>
<div className="flex items-baseline justify-between gap-2">
<h2 className="font-bold text-stat-heading text-xl">
{creature.name}
</h2>
<span className="shrink-0 font-semibold text-sm">
Level {creature.level}
</span>
</div>
<div className="mt-1 flex flex-wrap gap-1">
{displayTraits(creature.traits).map((trait) => (
<span
key={trait}
className="rounded border border-border bg-card px-1.5 py-0.5 text-foreground text-xs"
>
{trait}
</span>
))}
</div>
<p className="mt-1 text-muted-foreground text-xs">
{creature.sourceDisplayName}
</p>
</div>
<SectionDivider />
{/* Perception, Languages, Skills */}
<div className="space-y-0.5 text-sm">
<div>
<span className="font-semibold">Perception</span>{" "}
{formatInitiativeModifier(creature.perception)}
{creature.senses ? `; ${creature.senses}` : ""}
</div>
<PropertyLine label="Languages" value={creature.languages} />
<PropertyLine label="Skills" value={creature.skills} />
</div>
{/* Ability Modifiers */}
<div className="grid grid-cols-6 gap-1 text-center text-sm">
{abilityEntries.map((a) => (
<div key={a.label}>
<div className="font-semibold text-muted-foreground text-xs">
{a.label}
</div>
<div>{formatMod(a.mod)}</div>
</div>
))}
</div>
<PropertyLine label="Items" value={creature.items} />
{/* Top abilities (before defenses) */}
<TraitSection entries={creature.abilitiesTop} />
<SectionDivider />
{/* Defenses */}
<div className="space-y-0.5 text-sm">
<div>
<span className="font-semibold">AC</span> {creature.ac}
{creature.acConditional ? ` (${creature.acConditional})` : ""};{" "}
<span className="font-semibold">Fort</span>{" "}
{formatMod(creature.saveFort)},{" "}
<span className="font-semibold">Ref</span>{" "}
{formatMod(creature.saveRef)},{" "}
<span className="font-semibold">Will</span>{" "}
{formatMod(creature.saveWill)}
</div>
<div>
<span className="font-semibold">HP</span> {creature.hp}
</div>
<PropertyLine label="Immunities" value={creature.immunities} />
<PropertyLine label="Resistances" value={creature.resistances} />
<PropertyLine label="Weaknesses" value={creature.weaknesses} />
</div>
{/* Mid abilities (reactions, auras) */}
<TraitSection entries={creature.abilitiesMid} />
<SectionDivider />
{/* Speed */}
<div className="text-sm">
<span className="font-semibold">Speed</span> {creature.speed}
</div>
{/* Attacks */}
<TraitSection entries={creature.attacks} />
{/* Bottom abilities (active abilities) */}
<TraitSection entries={creature.abilitiesBot} />
</div>
);
}

View File

@@ -13,6 +13,7 @@ interface SettingsModalProps {
const EDITION_OPTIONS: { value: RulesEdition; label: string }[] = [
{ value: "5e", label: "5e (2014)" },
{ value: "5.5e", label: "5.5e (2024)" },
{ value: "pf2e", label: "Pathfinder 2e" },
];
const THEME_OPTIONS: {
@@ -36,7 +37,7 @@ export function SettingsModal({ open, onClose }: Readonly<SettingsModalProps>) {
<div className="flex flex-col gap-5">
<div>
<span className="mb-2 block font-medium text-muted-foreground text-sm">
Rules Edition
Game System
</span>
<div className="flex gap-1">
{EDITION_OPTIONS.map((opt) => (

View File

@@ -2,6 +2,7 @@ import { Download, Loader2, Upload } from "lucide-react";
import { useId, useRef, useState } from "react";
import { useAdapters } from "../contexts/adapter-context.js";
import { useBestiaryContext } from "../contexts/bestiary-context.js";
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
import { Button } from "./ui/button.js";
import { Input } from "./ui/input.js";
@@ -14,11 +15,13 @@ export function SourceFetchPrompt({
sourceCode,
onSourceLoaded,
}: Readonly<SourceFetchPromptProps>) {
const { bestiaryIndex } = useAdapters();
const { bestiaryIndex, pf2eBestiaryIndex } = useAdapters();
const { fetchAndCacheSource, uploadAndCacheSource } = useBestiaryContext();
const sourceDisplayName = bestiaryIndex.getSourceDisplayName(sourceCode);
const { edition } = useRulesEditionContext();
const indexPort = edition === "pf2e" ? pf2eBestiaryIndex : bestiaryIndex;
const sourceDisplayName = indexPort.getSourceDisplayName(sourceCode);
const [url, setUrl] = useState(() =>
bestiaryIndex.getDefaultFetchUrl(sourceCode),
indexPort.getDefaultFetchUrl(sourceCode),
);
const [status, setStatus] = useState<"idle" | "fetching" | "error">("idle");
const [error, setError] = useState<string>("");

View File

@@ -9,12 +9,15 @@ import {
import type { CachedSourceInfo } from "../adapters/ports.js";
import { useAdapters } from "../contexts/adapter-context.js";
import { useBestiaryContext } from "../contexts/bestiary-context.js";
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
import { Button } from "./ui/button.js";
import { Input } from "./ui/input.js";
export function SourceManager() {
const { bestiaryCache } = useAdapters();
const { refreshCache } = useBestiaryContext();
const { edition } = useRulesEditionContext();
const system = edition === "pf2e" ? "pf2e" : "dnd";
const [sources, setSources] = useState<CachedSourceInfo[]>([]);
const [filter, setFilter] = useState("");
const [optimisticSources, applyOptimistic] = useOptimistic(
@@ -29,9 +32,9 @@ export function SourceManager() {
);
const loadSources = useCallback(async () => {
const cached = await bestiaryCache.getCachedSources();
const cached = await bestiaryCache.getCachedSources(system);
setSources(cached);
}, [bestiaryCache]);
}, [bestiaryCache, system]);
useEffect(() => {
void loadSources();
@@ -39,7 +42,7 @@ export function SourceManager() {
const handleClearSource = async (sourceCode: string) => {
applyOptimistic({ type: "remove", sourceCode });
await bestiaryCache.clearSource(sourceCode);
await bestiaryCache.clearSource(system, sourceCode);
await loadSources();
void refreshCache();
};

View File

@@ -1,4 +1,4 @@
import type { CreatureId } from "@initiative/domain";
import type { Creature, CreatureId } from "@initiative/domain";
import { PanelRightClose, Pin, PinOff } from "lucide-react";
import type { ReactNode } from "react";
import { useEffect, useState } from "react";
@@ -7,9 +7,10 @@ import { useSidePanelContext } from "../contexts/side-panel-context.js";
import { useSwipeToDismiss } from "../hooks/use-swipe-to-dismiss.js";
import { cn } from "../lib/utils.js";
import { BulkImportPrompt } from "./bulk-import-prompt.js";
import { DndStatBlock } from "./dnd-stat-block.js";
import { Pf2eStatBlock } from "./pf2e-stat-block.js";
import { SourceFetchPrompt } from "./source-fetch-prompt.js";
import { SourceManager } from "./source-manager.js";
import { StatBlock } from "./stat-block.js";
import { Button } from "./ui/button.js";
interface StatBlockPanelProps {
@@ -307,7 +308,10 @@ export function StatBlockPanel({
}
if (creature) {
return <StatBlock creature={creature} />;
if ("system" in creature && creature.system === "pf2e") {
return <Pf2eStatBlock creature={creature} />;
}
return <DndStatBlock creature={creature as Creature} />;
}
if (needsFetch && sourceCode) {

View File

@@ -0,0 +1,90 @@
import type { TraitBlock, TraitSegment } from "@initiative/domain";
export function PropertyLine({
label,
value,
}: Readonly<{
label: string;
value: string | undefined;
}>) {
if (!value) return null;
return (
<div className="text-sm">
<span className="font-semibold">{label}</span> {value}
</div>
);
}
export function SectionDivider() {
return (
<div className="my-2 h-px bg-gradient-to-r from-stat-divider-from via-stat-divider-via to-transparent" />
);
}
function segmentKey(seg: TraitSegment): string {
return seg.type === "text"
? seg.value.slice(0, 40)
: seg.items.map((i) => i.label ?? i.text.slice(0, 20)).join(",");
}
function TraitSegments({
segments,
}: Readonly<{ segments: readonly TraitSegment[] }>) {
return (
<>
{segments.map((seg, i) => {
if (seg.type === "text") {
return (
<span key={segmentKey(seg)}>
{i === 0 ? ` ${seg.value}` : seg.value}
</span>
);
}
return (
<div key={segmentKey(seg)} className="mt-1 space-y-0.5">
{seg.items.map((item) => (
<p key={item.label ?? item.text}>
{item.label != null && (
<span className="font-semibold">{item.label}. </span>
)}
{item.text}
</p>
))}
</div>
);
})}
</>
);
}
export function TraitEntry({ trait }: Readonly<{ trait: TraitBlock }>) {
return (
<div className="text-sm">
<span className="font-semibold italic">{trait.name}.</span>
<TraitSegments segments={trait.segments} />
</div>
);
}
export function TraitSection({
entries,
heading,
}: Readonly<{
entries: readonly TraitBlock[] | undefined;
heading?: string;
}>) {
if (!entries || entries.length === 0) return null;
return (
<>
<SectionDivider />
{heading ? (
<h3 className="font-bold text-base text-stat-heading">{heading}</h3>
) : null}
<div className="space-y-2">
{entries.map((e) => (
<TraitEntry key={e.name} trait={e} />
))}
</div>
</>
);
}

View File

@@ -3,6 +3,7 @@ import type {
BestiaryCachePort,
BestiaryIndexPort,
EncounterPersistence,
Pf2eBestiaryIndexPort,
PlayerCharacterPersistence,
UndoRedoPersistence,
} from "../adapters/ports.js";
@@ -13,6 +14,7 @@ export interface Adapters {
playerCharacterPersistence: PlayerCharacterPersistence;
bestiaryCache: BestiaryCachePort;
bestiaryIndex: BestiaryIndexPort;
pf2eBestiaryIndex: Pf2eBestiaryIndexPort;
}
const AdapterContext = createContext<Adapters | null>(null);

View File

@@ -1,8 +1,4 @@
import type {
BestiaryIndexEntry,
ConditionId,
PlayerCharacter,
} from "@initiative/domain";
import type { ConditionId, PlayerCharacter } from "@initiative/domain";
import {
combatantId,
createEncounter,
@@ -11,6 +7,7 @@ import {
playerCharacterId,
} from "@initiative/domain";
import { describe, expect, it } from "vitest";
import type { SearchResult } from "../use-bestiary.js";
import { type EncounterState, encounterReducer } from "../use-encounter.js";
function emptyState(): EncounterState {
@@ -45,9 +42,11 @@ function stateWithHp(name: string, maxHp: number): EncounterState {
});
}
const BESTIARY_ENTRY: BestiaryIndexEntry = {
const BESTIARY_ENTRY: SearchResult = {
system: "dnd",
name: "Goblin",
source: "MM",
sourceDisplayName: "Monster Manual",
ac: 15,
hp: 7,
dex: 14,
@@ -57,6 +56,19 @@ const BESTIARY_ENTRY: BestiaryIndexEntry = {
type: "humanoid",
};
const PF2E_BESTIARY_ENTRY: SearchResult = {
system: "pf2e",
name: "Goblin Warrior",
source: "B1",
sourceDisplayName: "Bestiary",
level: -1,
ac: 16,
hp: 6,
perception: 5,
size: "small",
type: "humanoid",
};
describe("encounterReducer", () => {
describe("add-combatant", () => {
it("adds a combatant and pushes undo", () => {
@@ -236,7 +248,9 @@ describe("encounterReducer", () => {
conditionId: "blinded" as ConditionId,
});
expect(next.encounter.combatants[0].conditions).toContain("blinded");
expect(next.encounter.combatants[0].conditions).toContainEqual({
id: "blinded",
});
});
it("toggles concentration", () => {
@@ -327,6 +341,19 @@ describe("encounterReducer", () => {
expect(names).toContain("Goblin 1");
expect(names).toContain("Goblin 2");
});
it("adds PF2e creature with HP, AC, and creatureId", () => {
const next = encounterReducer(emptyState(), {
type: "add-from-bestiary",
entry: PF2E_BESTIARY_ENTRY,
});
const c = next.encounter.combatants[0];
expect(c.name).toBe("Goblin Warrior");
expect(c.maxHp).toBe(6);
expect(c.ac).toBe(16);
expect(c.creatureId).toBe("b1:goblin-warrior");
});
});
describe("add-multiple-from-bestiary", () => {

View File

@@ -1,11 +1,12 @@
// @vitest-environment jsdom
import type { BestiaryIndexEntry, PlayerCharacter } from "@initiative/domain";
import type { PlayerCharacter } from "@initiative/domain";
import { combatantId, creatureId, playerCharacterId } from "@initiative/domain";
import { act, renderHook } from "@testing-library/react";
import type { ReactNode } from "react";
import { beforeAll, describe, expect, it, vi } from "vitest";
import { createTestAdapters } from "../../__tests__/adapters/in-memory-adapters.js";
import { AllProviders } from "../../__tests__/test-providers.js";
import type { SearchResult } from "../use-bestiary.js";
import { useEncounter } from "../use-encounter.js";
beforeAll(() => {
@@ -152,9 +153,11 @@ describe("useEncounter", () => {
expect(result.current.canRollAllInitiative).toBe(false);
// Add from bestiary to get a creature combatant
const entry: BestiaryIndexEntry = {
const entry: SearchResult = {
system: "dnd",
name: "Goblin",
source: "MM",
sourceDisplayName: "Monster Manual",
ac: 15,
hp: 7,
dex: 14,
@@ -175,9 +178,11 @@ describe("useEncounter", () => {
it("addFromBestiary adds combatant with HP, AC, creatureId", () => {
const { result } = renderHook(() => useEncounter(), { wrapper });
const entry: BestiaryIndexEntry = {
const entry: SearchResult = {
system: "dnd",
name: "Goblin",
source: "MM",
sourceDisplayName: "Monster Manual",
ac: 15,
hp: 7,
dex: 14,
@@ -202,9 +207,11 @@ describe("useEncounter", () => {
it("addFromBestiary auto-numbers duplicate names", () => {
const { result } = renderHook(() => useEncounter(), { wrapper });
const entry: BestiaryIndexEntry = {
const entry: SearchResult = {
system: "dnd",
name: "Goblin",
source: "MM",
sourceDisplayName: "Monster Manual",
ac: 15,
hp: 7,
dex: 14,

View File

@@ -1,9 +1,10 @@
// @vitest-environment jsdom
import { act, renderHook } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { useRulesEdition } from "../use-rules-edition.js";
const STORAGE_KEY = "initiative:rules-edition";
const STORAGE_KEY = "initiative:game-system";
const OLD_STORAGE_KEY = "initiative:rules-edition";
describe("useRulesEdition", () => {
afterEach(() => {
@@ -11,6 +12,7 @@ describe("useRulesEdition", () => {
const { result } = renderHook(() => useRulesEdition());
act(() => result.current.setEdition("5.5e"));
localStorage.removeItem(STORAGE_KEY);
localStorage.removeItem(OLD_STORAGE_KEY);
});
it("defaults to 5.5e", () => {
@@ -42,4 +44,31 @@ describe("useRulesEdition", () => {
expect(r2.current.edition).toBe("5e");
});
it("accepts pf2e as a valid game system", () => {
const { result } = renderHook(() => useRulesEdition());
act(() => result.current.setEdition("pf2e"));
expect(result.current.edition).toBe("pf2e");
expect(localStorage.getItem(STORAGE_KEY)).toBe("pf2e");
});
it("migrates from old storage key on fresh module load", async () => {
// Set up old key before re-importing the module
localStorage.setItem(OLD_STORAGE_KEY, "5e");
localStorage.removeItem(STORAGE_KEY);
// Force a fresh module so loadEdition() re-runs at init time
vi.resetModules();
const { useRulesEdition: freshHook } = await import(
"../use-rules-edition.js"
);
const { result } = renderHook(() => freshHook());
expect(result.current.edition).toBe("5e");
expect(localStorage.getItem(STORAGE_KEY)).toBe("5e");
expect(localStorage.getItem(OLD_STORAGE_KEY)).toBeNull();
});
});

View File

@@ -1,22 +1,34 @@
import type {
AnyCreature,
BestiaryIndexEntry,
Creature,
CreatureId,
Pf2eBestiaryIndexEntry,
} from "@initiative/domain";
import { useCallback, useEffect, useState } from "react";
import {
normalizeBestiary,
setSourceDisplayNames,
} from "../adapters/bestiary-adapter.js";
import {
normalizePf2eBestiary,
setPf2eSourceDisplayNames,
} from "../adapters/pf2e-bestiary-adapter.js";
import { useAdapters } from "../contexts/adapter-context.js";
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
export interface SearchResult extends BestiaryIndexEntry {
readonly sourceDisplayName: string;
}
export type SearchResult =
| (BestiaryIndexEntry & {
readonly system: "dnd";
readonly sourceDisplayName: string;
})
| (Pf2eBestiaryIndexEntry & {
readonly system: "pf2e";
readonly sourceDisplayName: string;
});
interface BestiaryHook {
search: (query: string) => SearchResult[];
getCreature: (id: CreatureId) => Creature | undefined;
getCreature: (id: CreatureId) => AnyCreature | undefined;
isLoaded: boolean;
isSourceCached: (sourceCode: string) => Promise<boolean>;
fetchAndCacheSource: (sourceCode: string, url: string) => Promise<void>;
@@ -28,28 +40,47 @@ interface BestiaryHook {
}
export function useBestiary(): BestiaryHook {
const { bestiaryCache, bestiaryIndex } = useAdapters();
const { bestiaryCache, bestiaryIndex, pf2eBestiaryIndex } = useAdapters();
const { edition } = useRulesEditionContext();
const [isLoaded, setIsLoaded] = useState(false);
const [creatureMap, setCreatureMap] = useState(
() => new Map<CreatureId, Creature>(),
() => new Map<CreatureId, AnyCreature>(),
);
useEffect(() => {
const index = bestiaryIndex.loadIndex();
setSourceDisplayNames(index.sources as Record<string, string>);
if (index.creatures.length > 0) {
const pf2eIndex = pf2eBestiaryIndex.loadIndex();
setPf2eSourceDisplayNames(pf2eIndex.sources as Record<string, string>);
if (index.creatures.length > 0 || pf2eIndex.creatures.length > 0) {
setIsLoaded(true);
}
void bestiaryCache.loadAllCachedCreatures().then((map) => {
setCreatureMap(map);
});
}, [bestiaryCache, bestiaryIndex]);
}, [bestiaryCache, bestiaryIndex, pf2eBestiaryIndex]);
const search = useCallback(
(query: string): SearchResult[] => {
if (query.length < 2) return [];
const lower = query.toLowerCase();
if (edition === "pf2e") {
const index = pf2eBestiaryIndex.loadIndex();
return index.creatures
.filter((c) => c.name.toLowerCase().includes(lower))
.sort((a, b) => a.name.localeCompare(b.name))
.slice(0, 10)
.map((c) => ({
...c,
system: "pf2e" as const,
sourceDisplayName: pf2eBestiaryIndex.getSourceDisplayName(c.source),
}));
}
const index = bestiaryIndex.loadIndex();
return index.creatures
.filter((c) => c.name.toLowerCase().includes(lower))
@@ -57,24 +88,27 @@ export function useBestiary(): BestiaryHook {
.slice(0, 10)
.map((c) => ({
...c,
system: "dnd" as const,
sourceDisplayName: bestiaryIndex.getSourceDisplayName(c.source),
}));
},
[bestiaryIndex],
[bestiaryIndex, pf2eBestiaryIndex, edition],
);
const getCreature = useCallback(
(id: CreatureId): Creature | undefined => {
(id: CreatureId): AnyCreature | undefined => {
return creatureMap.get(id);
},
[creatureMap],
);
const system = edition === "pf2e" ? "pf2e" : "dnd";
const isSourceCachedFn = useCallback(
(sourceCode: string): Promise<boolean> => {
return bestiaryCache.isSourceCached(sourceCode);
return bestiaryCache.isSourceCached(system, sourceCode);
},
[bestiaryCache],
[bestiaryCache, system],
);
const fetchAndCacheSource = useCallback(
@@ -86,9 +120,20 @@ export function useBestiary(): BestiaryHook {
);
}
const json = await response.json();
const creatures = normalizeBestiary(json);
const displayName = bestiaryIndex.getSourceDisplayName(sourceCode);
await bestiaryCache.cacheSource(sourceCode, displayName, creatures);
const creatures =
edition === "pf2e"
? normalizePf2eBestiary(json)
: normalizeBestiary(json);
const displayName =
edition === "pf2e"
? pf2eBestiaryIndex.getSourceDisplayName(sourceCode)
: bestiaryIndex.getSourceDisplayName(sourceCode);
await bestiaryCache.cacheSource(
system,
sourceCode,
displayName,
creatures,
);
setCreatureMap((prev) => {
const next = new Map(prev);
for (const c of creatures) {
@@ -97,15 +142,27 @@ export function useBestiary(): BestiaryHook {
return next;
});
},
[bestiaryCache, bestiaryIndex],
[bestiaryCache, bestiaryIndex, pf2eBestiaryIndex, edition, system],
);
const uploadAndCacheSource = useCallback(
async (sourceCode: string, jsonData: unknown): Promise<void> => {
// biome-ignore lint/suspicious/noExplicitAny: raw JSON shape varies
const creatures = normalizeBestiary(jsonData as any);
const displayName = bestiaryIndex.getSourceDisplayName(sourceCode);
await bestiaryCache.cacheSource(sourceCode, displayName, creatures);
const creatures =
edition === "pf2e"
? normalizePf2eBestiary(jsonData as { creature: unknown[] })
: normalizeBestiary(
jsonData as Parameters<typeof normalizeBestiary>[0],
);
const displayName =
edition === "pf2e"
? pf2eBestiaryIndex.getSourceDisplayName(sourceCode)
: bestiaryIndex.getSourceDisplayName(sourceCode);
await bestiaryCache.cacheSource(
system,
sourceCode,
displayName,
creatures,
);
setCreatureMap((prev) => {
const next = new Map(prev);
for (const c of creatures) {
@@ -114,7 +171,7 @@ export function useBestiary(): BestiaryHook {
return next;
});
},
[bestiaryCache, bestiaryIndex],
[bestiaryCache, bestiaryIndex, pf2eBestiaryIndex, edition, system],
);
const refreshCache = useCallback(async (): Promise<void> => {

View File

@@ -1,5 +1,6 @@
import { useCallback, useRef, useState } from "react";
import { useAdapters } from "../contexts/adapter-context.js";
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
const BATCH_SIZE = 6;
@@ -29,7 +30,9 @@ interface BulkImportHook {
}
export function useBulkImport(): BulkImportHook {
const { bestiaryIndex } = useAdapters();
const { bestiaryIndex, pf2eBestiaryIndex } = useAdapters();
const { edition } = useRulesEditionContext();
const indexPort = edition === "pf2e" ? pf2eBestiaryIndex : bestiaryIndex;
const [state, setState] = useState<BulkImportState>(IDLE_STATE);
const countersRef = useRef({ completed: 0, failed: 0 });
@@ -40,7 +43,7 @@ export function useBulkImport(): BulkImportHook {
isSourceCached: (sourceCode: string) => Promise<boolean>,
refreshCache: () => Promise<void>,
) => {
const allCodes = bestiaryIndex.getAllSourceCodes();
const allCodes = indexPort.getAllSourceCodes();
const total = allCodes.length;
countersRef.current = { completed: 0, failed: 0 };
@@ -81,7 +84,7 @@ export function useBulkImport(): BulkImportHook {
chain.then(() =>
Promise.allSettled(
batch.map(async ({ code }) => {
const url = bestiaryIndex.getDefaultFetchUrl(code, baseUrl);
const url = indexPort.getDefaultFetchUrl(code, baseUrl);
try {
await fetchAndCacheSource(code, url);
countersRef.current.completed++;
@@ -115,7 +118,7 @@ export function useBulkImport(): BulkImportHook {
});
})();
},
[bestiaryIndex],
[indexPort],
);
const reset = useCallback(() => {

View File

@@ -64,7 +64,7 @@ export function useDifficultyBreakdown(): DifficultyBreakdown | null {
}
type CreatureInfo = {
cr: string;
cr?: string;
source: string;
sourceDisplayName: string;
};
@@ -87,10 +87,11 @@ function buildBreakdownEntry(
};
}
if (creature) {
const cr = creature.cr ?? null;
return {
combatant: c,
cr: creature.cr,
xp: crToXp(creature.cr),
cr,
xp: cr ? crToXp(cr) : null,
source: creature.sourceDisplayName ?? creature.source,
editable: false,
side,
@@ -132,7 +133,7 @@ function resolveCr(
getCreature: (id: CreatureId) => CreatureInfo | undefined,
): { cr: string | null; creature: CreatureInfo | undefined } {
const creature = c.creatureId ? getCreature(c.creatureId) : undefined;
const cr = creature ? creature.cr : (c.cr ?? null);
const cr = creature?.cr ?? c.cr ?? null;
return { cr, creature };
}

View File

@@ -1,4 +1,5 @@
import type {
AnyCreature,
Combatant,
CombatantDescriptor,
CreatureId,
@@ -20,7 +21,7 @@ export function resolveSide(c: Combatant): "party" | "enemy" {
function buildDescriptors(
combatants: readonly Combatant[],
characters: readonly PlayerCharacter[],
getCreature: (id: CreatureId) => { cr: string } | undefined,
getCreature: (id: CreatureId) => AnyCreature | undefined,
): CombatantDescriptor[] {
const descriptors: CombatantDescriptor[] = [];
for (const c of combatants) {
@@ -28,9 +29,10 @@ function buildDescriptors(
const level = c.playerCharacterId
? characters.find((p) => p.id === c.playerCharacterId)?.level
: undefined;
const cr = c.creatureId
? getCreature(c.creatureId)?.cr
: (c.cr ?? undefined);
const creature = c.creatureId ? getCreature(c.creatureId) : undefined;
const creatureCr =
creature && !("system" in creature) ? creature.cr : undefined;
const cr = creatureCr ?? c.cr ?? undefined;
if (level !== undefined || cr !== undefined) {
descriptors.push({ level, cr, side });
@@ -46,6 +48,8 @@ export function useDifficulty(): DifficultyResult | null {
const { edition } = useRulesEditionContext();
return useMemo(() => {
if (edition === "pf2e") return null;
const descriptors = buildDescriptors(
encounter.combatants,
characters,

View File

@@ -4,11 +4,13 @@ import {
adjustHpUseCase,
advanceTurnUseCase,
clearEncounterUseCase,
decrementConditionUseCase,
editCombatantUseCase,
redoUseCase,
removeCombatantUseCase,
retreatTurnUseCase,
setAcUseCase,
setConditionValueUseCase,
setCrUseCase,
setHpUseCase,
setInitiativeUseCase,
@@ -19,7 +21,6 @@ import {
undoUseCase,
} from "@initiative/application";
import type {
BestiaryIndexEntry,
CombatantId,
CombatantInit,
ConditionId,
@@ -40,6 +41,7 @@ import {
} from "@initiative/domain";
import { useCallback, useEffect, useReducer, useRef } from "react";
import { useAdapters } from "../contexts/adapter-context.js";
import type { SearchResult } from "./use-bestiary.js";
// -- Types --
@@ -61,14 +63,25 @@ type EncounterAction =
id: CombatantId;
conditionId: ConditionId;
}
| {
type: "set-condition-value";
id: CombatantId;
conditionId: ConditionId;
value: number;
}
| {
type: "decrement-condition";
id: CombatantId;
conditionId: ConditionId;
}
| { type: "toggle-concentration"; id: CombatantId }
| { type: "clear-encounter" }
| { type: "undo" }
| { type: "redo" }
| { type: "add-from-bestiary"; entry: BestiaryIndexEntry }
| { type: "add-from-bestiary"; entry: SearchResult }
| {
type: "add-multiple-from-bestiary";
entry: BestiaryIndexEntry;
entry: SearchResult;
count: number;
}
| { type: "add-from-player-character"; pc: PlayerCharacter }
@@ -156,7 +169,7 @@ function resolveAndRename(store: EncounterStore, name: string): string {
function addOneFromBestiary(
store: EncounterStore,
entry: BestiaryIndexEntry,
entry: SearchResult,
nextId: number,
): {
cId: CreatureId;
@@ -215,7 +228,7 @@ function handleUndoRedo(
function handleAddFromBestiary(
state: EncounterState,
entry: BestiaryIndexEntry,
entry: SearchResult,
count: number,
): EncounterState {
const { store, getEncounter } = makeStoreFromState(state);
@@ -325,6 +338,8 @@ function dispatchEncounterAction(
| { type: "set-cr" }
| { type: "set-side" }
| { type: "toggle-condition" }
| { type: "set-condition-value" }
| { type: "decrement-condition" }
| { type: "toggle-concentration" }
>,
): EncounterState {
@@ -373,6 +388,17 @@ function dispatchEncounterAction(
case "toggle-condition":
result = toggleConditionUseCase(store, action.id, action.conditionId);
break;
case "set-condition-value":
result = setConditionValueUseCase(
store,
action.id,
action.conditionId,
action.value,
);
break;
case "decrement-condition":
result = decrementConditionUseCase(store, action.id, action.conditionId);
break;
case "toggle-concentration":
result = toggleConcentrationUseCase(store, action.id);
break;
@@ -522,6 +548,16 @@ export function useEncounter() {
dispatch({ type: "toggle-condition", id, conditionId }),
[],
),
setConditionValue: useCallback(
(id: CombatantId, conditionId: ConditionId, value: number) =>
dispatch({ type: "set-condition-value", id, conditionId, value }),
[],
),
decrementCondition: useCallback(
(id: CombatantId, conditionId: ConditionId) =>
dispatch({ type: "decrement-condition", id, conditionId }),
[],
),
toggleConcentration: useCallback(
(id: CombatantId) => dispatch({ type: "toggle-concentration", id }),
[],
@@ -530,15 +566,12 @@ export function useEncounter() {
() => dispatch({ type: "clear-encounter" }),
[],
),
addFromBestiary: useCallback(
(entry: BestiaryIndexEntry): CreatureId | null => {
dispatch({ type: "add-from-bestiary", entry });
return null;
},
[],
),
addFromBestiary: useCallback((entry: SearchResult): CreatureId | null => {
dispatch({ type: "add-from-bestiary", entry });
return null;
}, []),
addMultipleFromBestiary: useCallback(
(entry: BestiaryIndexEntry, count: number): CreatureId | null => {
(entry: SearchResult, count: number): CreatureId | null => {
dispatch({
type: "add-multiple-from-bestiary",
entry,

View File

@@ -1,7 +1,8 @@
import type { RulesEdition } from "@initiative/domain";
import { useCallback, useSyncExternalStore } from "react";
const STORAGE_KEY = "initiative:rules-edition";
const STORAGE_KEY = "initiative:game-system";
const OLD_STORAGE_KEY = "initiative:rules-edition";
const listeners = new Set<() => void>();
let currentEdition: RulesEdition = loadEdition();
@@ -9,7 +10,14 @@ let currentEdition: RulesEdition = loadEdition();
function loadEdition(): RulesEdition {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw === "5e" || raw === "5.5e") return raw;
if (raw === "5e" || raw === "5.5e" || raw === "pf2e") return raw;
// Migrate from old key
const old = localStorage.getItem(OLD_STORAGE_KEY);
if (old === "5e" || old === "5.5e") {
localStorage.setItem(STORAGE_KEY, old);
localStorage.removeItem(OLD_STORAGE_KEY);
return old;
}
} catch {
// storage unavailable
}