polats Claude Opus 4.8 (1M context) commited on
Commit
6ca2d09
·
1 Parent(s): 1eb2325

Fun+LLM: death after-action report with in-voice war-diary recap

Browse files

When a hero falls, intercept the death (snap.over) with an after-action screen: run
stats (foes felled, threat reached, levels gained, time held) + a streamed first-person
war-diary recap the fighter writes about THIS battle (load-bearing LLM). The recap is
banked to the hero's event log as a 'battle' memory, so chat recalls past battles.
Buttons: Fight again / Choose hero. Verified: overlay + real model recap + persistence.

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

Files changed (3) hide show
  1. web/afterAction.js +90 -0
  2. web/characterContext.js +1 -0
  3. web/tiny.js +19 -1
web/afterAction.js ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Death after-action — when a hero falls, show what the run achieved AND have the fighter
2
+ // recount the battle in its own voice (load-bearing LLM). The recap is banked to the hero's
3
+ // event log so it remembers this fight (feeds chat + future "living character" features).
4
+ import { streamChat, ensureModel, currentModelId } from '/web/runtime.js'
5
+ import { noThink, stripThinkFinal } from '/web/personaPrompts.js'
6
+ import { getPersona, patchPersona } from '/web/personaStore.js'
7
+ import { appendEvent } from '/web/progression.js'
8
+
9
+ function recapPrompts(p, run, recentEvents) {
10
+ const system = [
11
+ `You are ${p?.name || 'a fighter'}${p?.unitClass ? `, a ${p.unitClass}` : ''}, in a fantasy auto-battler.`,
12
+ 'You just fought a desperate battle and fell in the field. Recount it in FIRST PERSON, past tense,',
13
+ 'in 1-2 vivid sentences — proud, rueful or defiant as fits you. No preamble, no markdown.',
14
+ ].join(' ')
15
+ const lines = [
16
+ `This battle: you cut down ${run.killsThisRun} foe${run.killsThisRun === 1 ? '' : 's'}`,
17
+ run.levelEnd > run.levelStart ? `, rose from level ${run.levelStart} to ${run.levelEnd}` : `, at level ${run.levelEnd}`,
18
+ `, the threat climbed to ${run.threat}×`,
19
+ `, and you held for ${run.timeSurvived}s before falling.`,
20
+ ].join('')
21
+ return { system, user: lines + (recentEvents ? `\nAlong the way: ${recentEvents}.` : '') }
22
+ }
23
+
24
+ export function mountAfterAction(host, persona, run, { onAgain, onChoose } = {}) {
25
+ const back = document.createElement('div')
26
+ back.style.cssText = 'position:absolute;inset:0;z-index:13;background:rgba(6,8,12,.82);display:flex;align-items:center;justify-content:center;padding:16px'
27
+ const card = document.createElement('div')
28
+ card.style.cssText = 'width:min(440px,94vw);background:#11151c;border:1px solid #2a3340;border-radius:16px;padding:20px;color:#e8e8e8;box-sizing:border-box;text-align:center'
29
+
30
+ const title = document.createElement('div'); title.textContent = `${persona?.name || 'Your hero'} has fallen`
31
+ title.style.cssText = 'font:800 22px var(--tac-font,system-ui);color:#ff8a8a;margin-bottom:4px'
32
+ const sub = document.createElement('div'); sub.textContent = 'After-action report'
33
+ sub.style.cssText = 'font-size:11px;letter-spacing:.18em;text-transform:uppercase;color:#9aa4b2;margin-bottom:16px'
34
+ card.append(title, sub)
35
+
36
+ // Stat grid
37
+ const grid = document.createElement('div'); grid.style.cssText = 'display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-bottom:16px'
38
+ const stat = (label, value, gold) => {
39
+ const c = document.createElement('div'); c.style.cssText = 'background:rgba(20,24,33,.6);border:1px solid #232b36;border-radius:10px;padding:10px'
40
+ const v = document.createElement('div'); v.textContent = value; v.style.cssText = `font:800 20px var(--tac-font,system-ui);color:${gold ? '#ffe082' : '#e8e8e8'}`
41
+ const l = document.createElement('div'); l.textContent = label; l.style.cssText = 'font-size:10px;letter-spacing:.1em;text-transform:uppercase;color:#9aa4b2;margin-top:2px'
42
+ c.append(v, l); return c
43
+ }
44
+ grid.append(
45
+ stat('Foes felled', String(run.killsThisRun), true),
46
+ stat('Threat reached', run.threat + '×'),
47
+ stat('Levels gained', '+' + Math.max(0, run.levelEnd - run.levelStart)),
48
+ stat('Held for', run.timeSurvived + 's'),
49
+ )
50
+ card.append(grid)
51
+
52
+ // War-diary recap (streamed, in-voice)
53
+ const recapWrap = document.createElement('div'); recapWrap.style.cssText = 'background:rgba(20,24,33,.5);border-left:3px solid #c9a227;border-radius:8px;padding:12px;margin-bottom:16px;min-height:48px;text-align:left'
54
+ const recap = document.createElement('div'); recap.textContent = '…'; recap.style.cssText = 'font-style:italic;color:#c2c8d2;line-height:1.5;font:italic 14px var(--tac-font,system-ui)'
55
+ recapWrap.append(recap); card.append(recapWrap)
56
+
57
+ const btns = document.createElement('div'); btns.style.cssText = 'display:flex;gap:10px'
58
+ const again = document.createElement('button'); again.type = 'button'; again.textContent = '⚔ Fight again'
59
+ again.style.cssText = 'flex:1;padding:13px;border-radius:10px;border:1px solid #c9a227;background:#2a2410;color:#ffe082;font:700 14px var(--tac-font,system-ui);cursor:pointer'
60
+ const choose = document.createElement('button'); choose.type = 'button'; choose.textContent = 'Choose hero'
61
+ choose.style.cssText = 'flex:1;padding:13px;border-radius:10px;border:1px solid #2a3340;background:#1a2230;color:#e8e8e8;font:600 14px var(--tac-font,system-ui);cursor:pointer'
62
+ const close = () => back.remove()
63
+ again.addEventListener('click', () => { close(); onAgain && onAgain() })
64
+ choose.addEventListener('click', () => { close(); onChoose && onChoose() })
65
+ btns.append(again, choose); card.append(btns)
66
+
67
+ back.append(card); host.append(back)
68
+
69
+ // Stream the in-voice recap, then bank it to the hero's memory.
70
+ ;(async () => {
71
+ try {
72
+ const recent = (persona?.events || []).slice(-4).map((e) => e.type === 'skill_learned' ? `forged ${e.skill}` : e.type === 'level_up' ? `reached level ${e.level}` : '').filter(Boolean).join(', ')
73
+ const { system, user } = recapPrompts(persona, run, recent)
74
+ await ensureModel(() => {})
75
+ let raw = ''
76
+ await streamChat(system, user + noThink(currentModelId()), {
77
+ maxTokens: 110, temperature: 0.9,
78
+ onToken: (t) => { raw += t; recap.textContent = stripThinkFinal(raw) || '…' },
79
+ })
80
+ const text = stripThinkFinal(raw).trim() || '…'
81
+ recap.textContent = text
82
+ if (persona?.id) {
83
+ const fresh = getPersona(persona.id)
84
+ if (fresh) patchPersona(persona.id, { events: appendEvent(fresh.events, 'battle', { kills: run.killsThisRun, threat: run.threat, recap: text, ts: Date.now() }) })
85
+ }
86
+ } catch (e) { recap.textContent = '(the memory is a blur of steel and blood)' }
87
+ })()
88
+
89
+ return { close }
90
+ }
web/characterContext.js CHANGED
@@ -12,6 +12,7 @@ function formatEvent(e) {
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
  }
 
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
+ case 'battle': return e.recap ? `You remember a battle: "${e.recap}"` : `You fought a hard battle, felling ${e.kills || 0} foes before falling.`
16
  default: return null
17
  }
18
  }
web/tiny.js CHANGED
@@ -22,6 +22,7 @@ import { effectSummary, resolveEquipped } from '/web/skillSchema.js'
22
  import { generatePortrait } from '/web/imagen.js'
23
  import { mountCharacterChat } from '/web/characterChat.js'
24
  import { forgeSkillForHero } from '/web/skillForge.js'
 
25
  import { mountDiaryPanel } from '/web/diaryPanel.js'
26
  import { mountSettingsPanel } from '/web/settingsPanel.js'
27
  import { mountSkillForgePanel } from '/web/skillForgePanel.js'
@@ -369,10 +370,27 @@ whenEl('battle-stage', async (el) => {
369
  // Re-read from the store so we carry live progression (level/xp/skills), not a stale picker copy.
370
  currentHero = (p && p.id && getPersona(p.id)) || p
371
  setActiveHeroId(currentHero?.id || null)
 
 
372
  const s = comboCtrl.getSpawnWorld()
373
  comboCtrl.selectHero(buildPlayer(chars, currentHero))
374
  comboCtrl.map.flyTo(s.x, s.y, GAMEPLAY_ZOOM, 1000).catch(() => {})
375
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
376
 
377
  // ── Leveling: a foe defeat grants XP to the active hero, persisted to the roster. ──
378
  const awardKill = (enemy) => {
@@ -682,6 +700,6 @@ whenEl('battle-stage', async (el) => {
682
  el.append(sheetBtn, sheet)
683
 
684
  showPicker()
685
- comboCtrl.onChange((s) => { if (s.over) showPicker(); if (sheetOpen) updateHp(comboCtrl.getHero?.()) })
686
  onRosterChange(refreshPicker)
687
  })
 
22
  import { generatePortrait } from '/web/imagen.js'
23
  import { mountCharacterChat } from '/web/characterChat.js'
24
  import { forgeSkillForHero } from '/web/skillForge.js'
25
+ import { mountAfterAction } from '/web/afterAction.js'
26
  import { mountDiaryPanel } from '/web/diaryPanel.js'
27
  import { mountSettingsPanel } from '/web/settingsPanel.js'
28
  import { mountSkillForgePanel } from '/web/skillForgePanel.js'
 
370
  // Re-read from the store so we carry live progression (level/xp/skills), not a stale picker copy.
371
  currentHero = (p && p.id && getPersona(p.id)) || p
372
  setActiveHeroId(currentHero?.id || null)
373
+ runStart = { level: currentHero?.progression?.level || 1 } // for the after-action "levels gained"
374
+ deathShown = false
375
  const s = comboCtrl.getSpawnWorld()
376
  comboCtrl.selectHero(buildPlayer(chars, currentHero))
377
  comboCtrl.map.flyTo(s.x, s.y, GAMEPLAY_ZOOM, 1000).catch(() => {})
378
  }
379
+ // Hero died → after-action report (run stats + an in-voice war-diary recap), then re-enter.
380
+ let runStart = null, deathShown = false
381
+ const onHeroDown = () => {
382
+ if (deathShown) return; deathShown = true
383
+ const th = comboCtrl.getThreat?.() || { kills: 0, time: 0, diff: 1 }
384
+ const fresh = (currentHero?.id && getPersona(currentHero.id)) || currentHero
385
+ const run = {
386
+ killsThisRun: th.kills, threat: +th.diff.toFixed(1), timeSurvived: Math.round(th.time),
387
+ levelStart: runStart?.level || fresh?.progression?.level || 1, levelEnd: fresh?.progression?.level || 1,
388
+ }
389
+ mountAfterAction(el, fresh, run, {
390
+ onAgain: () => { if (fresh) spawnWithFly(fresh) },
391
+ onChoose: () => showPicker(),
392
+ })
393
+ }
394
 
395
  // ── Leveling: a foe defeat grants XP to the active hero, persisted to the roster. ──
396
  const awardKill = (enemy) => {
 
700
  el.append(sheetBtn, sheet)
701
 
702
  showPicker()
703
+ comboCtrl.onChange((s) => { if (s.over) onHeroDown(); if (sheetOpen) updateHp(comboCtrl.getHero?.()) })
704
  onRosterChange(refreshPicker)
705
  })