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:
@@ -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> => {
|
||||
|
||||
Reference in New Issue
Block a user