// Local-first roster of saved heroes (personas-as-agents). Modeled on woid's Shelter // store: a single JSON blob in localStorage, simple CRUD, change listeners, and a // pluggable `sync` hook so a backend can push/pull later WITHOUT changing callers. // Voice is stored as the design TEXT + quote (re-synthesized on replay) — the audio // blob and cross-device sync are the backend's job (see the plan). Future persona data // (stats, avatar, xp, relationships) just adds fields to the record. const KEY = 'tinyarmy.roster.v1' const listeners = new Set() let _sync = null // optional { push(records), pull() } — wired to a backend later function read() { try { const d = JSON.parse(localStorage.getItem(KEY) || '{}'); return Array.isArray(d.personas) ? d : { personas: [] } } catch { return { personas: [] } } } function write(d) { try { localStorage.setItem(KEY, JSON.stringify(d)) } catch { /* quota / disabled */ } for (const fn of listeners) { try { fn(d.personas) } catch { /* ignore */ } } if (_sync && _sync.push) { try { _sync.push(d.personas) } catch { /* best-effort */ } } } const newId = () => 's_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 7) export function listPersonas() { return read().personas } export function getPersona(id) { return read().personas.find((p) => p.id === id) || null } export function onRosterChange(fn) { listeners.add(fn); return () => listeners.delete(fn) } export function setSync(sync) { _sync = sync } // Insert or update. Returns the stored record (with id + timestamps). export function savePersona(p) { const d = read() const now = Date.now() const id = p.id || newId() const rec = { id, name: p.name || 'Unnamed hero', unitClass: p.unitClass || '', about: p.about || '', quote: p.quote || '', voice: p.voice || '', // Named voice for fixed-voice providers (Kokoro/Kitten/Web Speech); the design `voice` // text above is what Qwen3-TTS uses instead. Both are saved so a hero keeps its voice. voiceId: p.voiceId || '', specialty: p.specialty || '', personality: p.personality || '', vibe: p.vibe || '', seed: p.seed || '', // What the cached voice file (IndexedDB) was made for — so after a refresh we know // the audio is current (replay it) vs stale (re-make it), instead of always re-synthing. voiceQuote: p.voiceQuote || '', voiceDesignUsed: p.voiceDesignUsed || '', voiceIdUsed: p.voiceIdUsed || '', // Portrait: the editable appearance prompt + what the cached image (IndexedDB) was // made from, so a reload knows the image is current vs stale (re-generate it). appearance: p.appearance || '', portraitUsed: p.portraitUsed || '', createdAt: now, updatedAt: now, } const i = d.personas.findIndex((x) => x.id === id) if (i >= 0) { rec.createdAt = d.personas[i].createdAt; d.personas[i] = rec } else { d.personas.unshift(rec) } write(d) return rec } export function removePersona(id) { const d = read() const next = d.personas.filter((x) => x.id !== id) if (next.length !== d.personas.length) write({ personas: next }) _del(STORE, id); _del(PSTORE, id) } // ── Media store (IndexedDB — WAV + PNG blobs are too big for localStorage) ────── const DB = 'tinyarmy', STORE = 'voices', PSTORE = 'portraits' let _dbp = null function db() { if (!_dbp) { _dbp = new Promise((res, rej) => { const r = indexedDB.open(DB, 2) // v2 adds the 'portraits' store alongside 'voices' r.onupgradeneeded = () => { const d = r.result if (!d.objectStoreNames.contains(STORE)) d.createObjectStore(STORE) if (!d.objectStoreNames.contains(PSTORE)) d.createObjectStore(PSTORE) } r.onsuccess = () => res(r.result) r.onerror = () => rej(r.error) }) } return _dbp } async function _put(store, id, blob) { try { const d = await db() await new Promise((res, rej) => { const t = d.transaction(store, 'readwrite'); t.objectStore(store).put(blob, id); t.oncomplete = res; t.onerror = () => rej(t.error) }) } catch { /* best-effort */ } } async function _get(store, id) { try { const d = await db() return await new Promise((res) => { const t = d.transaction(store, 'readonly'); const q = t.objectStore(store).get(id); q.onsuccess = () => res(q.result || null); q.onerror = () => res(null) }) } catch { return null } } async function _del(store, id) { try { const d = await db(); d.transaction(store, 'readwrite').objectStore(store).delete(id) } catch { /* ignore */ } } export const putAudio = (id, blob) => _put(STORE, id, blob) export const getAudio = (id) => _get(STORE, id) export const putPortrait = (id, blob) => _put(PSTORE, id, blob) export const getPortrait = (id) => _get(PSTORE, id)