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

Fun+living-world: personal-best records + shared Chronicle of legends

Browse files

After-action now tracks each hero's best run (foes felled / threat / level) — a record
to beat ('★ New record!'), shown on the character sheet. Fallen heroes' recaps post to a
shared Chronicle (capped world feed); the after-action shows other heroes' recent legends
so the world feels populated by past deeds. Verified: chronicle accumulates across heroes,
bests persist per hero.

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

Files changed (3) hide show
  1. web/afterAction.js +33 -1
  2. web/personaStore.js +7 -0
  3. web/tiny.js +1 -0
web/afterAction.js CHANGED
@@ -3,7 +3,7 @@
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) {
@@ -49,11 +49,41 @@ export function mountAfterAction(host, persona, run, { onAgain, onChoose } = {})
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'
@@ -83,6 +113,8 @@ export function mountAfterAction(host, persona, run, { onAgain, onChoose } = {})
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
 
 
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, addChronicle, listChronicle } from '/web/personaStore.js'
7
  import { appendEvent } from '/web/progression.js'
8
 
9
  function recapPrompts(p, run, recentEvents) {
 
49
  )
50
  card.append(grid)
51
 
52
+ // Personal best: did this run beat the hero's record? Update + celebrate.
53
+ const prevBest = persona?.progression?.best || { kills: 0, threat: 0 }
54
+ const newRecord = run.killsThisRun > (prevBest.kills || 0)
55
+ if (persona?.id) {
56
+ const fresh0 = getPersona(persona.id)
57
+ 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) } } })
58
+ }
59
+ if (newRecord) {
60
+ const rec = document.createElement('div'); rec.textContent = `★ New record — ${run.killsThisRun} foes!`
61
+ rec.style.cssText = 'color:#ffe082;font:700 13px var(--tac-font,system-ui);margin:-6px 0 14px'
62
+ card.append(rec)
63
+ } else if (prevBest.kills) {
64
+ const rec = document.createElement('div'); rec.textContent = `Best: ${prevBest.kills} foes`
65
+ rec.style.cssText = 'color:#9aa4b2;font-size:12px;margin:-6px 0 14px'
66
+ card.append(rec)
67
+ }
68
+
69
  // War-diary recap (streamed, in-voice)
70
  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'
71
  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)'
72
  recapWrap.append(recap); card.append(recapWrap)
73
 
74
+ // The Chronicle: other heroes' recent legends, so the world feels populated by past deeds.
75
+ const legends = listChronicle().slice(0, 3)
76
+ if (legends.length) {
77
+ 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'
78
+ card.append(lh)
79
+ legends.forEach((e) => {
80
+ 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'
81
+ row.textContent = `${e.name} — felled ${e.kills}: “${(e.recap || '').slice(0, 90)}${(e.recap || '').length > 90 ? '…' : ''}”`
82
+ card.append(row)
83
+ })
84
+ const sp = document.createElement('div'); sp.style.cssText = 'height:8px'; card.append(sp)
85
+ }
86
+
87
  const btns = document.createElement('div'); btns.style.cssText = 'display:flex;gap:10px'
88
  const again = document.createElement('button'); again.type = 'button'; again.textContent = '⚔ Fight again'
89
  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'
 
113
  const fresh = getPersona(persona.id)
114
  if (fresh) patchPersona(persona.id, { events: appendEvent(fresh.events, 'battle', { kills: run.killsThisRun, threat: run.threat, recap: text, ts: Date.now() }) })
115
  }
116
+ // Post this deed to the shared Chronicle (the living world's history).
117
+ addChronicle({ name: persona?.name || 'A fighter', unitClass: persona?.unitClass || '', kills: run.killsThisRun, threat: run.threat, level: run.levelEnd, recap: text, ts: Date.now() })
118
  } catch (e) { recap.textContent = '(the memory is a blur of steel and blood)' }
119
  })()
120
 
web/personaStore.js CHANGED
@@ -10,6 +10,7 @@
10
  // working with no explicit migration step. See docs/rpg-progression-plan.md.
11
  const KEY = 'tinyarmy.roster.v1'
12
  const AKEY = 'tinyarmy.activeHeroId'
 
13
 
14
  const listeners = new Set()
15
  const activeListeners = new Set()
@@ -125,6 +126,12 @@ export function setActiveHeroId(id) {
125
  export function getActiveHero() { const id = getActiveHeroId(); return id ? getPersona(id) : null }
126
  export function onActiveHeroChange(fn) { activeListeners.add(fn); return () => activeListeners.delete(fn) }
127
 
 
 
 
 
 
 
128
  // ── Media store (IndexedDB — WAV + PNG blobs are too big for localStorage) ──────
129
  // v3 adds skill icons, skill action-shot art, and per-message chat audio alongside
130
  // the original voices + portraits stores.
 
10
  // working with no explicit migration step. See docs/rpg-progression-plan.md.
11
  const KEY = 'tinyarmy.roster.v1'
12
  const AKEY = 'tinyarmy.activeHeroId'
13
+ const CHRON = 'tinyarmy.chronicle.v1' // a shared, capped feed of notable deeds across all heroes
14
 
15
  const listeners = new Set()
16
  const activeListeners = new Set()
 
126
  export function getActiveHero() { const id = getActiveHeroId(); return id ? getPersona(id) : null }
127
  export function onActiveHeroChange(fn) { activeListeners.add(fn); return () => activeListeners.delete(fn) }
128
 
129
+ // ── Chronicle — a shared world feed of notable deeds (fallen heroes' legends), newest first ──
130
+ export function addChronicle(entry) {
131
+ try { const l = JSON.parse(localStorage.getItem(CHRON) || '[]'); l.unshift({ ...entry }); localStorage.setItem(CHRON, JSON.stringify(l.slice(0, 50))) } catch { /* ignore */ }
132
+ }
133
+ export function listChronicle() { try { return JSON.parse(localStorage.getItem(CHRON) || '[]') } catch { return [] } }
134
+
135
  // ── Media store (IndexedDB — WAV + PNG blobs are too big for localStorage) ──────
136
  // v3 adds skill icons, skill action-shot art, and per-message chat audio alongside
137
  // the original voices + portraits stores.
web/tiny.js CHANGED
@@ -625,6 +625,7 @@ whenEl('battle-stage', async (el) => {
625
  meta.append(kills)
626
  if (prog.attributePoints > 0) { const pts = document.createElement('button'); pts.type = 'button'; pts.textContent = `★ ${prog.attributePoints} point${prog.attributePoints > 1 ? 's' : ''} — spend`; pts.style.cssText = 'background:none;border:none;color:#ffe082;font:inherit;cursor:pointer;text-decoration:underline'; pts.addEventListener('click', openSpend); meta.append(pts) }
627
  sheet.append(meta)
 
628
  }
629
  // Talk to this fighter (chat + voice). Needs a saved hero (id) to persist the conversation.
630
  if (currentHero.id) {
 
625
  meta.append(kills)
626
  if (prog.attributePoints > 0) { const pts = document.createElement('button'); pts.type = 'button'; pts.textContent = `★ ${prog.attributePoints} point${prog.attributePoints > 1 ? 's' : ''} — spend`; pts.style.cssText = 'background:none;border:none;color:#ffe082;font:inherit;cursor:pointer;text-decoration:underline'; pts.addEventListener('click', openSpend); meta.append(pts) }
627
  sheet.append(meta)
628
+ if (prog.best && prog.best.kills) { const bst = document.createElement('div'); bst.textContent = `🏆 Best run: ${prog.best.kills} foes · threat ${prog.best.threat || 1}×`; bst.style.cssText = 'font-size:11px;color:#c9a227;margin:-4px 0 10px'; sheet.append(bst) }
629
  }
630
  // Talk to this fighter (chat + voice). Needs a saved hero (id) to persist the conversation.
631
  if (currentHero.id) {