File size: 4,790 Bytes
29474fc
0ecdf98
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29474fc
0ecdf98
 
 
 
e648dca
 
 
0ecdf98
 
 
 
9f5e779
 
 
 
e648dca
6a861b9
 
 
 
0ecdf98
 
 
 
 
 
 
 
 
 
 
 
 
 
6a861b9
308478f
 
6a861b9
 
308478f
 
 
 
6a861b9
 
 
 
 
 
308478f
 
 
 
 
 
6a861b9
308478f
 
6a861b9
308478f
 
6a861b9
308478f
 
6a861b9
308478f
 
6a861b9
 
0ecdf98
6a861b9
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
// 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)