Spaces:
Running
Running
| // Death after-action — when a hero falls, show what the run achieved AND have the fighter | |
| // recount the battle in its own voice (load-bearing LLM). The recap is banked to the hero's | |
| // event log so it remembers this fight (feeds chat + future "living character" features). | |
| import { streamChat, ensureModel, currentModelId } from '/web/runtime.js' | |
| import { noThink, stripThinkFinal } from '/web/personaPrompts.js' | |
| import { getPersona, patchPersona, addChronicle, listChronicle } from '/web/personaStore.js' | |
| import { appendEvent } from '/web/progression.js' | |
| function recapPrompts(p, run, recentEvents) { | |
| const system = [ | |
| `You are ${p?.name || 'a fighter'}${p?.unitClass ? `, a ${p.unitClass}` : ''}, in a fantasy auto-battler.`, | |
| 'You just fought a desperate battle and fell in the field. Recount it in FIRST PERSON, past tense,', | |
| 'in 1-2 vivid sentences — proud, rueful or defiant as fits you. No preamble, no markdown.', | |
| ].join(' ') | |
| const lines = [ | |
| `This battle: you cut down ${run.killsThisRun} foe${run.killsThisRun === 1 ? '' : 's'}`, | |
| run.levelEnd > run.levelStart ? `, rose from level ${run.levelStart} to ${run.levelEnd}` : `, at level ${run.levelEnd}`, | |
| `, the threat climbed to ${run.threat}×`, | |
| `, and you held for ${run.timeSurvived}s before falling.`, | |
| ].join('') | |
| return { system, user: lines + (recentEvents ? `\nAlong the way: ${recentEvents}.` : '') } | |
| } | |
| export function mountAfterAction(host, persona, run, { onAgain, onChoose } = {}) { | |
| const back = document.createElement('div') | |
| 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' | |
| const card = document.createElement('div') | |
| 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' | |
| const title = document.createElement('div'); title.textContent = `${persona?.name || 'Your hero'} has fallen` | |
| title.style.cssText = 'font:800 22px var(--tac-font,system-ui);color:#ff8a8a;margin-bottom:4px' | |
| const sub = document.createElement('div'); sub.textContent = 'After-action report' | |
| sub.style.cssText = 'font-size:11px;letter-spacing:.18em;text-transform:uppercase;color:#9aa4b2;margin-bottom:16px' | |
| card.append(title, sub) | |
| // Stat grid | |
| const grid = document.createElement('div'); grid.style.cssText = 'display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-bottom:16px' | |
| const stat = (label, value, gold) => { | |
| const c = document.createElement('div'); c.style.cssText = 'background:rgba(20,24,33,.6);border:1px solid #232b36;border-radius:10px;padding:10px' | |
| const v = document.createElement('div'); v.textContent = value; v.style.cssText = `font:800 20px var(--tac-font,system-ui);color:${gold ? '#ffe082' : '#e8e8e8'}` | |
| 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' | |
| c.append(v, l); return c | |
| } | |
| grid.append( | |
| stat('Foes felled', String(run.killsThisRun), true), | |
| stat('Threat reached', run.threat + '×'), | |
| stat('Levels gained', '+' + Math.max(0, run.levelEnd - run.levelStart)), | |
| stat('Held for', run.timeSurvived + 's'), | |
| ) | |
| card.append(grid) | |
| // Personal best: did this run beat the hero's record? Update + celebrate. | |
| const prevBest = persona?.progression?.best || { kills: 0, threat: 0 } | |
| const newRecord = run.killsThisRun > (prevBest.kills || 0) | |
| if (persona?.id) { | |
| const fresh0 = getPersona(persona.id) | |
| if (fresh0) patchPersona(persona.id, { progression: { ...fresh0.progression, best: { kills: Math.max(prevBest.kills || 0, run.killsThisRun), threat: Math.max(prevBest.threat || 0, run.threat), level: Math.max(prevBest.level || 0, run.levelEnd) } } }) | |
| } | |
| if (newRecord) { | |
| const rec = document.createElement('div'); rec.textContent = `★ New record — ${run.killsThisRun} foes!` | |
| rec.style.cssText = 'color:#ffe082;font:700 13px var(--tac-font,system-ui);margin:-6px 0 14px' | |
| card.append(rec) | |
| } else if (prevBest.kills) { | |
| const rec = document.createElement('div'); rec.textContent = `Best: ${prevBest.kills} foes` | |
| rec.style.cssText = 'color:#9aa4b2;font-size:12px;margin:-6px 0 14px' | |
| card.append(rec) | |
| } | |
| // War-diary recap (streamed, in-voice) | |
| 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' | |
| 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)' | |
| recapWrap.append(recap); card.append(recapWrap) | |
| // The Chronicle: other heroes' recent legends, so the world feels populated by past deeds. | |
| const legends = listChronicle().slice(0, 3) | |
| if (legends.length) { | |
| const lh = document.createElement('div'); lh.textContent = 'The Chronicle remembers'; lh.style.cssText = 'font-size:10px;letter-spacing:.16em;text-transform:uppercase;color:#9aa4b2;text-align:left;margin-bottom:6px' | |
| card.append(lh) | |
| legends.forEach((e) => { | |
| const row = document.createElement('div'); row.style.cssText = 'text-align:left;font-size:12px;color:#a9b2bf;line-height:1.4;margin-bottom:6px;border-left:2px solid #2a3340;padding-left:8px' | |
| row.textContent = `${e.name} — felled ${e.kills}: “${(e.recap || '').slice(0, 90)}${(e.recap || '').length > 90 ? '…' : ''}”` | |
| card.append(row) | |
| }) | |
| const sp = document.createElement('div'); sp.style.cssText = 'height:8px'; card.append(sp) | |
| } | |
| const btns = document.createElement('div'); btns.style.cssText = 'display:flex;gap:10px' | |
| const again = document.createElement('button'); again.type = 'button'; again.textContent = '⚔ Fight again' | |
| 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' | |
| const choose = document.createElement('button'); choose.type = 'button'; choose.textContent = 'Choose hero' | |
| 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' | |
| const close = () => back.remove() | |
| again.addEventListener('click', () => { close(); onAgain && onAgain() }) | |
| choose.addEventListener('click', () => { close(); onChoose && onChoose() }) | |
| btns.append(again, choose); card.append(btns) | |
| back.append(card); host.append(back) | |
| // Stream the in-voice recap, then bank it to the hero's memory. | |
| ;(async () => { | |
| try { | |
| 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(', ') | |
| const { system, user } = recapPrompts(persona, run, recent) | |
| await ensureModel(() => {}) | |
| let raw = '' | |
| await streamChat(system, user + noThink(currentModelId()), { | |
| maxTokens: 110, temperature: 0.9, | |
| onToken: (t) => { raw += t; recap.textContent = stripThinkFinal(raw) || '…' }, | |
| }) | |
| const text = stripThinkFinal(raw).trim() || '…' | |
| recap.textContent = text | |
| if (persona?.id) { | |
| const fresh = getPersona(persona.id) | |
| if (fresh) patchPersona(persona.id, { events: appendEvent(fresh.events, 'battle', { kills: run.killsThisRun, threat: run.threat, recap: text, ts: Date.now() }) }) | |
| } | |
| // Post this deed to the shared Chronicle (the living world's history). | |
| addChronicle({ name: persona?.name || 'A fighter', unitClass: persona?.unitClass || '', kills: run.killsThisRun, threat: run.threat, level: run.levelEnd, recap: text, ts: Date.now() }) | |
| } catch (e) { recap.textContent = '(the memory is a blur of steel and blood)' } | |
| })() | |
| return { close } | |
| } | |