Spaces:
Running
Running
Fun+living-world: personal-best records + shared Chronicle of legends
Browse filesAfter-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>
- web/afterAction.js +33 -1
- web/personaStore.js +7 -0
- 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) {
|