polats Claude Opus 4.8 (1M context) commited on
Commit
72082c8
·
1 Parent(s): 9fe1971

Phase 3+4: character chat (woid context) with per-message voice playback

Browse files

characterContext.js ports woid's two-part pattern client-side: a static identity
system prompt + a dynamic state turn (level, equipped skills, a perception log of
recent kills/level-ups/forged skills) + verbatim recent-chat memory. characterChat.js
mounts a mobile chat thread that streams replies via the local text model
(runtime.js) and persists history to the soul. Each reply gets a ▶ button that speaks
it in the fighter's voice (tts.js), caching the WAV per message in IndexedDB. A
'Talk to <name>' button on the Game character sheet opens it as a bottom-sheet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files changed (3) hide show
  1. web/characterChat.js +110 -0
  2. web/characterContext.js +63 -0
  3. web/tiny.js +24 -0
web/characterChat.js ADDED
@@ -0,0 +1,110 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Character chat — talk to a fighter on its character page. Streams replies from the local
2
+ // text model (runtime.js facade) using woid-style assembled context (characterContext.js), so
3
+ // the fighter answers in-voice and aware of its real game state. Each reply gets a ▶ button
4
+ // that speaks it in the fighter's voice (tts.js), caching the WAV per-message (Phase 4).
5
+ import { streamChat, ensureModel, currentModelId } from '/web/runtime.js'
6
+ import { noThink, stripThinkFinal } from '/web/personaPrompts.js'
7
+ import { buildContext } from '/web/characterContext.js'
8
+ import { getPersona, patchPersona, putChatAudio, getChatAudio } from '/web/personaStore.js'
9
+ import {
10
+ ensureTts, createVoiceWav, synthVoiceWav, speakVoiceLive, playWav,
11
+ activeEngineIsDesign, activeEngineIsNative, currentVoiceId, getAutoNarrate,
12
+ } from '/web/tts.js'
13
+
14
+ const msgId = () => 'm_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6)
15
+ const HISTORY_CAP = 50
16
+
17
+ // Speak `text` in the fighter's voice; cache the WAV under the message id for instant replay.
18
+ async function speak(id, text, p, btn) {
19
+ const set = (s) => { if (btn) btn.textContent = s }
20
+ try {
21
+ if (activeEngineIsNative()) { set('⏳'); await ensureTts(); await speakVoiceLive(p.voiceId || currentVoiceId(), text); return }
22
+ let blob = await getChatAudio(id)
23
+ let bytes
24
+ if (blob) { bytes = await blob.arrayBuffer() }
25
+ else {
26
+ set('⏳'); await ensureTts()
27
+ const wav = activeEngineIsDesign() ? await createVoiceWav(p.voice || '', text) : await synthVoiceWav(p.voiceId || currentVoiceId(), text)
28
+ bytes = wav instanceof Blob ? await wav.arrayBuffer() : wav
29
+ try { await putChatAudio(id, new Blob([bytes.slice(0)])) } catch { /* cache best-effort */ }
30
+ }
31
+ set('⏸'); await playWav(bytes.slice(0))
32
+ } catch { /* voice is best-effort */ } finally { set('▶') }
33
+ }
34
+
35
+ export function mountCharacterChat(host, personaId, { onUpdate } = {}) {
36
+ const wrap = document.createElement('div'); wrap.style.cssText = 'display:flex;flex-direction:column;height:100%;min-height:0'
37
+ const thread = document.createElement('div'); thread.style.cssText = 'flex:1;overflow-y:auto;display:flex;flex-direction:column;gap:8px;padding:4px 2px 8px'
38
+ const form = document.createElement('div'); form.style.cssText = 'display:flex;gap:8px;align-items:flex-end;border-top:1px solid #232b36;padding-top:8px'
39
+ const input = document.createElement('textarea'); input.rows = 1; input.placeholder = 'say something…'
40
+ input.style.cssText = 'flex:1;resize:none;max-height:96px;background:#0d1015;border:1px solid #2a3340;border-radius:10px;color:#e8e8e8;padding:9px 11px;font:13px var(--tac-font,system-ui);box-sizing:border-box'
41
+ const send = document.createElement('button'); send.type = 'button'; send.textContent = '➤'
42
+ send.style.cssText = 'flex:none;width:42px;height:40px;border-radius:10px;border:1px solid #2a3340;background:#1a2230;color:#e8e8e8;font-size:16px;cursor:pointer'
43
+ form.append(input, send); wrap.append(thread, form); host.append(wrap)
44
+
45
+ const bubble = (role, text) => {
46
+ const row = document.createElement('div'); row.style.cssText = `display:flex;flex-direction:column;max-width:88%;${role === 'user' ? 'align-self:flex-end;align-items:flex-end' : 'align-self:flex-start;align-items:flex-start'}`
47
+ const b = document.createElement('div')
48
+ b.style.cssText = `padding:8px 11px;border-radius:12px;line-height:1.4;font:13px var(--tac-font,system-ui);white-space:pre-wrap;${role === 'user' ? 'background:#1f6feb;color:#fff;border-bottom-right-radius:4px' : 'background:#1a2230;color:#e8e8e8;border:1px solid #232b36;border-bottom-left-radius:4px'}`
49
+ b.textContent = text
50
+ row.append(b); row._b = b; return row
51
+ }
52
+
53
+ function renderThread() {
54
+ const p = getPersona(personaId)
55
+ thread.replaceChildren()
56
+ const hist = (p && p.chat && p.chat.history) || []
57
+ if (!hist.length) {
58
+ const hint = document.createElement('div'); hint.textContent = `Ask ${p ? p.name : 'them'} about a battle, a skill, or how they feel.`
59
+ hint.style.cssText = 'color:#6b7686;font-size:12px;text-align:center;margin:auto;padding:20px 6px'; thread.append(hint)
60
+ }
61
+ for (const m of hist) {
62
+ const row = bubble(m.role, m.text)
63
+ if (m.role === 'assistant') {
64
+ const play = document.createElement('button'); play.type = 'button'; play.textContent = '▶'
65
+ play.title = 'Hear it'; play.style.cssText = 'margin-top:3px;background:none;border:none;color:#9aa4b2;font-size:12px;cursor:pointer;padding:2px 4px'
66
+ play.addEventListener('click', () => speak(m.id, m.text, getPersona(personaId), play))
67
+ row.append(play)
68
+ }
69
+ thread.append(row)
70
+ }
71
+ thread.scrollTop = thread.scrollHeight
72
+ }
73
+
74
+ function persist(role, id, text) {
75
+ const cur = getPersona(personaId); if (!cur) return
76
+ const history = [...((cur.chat && cur.chat.history) || []), { id, role, text, ts: Date.now() }].slice(-HISTORY_CAP)
77
+ patchPersona(personaId, { chat: { history } })
78
+ onUpdate && onUpdate()
79
+ }
80
+
81
+ let busy = false
82
+ async function submit() {
83
+ const text = input.value.trim(); if (!text || busy) return
84
+ const p = getPersona(personaId); if (!p) return
85
+ busy = true; send.disabled = true; input.value = ''
86
+ persist('user', msgId(), text); renderThread()
87
+ // Transient streaming bubble (not yet in history) we write tokens into live.
88
+ const row = bubble('assistant', '…'); thread.append(row); thread.scrollTop = thread.scrollHeight
89
+ const { system, user } = buildContext(p, text)
90
+ let raw = ''
91
+ try {
92
+ await ensureModel(() => {})
93
+ await streamChat(system, user + noThink(currentModelId()), {
94
+ maxTokens: 220, temperature: 0.85,
95
+ onToken: (t) => { raw += t; row._b.textContent = stripThinkFinal(raw) || '…'; thread.scrollTop = thread.scrollHeight },
96
+ })
97
+ } catch (e) { row._b.textContent = '…(couldn’t answer: ' + (e && e.message ? e.message : e) + ')' }
98
+ const final = stripThinkFinal(raw).trim() || '…'
99
+ const id = msgId()
100
+ persist('assistant', id, final)
101
+ renderThread()
102
+ if (getAutoNarrate()) { const btns = thread.querySelectorAll('button'); speak(id, final, getPersona(personaId), btns[btns.length - 1]) }
103
+ busy = false; send.disabled = false
104
+ }
105
+
106
+ send.addEventListener('click', submit)
107
+ input.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); submit() } })
108
+ renderThread()
109
+ return { refresh: renderThread, focus: () => input.focus() }
110
+ }
web/characterContext.js ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Character context — a single-player, client-side port of woid's two-part context pattern
2
+ // (agent-sandbox/pi-bridge/buildContext.js): a STATIC system prompt that is the fighter's
3
+ // identity, plus a DYNAMIC "what's happening to you now" turn assembled from live game state
4
+ // (level, recent battle/skill events — woid's perception log) and a VERBATIM slice of the
5
+ // recent conversation (woid drops summarization; so do we). See docs/rpg-progression-plan.md.
6
+
7
+ import { effectSummary } from '/web/skillSchema.js'
8
+
9
+ // One perception-log line per event, in the fighter's lived-experience voice.
10
+ function formatEvent(e) {
11
+ switch (e && e.type) {
12
+ case 'kill': return `You defeated ${e.enemy || 'a foe'} (+${e.xp || 0} xp).`
13
+ case 'level_up': return `You grew stronger — you reached level ${e.level}.`
14
+ case 'skill_learned': return `You forged a new skill: "${e.skill}".`
15
+ default: return null
16
+ }
17
+ }
18
+
19
+ // STATIC identity. Built once per chat session; the fighter's "bible".
20
+ export function buildSystemPrompt(p) {
21
+ if (!p) return 'You are a fighter in a fantasy auto-battler. Stay in character.'
22
+ const lines = [
23
+ `You are ${p.name || 'a nameless fighter'}${p.unitClass ? `, a ${p.unitClass}` : ''} in a fantasy auto-battler called Tiny Army.`,
24
+ p.about ? `Who you are: ${p.about}` : '',
25
+ [p.personality && `Personality: ${p.personality}`, p.vibe && `Vibe: ${p.vibe}`, p.specialty && `Specialty: ${p.specialty}`].filter(Boolean).join('. '),
26
+ '',
27
+ 'You are a REAL fighter who has actually lived the battles described below — speak from that experience, in first person, never as an assistant.',
28
+ 'Stay fully in character. Keep replies short and natural (1-3 sentences). Never mention being an AI, a model, or a game system. Do not use markdown or stage directions.',
29
+ ]
30
+ return lines.filter((l) => l !== '').join('\n')
31
+ }
32
+
33
+ // DYNAMIC turn: current standing + recent perception events + verbatim recent chat + new line.
34
+ export function buildStateTurn(p, userMsg, { maxEvents = 5, maxHistory = 8 } = {}) {
35
+ const prog = (p && p.progression) || { level: 1, kills: 0 }
36
+ const cs = (p && p.customSkills) || {}
37
+ const equipped = (p && p.equippedSkills || []).map((id) => cs[id]).filter(Boolean)
38
+ const events = (p && p.events || []).map(formatEvent).filter(Boolean).slice(-maxEvents)
39
+ const history = (p && p.chat && p.chat.history || []).slice(-maxHistory)
40
+
41
+ const state = [
42
+ `Level ${prog.level}${prog.kills ? `, ${prog.kills} foes felled` : ''}.`,
43
+ equipped.length ? `Skills at the ready: ${equipped.map((s) => `${s.name} (${effectSummary(s)})`).join('; ')}.` : '',
44
+ events.length ? `Lately:\n${events.map((e) => ` - ${e}`).join('\n')}` : '',
45
+ ].filter(Boolean).join('\n')
46
+
47
+ const convo = history.map((m) => `${m.role === 'user' ? 'Player' : 'You'}: ${m.text}`).join('\n')
48
+
49
+ return [
50
+ '[Where you stand right now]',
51
+ state || 'You are fresh to the field, untested.',
52
+ '',
53
+ convo ? '[Your conversation so far]\n' + convo + '\n' : '',
54
+ `Player: ${userMsg}`,
55
+ '',
56
+ `Reply as ${(p && p.name) || 'yourself'}, in first person, 1-3 sentences.`,
57
+ ].filter((l) => l !== '').join('\n')
58
+ }
59
+
60
+ // Convenience: assemble both halves for a turn.
61
+ export function buildContext(p, userMsg, opts) {
62
+ return { system: buildSystemPrompt(p), user: buildStateTurn(p, userMsg, opts) }
63
+ }
web/tiny.js CHANGED
@@ -20,6 +20,7 @@ import { applyXp, enemyXpValue, levelProgress, xpToNext, appendEvent } from '/we
20
  import { getSkillIcon, getSkillArt, putSkillArt } from '/web/personaStore.js'
21
  import { effectSummary, resolveEquipped } from '/web/skillSchema.js'
22
  import { generatePortrait } from '/web/imagen.js'
 
23
  import { mountDiaryPanel } from '/web/diaryPanel.js'
24
  import { mountSettingsPanel } from '/web/settingsPanel.js'
25
  import { mountSkillForgePanel } from '/web/skillForgePanel.js'
@@ -451,6 +452,23 @@ whenEl('battle-stage', async (el) => {
451
  } catch { art.textContent = 'art unavailable' }
452
  })
453
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
454
  const renderSheet = () => {
455
  sheet.innerHTML = ''
456
  const close = document.createElement('button'); close.type = 'button'; close.textContent = '✕'
@@ -478,6 +496,12 @@ whenEl('battle-stage', async (el) => {
478
  if (prog.attributePoints > 0) { const pts = document.createElement('span'); pts.textContent = `★ ${prog.attributePoints} point${prog.attributePoints > 1 ? 's' : ''}`; pts.style.color = '#ffe082'; meta.append(pts) }
479
  sheet.append(meta)
480
  }
 
 
 
 
 
 
481
  if (hero) {
482
  const wrap = document.createElement('div'); wrap.style.cssText = 'margin:6px 0 12px'
483
  const lab = document.createElement('div'); lab.style.cssText = 'font-size:11px;color:#9aa4b2;margin-bottom:3px'
 
20
  import { getSkillIcon, getSkillArt, putSkillArt } from '/web/personaStore.js'
21
  import { effectSummary, resolveEquipped } from '/web/skillSchema.js'
22
  import { generatePortrait } from '/web/imagen.js'
23
+ import { mountCharacterChat } from '/web/characterChat.js'
24
  import { mountDiaryPanel } from '/web/diaryPanel.js'
25
  import { mountSettingsPanel } from '/web/settingsPanel.js'
26
  import { mountSkillForgePanel } from '/web/skillForgePanel.js'
 
452
  } catch { art.textContent = 'art unavailable' }
453
  })
454
  }
455
+ // Chat with the current hero in a bottom-sheet (woid-context chat + per-message voice).
456
+ let chatSheet = null
457
+ const openChat = () => {
458
+ if (!currentHero || !currentHero.id) return
459
+ chatSheet?.remove()
460
+ const back = document.createElement('div'); back.style.cssText = 'position:absolute;inset:0;z-index:11;background:rgba(0,0,0,.55);display:flex;align-items:flex-end;justify-content:center'
461
+ const sh = document.createElement('div'); sh.style.cssText = 'width:min(440px,96vw);height:82%;display:flex;flex-direction:column;background:#11151c;border:1px solid #2a3340;border-radius:16px 16px 0 0;padding:14px;color:#e8e8e8;box-sizing:border-box'
462
+ const head = document.createElement('div'); head.style.cssText = 'display:flex;align-items:center;justify-content:space-between;margin-bottom:8px'
463
+ const ttl = document.createElement('div'); ttl.textContent = `💬 ${currentHero.name || 'Hero'}`; ttl.style.cssText = 'font:700 16px var(--tac-font,system-ui)'
464
+ const x = document.createElement('button'); x.type = 'button'; x.textContent = '✕'; x.style.cssText = 'background:none;border:none;color:#9aa4b2;font-size:20px;cursor:pointer'
465
+ const closeChat = () => { back.remove(); chatSheet = null }
466
+ x.addEventListener('click', closeChat); head.append(ttl, x); sh.append(head)
467
+ const body = document.createElement('div'); body.style.cssText = 'flex:1;min-height:0;display:flex;flex-direction:column'; sh.append(body)
468
+ const chat = mountCharacterChat(body, currentHero.id, {}); setTimeout(() => chat.focus(), 50)
469
+ back.addEventListener('pointerdown', (e) => { if (e.target === back) closeChat() })
470
+ back.append(sh); el.append(back); chatSheet = back
471
+ }
472
  const renderSheet = () => {
473
  sheet.innerHTML = ''
474
  const close = document.createElement('button'); close.type = 'button'; close.textContent = '✕'
 
496
  if (prog.attributePoints > 0) { const pts = document.createElement('span'); pts.textContent = `★ ${prog.attributePoints} point${prog.attributePoints > 1 ? 's' : ''}`; pts.style.color = '#ffe082'; meta.append(pts) }
497
  sheet.append(meta)
498
  }
499
+ // Talk to this fighter (chat + voice). Needs a saved hero (id) to persist the conversation.
500
+ if (currentHero.id) {
501
+ const talk = document.createElement('button'); talk.type = 'button'; talk.textContent = `💬 Talk to ${currentHero.name || 'them'}`
502
+ talk.style.cssText = 'width:100%;padding:11px;border-radius:10px;border:1px solid #2a3340;background:#16202e;color:#e8e8e8;font:600 13px var(--tac-font,system-ui);cursor:pointer;margin-bottom:12px'
503
+ talk.addEventListener('click', openChat); sheet.append(talk)
504
+ }
505
  if (hero) {
506
  const wrap = document.createElement('div'); wrap.style.cssText = 'margin:6px 0 12px'
507
  const lab = document.createElement('div'); lab.style.cssText = 'font-size:11px;color:#9aa4b2;margin-bottom:3px'