adventure: tell the story of a run, not just how it ended
Pete only ever heard that an expedition happened once it was over — a zone cleared, a retreat, a death. The run itself was narrated into one Matrix DM and thrown away. The map on the adventurer page has always shown where somebody is; this shows what happened there. Beats arrive on their own channel, append-only and idempotent on (run_id, seq). They are the one thing gogobee pushes that is history rather than state, so they accumulate instead of replacing — and they stay off the dispatch queue so a chatty run can never spend the retry budget a death dispatch depends on. The run header is derived from the beats rather than pushed: a run whose start beat never arrived still gets a readable, unattributed log instead of being dropped for want of a name. An unknown beat kind renders as its own noun rather than 400ing. That is the same lesson the dispatch ingest learned the hard way, and the regression test covers the class, not the case. Claude-Session: https://claude.ai/code/session_012bxpQQJDjC1mTtLN3VVtBQ
This commit is contained in:
@@ -167,6 +167,27 @@
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
<!-- The liveblog. The map above says where they are; this says what happened
|
||||
there. Sits outside the HasDetail gate on purpose: the log arrives on its
|
||||
own channel and can outlive the snapshot that drew the map — a run that
|
||||
just ended still has a story after the board has moved the mark back to
|
||||
town. Rebuilt in place by the same poll that moves the HP bar. -->
|
||||
<section id="who-runlog-section" class="mt-6 rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 p-6 shadow-pete{{if not .RunLog.Has}} hidden{{end}}">
|
||||
<div class="flex items-baseline justify-between mb-4 gap-3">
|
||||
<h2 class="font-display text-xl font-bold">The run</h2>
|
||||
<span id="who-runlog-status" class="text-sm text-[color:var(--ink)]/50">{{if .RunLog.Live}}under way{{else if .RunLog.Outcome}}{{.RunLog.Outcome}}{{else}}finished{{end}}{{if .RunLog.Rooms}} · room {{.RunLog.Rooms}}{{end}}</span>
|
||||
</div>
|
||||
<ol id="who-runlog" class="runlog">
|
||||
{{range .RunLog.Lines}}
|
||||
<li class="runlog-line{{if .Hurt}} runlog-hurt{{end}}{{if .Good}} runlog-good{{end}}">
|
||||
<span class="runlog-emoji" aria-hidden="true">{{.Emoji}}</span>
|
||||
<span class="runlog-text">{{.Text}}</span>
|
||||
<span class="runlog-meta">{{if .Room}}{{.Room}} · {{end}}{{.When}}</span>
|
||||
</li>
|
||||
{{end}}
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
{{if .HasHistory}}
|
||||
<!-- The record. Public, like the dispatches it's counted from — this is the
|
||||
same information the /adventure feed already printed, only as numbers
|
||||
@@ -421,6 +442,53 @@
|
||||
|
||||
function txt(id, v) { var el = document.getElementById(id); if (el && v != null) el.textContent = v; }
|
||||
|
||||
// The liveblog is rebuilt from the JSON rather than patched: beats only ever
|
||||
// arrive at the end, but a run can also END between polls, which changes the
|
||||
// header and can drop the section entirely. Redrawing forty short lines is
|
||||
// cheaper than getting the incremental case wrong.
|
||||
//
|
||||
// Built with textContent throughout — a beat carries a monster name that came
|
||||
// off the wire, and innerHTML here would make the game box able to inject
|
||||
// markup into a public page.
|
||||
function drawRunLog(log) {
|
||||
var section = document.getElementById('who-runlog-section');
|
||||
var list = document.getElementById('who-runlog');
|
||||
if (!section || !list) return;
|
||||
if (!log || !log.Has || !log.Lines || !log.Lines.length) {
|
||||
section.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
// Only scroll to the newest beat when the reader was already at the bottom.
|
||||
// Yanking them back down while they're reading four rooms ago is worse than
|
||||
// making them scroll.
|
||||
var pinned = list.scrollHeight - list.scrollTop - list.clientHeight < 24;
|
||||
|
||||
section.classList.remove('hidden');
|
||||
txt('who-runlog-status',
|
||||
(log.Live ? 'under way' : (log.Outcome || 'finished')) +
|
||||
(log.Rooms ? ' · room ' + log.Rooms : ''));
|
||||
|
||||
var frag = document.createDocumentFragment();
|
||||
log.Lines.forEach(function (ln) {
|
||||
var li = document.createElement('li');
|
||||
li.className = 'runlog-line' + (ln.Hurt ? ' runlog-hurt' : '') + (ln.Good ? ' runlog-good' : '');
|
||||
var e = document.createElement('span');
|
||||
e.className = 'runlog-emoji'; e.setAttribute('aria-hidden', 'true'); e.textContent = ln.Emoji || '';
|
||||
var t = document.createElement('span');
|
||||
t.className = 'runlog-text'; t.textContent = ln.Text || '';
|
||||
var m = document.createElement('span');
|
||||
m.className = 'runlog-meta'; m.textContent = (ln.Room ? ln.Room + ' · ' : '') + (ln.When || '');
|
||||
li.appendChild(e); li.appendChild(t); li.appendChild(m);
|
||||
frag.appendChild(li);
|
||||
});
|
||||
list.replaceChildren(frag);
|
||||
if (pinned) list.scrollTop = list.scrollHeight;
|
||||
}
|
||||
(function () {
|
||||
var list = document.getElementById('who-runlog');
|
||||
if (list) list.scrollTop = list.scrollHeight;
|
||||
})();
|
||||
|
||||
var timer = null;
|
||||
function refresh() {
|
||||
fetch('/api/adventure/who/' + encodeURIComponent(token), { headers: { 'Accept': 'application/json' } })
|
||||
@@ -430,7 +498,10 @@
|
||||
if (!data.live) {
|
||||
// Off the board entirely (expedition ended, opted out): nothing more to
|
||||
// poll for, so stop the timer rather than hammer a token that's gone.
|
||||
// The log goes with them: off the board is off the page, and the API
|
||||
// sends none in this branch.
|
||||
txt('who-where', 'Back in town');
|
||||
drawRunLog(null);
|
||||
if (timer) clearInterval(timer);
|
||||
return;
|
||||
}
|
||||
@@ -452,6 +523,7 @@
|
||||
} else {
|
||||
txt('who-where', 'In town' + (mark.Idle ? ' · ' + mark.Idle : ''));
|
||||
}
|
||||
drawRunLog(data.run_log);
|
||||
})
|
||||
.catch(function () { /* transient — next tick will do */ });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user