/** * Loads character dossiers at boot time. * * Dossiers live as JSON files at ./dossiers/{id}.json — one per character. * They are generated by apps/api/scripts/generate-who-dossiers.ts (a strong * cloud LLM does the research) and then consumed at runtime by the small * model (gemma3:4b) in carefully staged chunks to keep gameplay coherent. * * This loader: * 1. Reads every {id}.json under ./dossiers/ * 2. Validates each against CharacterDossierSchema (Zod) * 3. Sanity-checks that the dossier id matches the filename * 4. Builds a Map for O(1) runtime lookup * * Missing dossiers are NOT a hard error — the prompt builder falls back to * the legacy `personality` string on the WhoCharacter so the module keeps * working during the rollout. We only warn at boot. */ /* eslint-disable no-console */ import { readdirSync, readFileSync, existsSync } from 'node:fs'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { CharacterDossierSchema, type CharacterDossier } from './dossier-types'; import { CHARACTERS } from './characters'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const DOSSIER_DIR = join(__dirname, 'dossiers'); let dossierCache: Map | null = null; function loadDossiers(): Map { const map = new Map(); if (!existsSync(DOSSIER_DIR)) { console.warn( `[who] dossier directory missing at ${DOSSIER_DIR} — falling back to legacy personality strings for all characters. Run: bun run apps/api/scripts/generate-who-dossiers.ts` ); return map; } const files = readdirSync(DOSSIER_DIR).filter( (f) => f.endsWith('.json') && !f.endsWith('.raw.json') ); for (const file of files) { const path = join(DOSSIER_DIR, file); try { const raw = readFileSync(path, 'utf8'); const parsed = JSON.parse(raw); const dossier = CharacterDossierSchema.parse(parsed); // Filename must match the dossier id (catches accidental copies) const expectedId = Number(file.replace('.json', '')); if (dossier.id !== expectedId) { console.warn( `[who] dossier ${file}: id mismatch (file=${expectedId}, content=${dossier.id}) — skipping` ); continue; } map.set(dossier.id, dossier); } catch (err) { console.warn( `[who] failed to load dossier ${file}: ${err instanceof Error ? err.message : String(err)}` ); } } // Report coverage so devs notice gaps quickly during rollout const missing = CHARACTERS.filter((c) => !map.has(c.id)); if (missing.length > 0) { console.warn( `[who] ${map.size}/${CHARACTERS.length} dossiers loaded; missing ids: ${missing .map((c) => `${c.id}(${c.name})`) .join(', ')}` ); } else { console.log(`[who] loaded ${map.size}/${CHARACTERS.length} character dossiers`); } return map; } /** Returns the dossier for a character id, or undefined if not generated yet. */ export function getDossier(id: number): CharacterDossier | undefined { if (!dossierCache) { dossierCache = loadDossiers(); } return dossierCache.get(id); } /** Test/dev helper: force a reload from disk. */ export function reloadDossiers(): void { dossierCache = null; }