adventure: give a finished run a report worth sharing

The liveblog answers "what is happening" — it is capped, it scrolls, and six
hours after a run ends it is gone, because the adventurer page is about now.
Nothing answered the question asked afterwards, usually by somebody who wasn't
watching: what WAS that run. So a dispatch announcing a clear or a death was a
paragraph about an outcome with no way back to what produced it.

The report is that way back. The whole log uncapped, the numbers rolled up, and
the single worst hit the party took pulled out of the middle where it otherwise
reads as one line among forty. It is stable for a fortnight, which is what makes
it a thing worth linking from a dispatch and worth sending to somebody.

It is assembled from the same beats through the same renderer as the liveblog.
A report that told a different story from the log it was built out of would be
the more convincing of the two and the less true.

The summary is the exception and the only prose on the channel: gogobee's model
reads the finished run back and says what it was about, which is a judgement no
template makes. It rides a summary beat rather than its own endpoint, so it
inherits the whole channel — idempotent, retried, impossible to attach to a run
that doesn't exist — and it passes the same class of guard a dispatch lede does
before it reaches a public page.

Visibility is the adventurer page's rule exactly, and that matters more here
than anywhere: the report outlives the log by a fortnight and is linked from a
public dispatch, so it is the surface most likely to still be reachable after
somebody opts out. Coming off the board closes it, including through links
minted days earlier.

Claude-Session: https://claude.ai/code/session_012bxpQQJDjC1mTtLN3VVtBQ
This commit is contained in:
prosolis
2026-07-24 17:11:04 -07:00
parent 8c3f2b0d07
commit b4a276da36
14 changed files with 1052 additions and 49 deletions
+31 -8
View File
@@ -38,6 +38,12 @@ type AdvFact struct {
Milestone string `json:"milestone"`
OccurredAt int64 `json:"occurred_at"`
NoPush bool `json:"no_push"`
// RunID names the expedition this dispatch is the ending of, on the three
// event types that are one (a clear, a retreat, a death). It is what lets the
// permalink offer the run's own report — the log, the numbers, the moment it
// turned — instead of leaving a paragraph about an outcome with no way back to
// what produced it. Empty on every other fact.
RunID string `json:"run_id,omitempty"`
// Headline/Lede are gogobee's LLM-authored prose, both optional. When present
// and past the prose-guard they replace the template render; otherwise Pete
// falls back to renderAdventure. gogobee is compute here, Pete is the editor:
@@ -196,6 +202,7 @@ func (s *Server) handleAdventureIngest(w http.ResponseWriter, r *http.Request) {
Milestone: f.Milestone,
Stakes: f.Stakes,
Actors: f.Actors,
RunID: f.RunID,
OccurredAt: occurredAt,
}); err != nil {
slog.Error("adventure ingest: event record failed", "guid", f.GUID, "err", err)
@@ -367,6 +374,10 @@ type advStoryPage struct {
Region string
When string
Permalink string
// RunReportURL is the link to the expedition behind this dispatch, when the
// dispatch is the end of one and the run is still reachable. Empty is the
// common case and renders nothing.
RunReportURL string
}
// handleAdventureStory serves the server-rendered permalink for one dispatch
@@ -397,6 +408,17 @@ func (s *Server) handleAdventureStory(w http.ResponseWriter, r *http.Request) {
body = st.Lede // template-only dispatches carry the write-up in the lede
}
// The way back to what actually happened. Best-effort and usually absent: only
// the three end-of-expedition types carry a run id at all, and the run behind
// one is swept after a fortnight. A dispatch without it reads exactly as it
// did before the report existed.
runReport := ""
if ev, err := storage.AdventureEventByGUID(guid); err != nil {
slog.Error("adventure story: fact lookup failed", "guid", guid, "err", err)
} else {
runReport = runReportLinkFor(ev)
}
base := s.base(r)
base.Active = "adventure"
base.NoIndex = true // player-named page; keep out of search indexes (gap #5)
@@ -404,14 +426,15 @@ func (s *Server) handleAdventureStory(w http.ResponseWriter, r *http.Request) {
base.OGImage = abs + advArtURL(guid) // the dispatch's own card, for link unfurls
}
s.render(w, "story", advStoryPage{
pageData: base,
EventLabel: label,
Emoji: emoji,
Headline: st.Headline,
Body: body,
Region: "", // reserved: region isn't stored on the row yet
When: time.Unix(st.SeenAt, 0).UTC().Format("Jan 2, 2006"),
Permalink: s.advPermalink(guid),
pageData: base,
EventLabel: label,
Emoji: emoji,
Headline: st.Headline,
Body: body,
Region: "", // reserved: region isn't stored on the row yet
When: time.Unix(st.SeenAt, 0).UTC().Format("Jan 2, 2006"),
Permalink: s.advPermalink(guid),
RunReportURL: runReport,
})
}