Spaces:
Running
Running
Fun+LLM: death after-action report with in-voice war-diary recap
Browse filesWhen 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>
- web/afterAction.js +90 -0
- web/characterContext.js +1 -0
- 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)
|
| 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 |
})
|