Files
initiative/apps/web/src/adapters/bestiary-cache.ts
Lukas e62c49434c
All checks were successful
CI / check (push) Successful in 2m21s
CI / build-image (push) Successful in 24s
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>
2026-04-07 01:26:22 +02:00

170 lines
3.8 KiB
TypeScript

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 = 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: AnyCreature[];
cachedAt: number;
creatureCount: number;
system?: string;
}
let db: IDBPDatabase | null = null;
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;
try {
db = await openDB(DB_NAME, DB_VERSION, {
upgrade(database, oldVersion, _newVersion, transaction) {
if (oldVersion < 1) {
database.createObjectStore(STORE_NAME, {
keyPath: "sourceCode",
});
}
if (
oldVersion < DB_VERSION &&
database.objectStoreNames.contains(STORE_NAME)
) {
// Clear cached creatures so they get re-normalized with latest rendering
void transaction.objectStore(STORE_NAME).clear();
}
},
});
return db;
} catch {
dbFailed = true;
console.warn(
"IndexedDB unavailable — bestiary cache will not persist across sessions.",
);
return null;
}
}
export async function cacheSource(
system: string,
sourceCode: string,
displayName: string,
creatures: AnyCreature[],
): Promise<void> {
const key = scopedKey(system, sourceCode);
const record: CachedSourceRecord = {
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(key, record);
}
}
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, key);
return record !== undefined;
}
return memoryStore.has(key);
}
export async function getCachedSources(
system?: string,
): Promise<CachedSourceInfo[]> {
const database = await getDb();
let records: CachedSourceRecord[];
if (database) {
records = await database.getAll(STORE_NAME);
} else {
records = [...memoryStore.values()];
}
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(
system: string,
sourceCode: string,
): Promise<void> {
const key = scopedKey(system, sourceCode);
const database = await getDb();
if (database) {
await database.delete(STORE_NAME, key);
} else {
memoryStore.delete(key);
}
}
export async function clearAll(): Promise<void> {
const database = await getDb();
if (database) {
await database.clear(STORE_NAME);
} else {
memoryStore.clear();
}
}
export async function loadAllCachedCreatures(): Promise<
Map<CreatureId, AnyCreature>
> {
const map = new Map<CreatureId, AnyCreature>();
const database = await getDb();
let records: CachedSourceRecord[];
if (database) {
records = await database.getAll(STORE_NAME);
} else {
records = [...memoryStore.values()];
}
for (const record of records) {
for (const creature of record.creatures) {
map.set(creature.id, creature);
}
}
return map;
}