File size: 8,049 Bytes
6ca2d09
 
 
 
 
8441cda
6ca2d09
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8441cda
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6ca2d09
 
 
 
 
8441cda
 
 
 
 
 
 
 
 
 
 
 
 
6ca2d09
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8441cda
 
6ca2d09
 
 
 
 
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
112
113
114
115
116
117
118
119
120
121
122
123
// 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 }
}