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>
170 lines
3.8 KiB
TypeScript
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;
|
|
}
|