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:
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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>,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 },
|
||||
143
apps/web/src/components/pf2e-stat-block.tsx
Normal file
143
apps/web/src/components/pf2e-stat-block.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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>("");
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
90
apps/web/src/components/stat-block-parts.tsx
Normal file
90
apps/web/src/components/stat-block-parts.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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> => {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user