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
+410
View File
@@ -0,0 +1,410 @@
package web
import (
"fmt"
"log/slog"
"net/http"
"net/url"
"strings"
"time"
"pete/internal/storage"
)
// The run report — the permalink an expedition leaves behind.
//
// The liveblog on the adventurer page answers "what is happening"; it is capped,
// it scrolls, and six hours after the run ends it is gone, because that page is
// about now. This answers the other question, the one asked afterwards and often
// by somebody who wasn't watching: what *was* that run. So it is the whole log,
// uncapped, with the numbers rolled up and the moment it turned pulled out of
// the middle — and it is stable for a fortnight, which is what makes it a thing
// worth putting in a dispatch and a thing worth sending to somebody.
//
// It is deliberately assembled from the same beats the liveblog renders, through
// the same renderRunBeat. 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 one thing here that Pete did not write is the summary: gogobee's LLM reads
// the finished run back and says what it was about. That is a judgement, not a
// fact, so it is the only prose on the channel and it passes the same guard a
// dispatch lede does before it reaches this page.
// runReportCap bounds the log on the report. Far above the liveblog's 60 — the
// point of this page is that nothing is missing — but not unbounded: a stuck
// multi-day expedition can beat out thousands of rows, and a page nobody can
// scroll is its own kind of missing.
const runReportCap = 500
// runStat is one rolled-up number with its label. Assembled rather than
// hardcoded in the template so a run with nothing to say about traps doesn't
// render a proud zero.
type runStat struct {
Value string
Label string
}
// RunReportView is the report as the page draws it.
type RunReportView struct {
pageData
RunID string
Name string
WhoURL string // link back to the adventurer page; "" when they're off the board
Level int
Zone string
Live bool
Outcome string // the raw word, for the chip class
Verdict string // the human sentence for it
Emoji string
Summary string
When string
Elapsed string
Rooms string
Stats []runStat
// Turning is the single beat that decided the run — the biggest thing that
// happened to the party's health in one go. Nil on a run where nothing much
// did, which is a real outcome and not worth inventing drama for.
Turning *runLogLine
Lines []runLogLine
Truncated bool
Permalink string
}
// runReportPath is the report's URL. The run id is generated by gogobee as
// 16 hex characters, but it is still escaped: it arrives over a wire, and a link
// that routes somewhere else because an id grew a slash is a bug you find in
// production.
func runReportPath(runID string) string {
return "/adventure/run/" + url.PathEscape(runID)
}
// handleRunReport serves one expedition's report.
//
// The visibility rule is the adventurer page's, exactly: a run whose token is not
// on the current board 404s. Finishing a run does not take anyone off the board —
// they stay on it as idle — so this only ever fires for a player who opted out or
// was removed, which is precisely the case where a room-by-room account of where
// they went must stop being reachable. An unattributed run (its `start` beat never
// arrived, so there is no token at all) 404s for the same reason: Pete cannot
// establish whose run it is, and "don't know" is not a basis for publishing one.
func (s *Server) handleRunReport(w http.ResponseWriter, r *http.Request) {
if !s.adv.Enabled {
http.NotFound(w, r)
return
}
runID := r.PathValue("run_id")
run, ok, err := storage.RunByID(runID)
if err != nil {
slog.Error("run report: header lookup failed", "run", runID, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if !ok || run.Token == "" {
http.NotFound(w, r)
return
}
entry, onBoard, err := storage.RosterEntryByToken(run.Token)
if err != nil {
slog.Error("run report: roster lookup failed", "run", runID, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if !onBoard {
http.NotFound(w, r)
return
}
beats, err := storage.RunBeats(run.RunID, runReportCap)
if err != nil {
slog.Error("run report: beats lookup failed", "run", runID, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if len(beats) == 0 {
// A header with no beats is a run that was pruned out from under its own
// dispatch, or one whose beats never landed. Either way there is no report.
http.NotFound(w, r)
return
}
s.track(r, "adventure")
view := buildRunReport(run, beats)
base := s.base(r)
base.Active = "adventure"
base.NoIndex = true // names a player character, like every other adventure page
view.pageData = base
// The name on the header is the roster's, not the beat's: the `start` beat
// froze a name at the moment the party set out, and the board is the current
// truth about what to call somebody.
if entry.Name != "" {
view.Name = entry.Name
}
view.WhoURL = "/adventure/who/" + url.PathEscape(run.Token)
view.Permalink = s.siteURL(runReportPath(run.RunID))
s.render(w, "run_report", view)
}
// buildRunReport turns a run and its beats into the page. Pure — no storage, no
// server — so the whole render is testable against a slice of beats, which is
// the only honest way to check that a run reads correctly.
func buildRunReport(run storage.Run, beats []storage.RunBeat) RunReportView {
v := RunReportView{
RunID: run.RunID,
Name: run.Name,
Level: run.Level,
Zone: run.Zone,
Live: run.Live(),
Outcome: run.Outcome,
Summary: run.Summary,
}
if v.Name == "" {
v.Name = "An adventurer"
}
if v.Zone == "" {
v.Zone = "the dungeon"
}
v.Verdict, v.Emoji = runVerdict(run)
when := run.EndedAt
if when == 0 {
when = run.StartedAt
}
if when > 0 {
v.When = time.Unix(when, 0).UTC().Format("Jan 2, 2006 · 15:04")
}
v.Elapsed = runElapsed(run, beats)
// How far they got — and only when that is a fact worth stating. A dungeon
// graph forks, so a run that cleared it never walks every room, and a header
// reading "room 7 / 9" over the word "Cleared it" says they fell two short of
// something. On a run that ended badly the same number is the whole story.
if run.Outcome != "cleared" {
deepest := 0
for _, b := range beats {
if b.Room > deepest {
deepest = b.Room
}
}
switch {
case deepest > 0 && run.TotalRooms > 0:
v.Rooms = fmt.Sprintf("got as far as room %d of %d", deepest, run.TotalRooms)
case deepest > 0:
v.Rooms = fmt.Sprintf("got as far as room %d", deepest)
}
}
var turningAt int64 = -1
for _, b := range beats {
// The summary is prose about the run, not a moment in it. It has its own
// place on the page and would read as a stray paragraph in the middle of a
// log if it were allowed to render as a line.
if b.Kind == "summary" {
continue
}
if b.Zone == "" {
b.Zone = run.Zone
}
line, ok := renderRunBeat(b)
if !ok {
continue
}
v.Lines = append(v.Lines, line)
// The turning point is the single largest hit the party took in one go.
// Ties go to the earlier beat: the moment a run turned is the first time
// it did, not the last time it did it again.
if hurt := beatHurt(b); hurt > 0 && int64(hurt) > turningAt {
turningAt = int64(hurt)
pick := line
v.Turning = &pick
}
}
v.Truncated = len(beats) >= runReportCap
v.Stats = runStats(beats)
return v
}
// beatHurt is how much health one beat cost, and it is the only thing the
// turning point is chosen on. Damage the party absorbed is the currency of a
// dungeon crawl: a fight won without a scratch is not the moment anything
// turned, however big the monster was.
func beatHurt(b storage.RunBeat) int {
switch b.Kind {
case "combat", "trap":
return b.Amount
}
return 0
}
// runVerdict is the human reading of an outcome, plus the emoji the header wears.
func runVerdict(run storage.Run) (verdict, emoji string) {
if run.Live() {
return "Still under way", "🚶"
}
switch run.Outcome {
case "cleared":
return "Cleared it", "🏆"
case "died":
return "Didn't come home", "💀"
case "retreated":
return "Walked out wounded", "🚑"
case "abandoned":
// The generic funnel's word. It covers a region crossing and an idle reap
// alike, and neither of those is a failure — saying "abandoned" at somebody
// would be Pete editorialising with the least informative word available.
return "Ended", "🚪"
}
return "Ended", "🚪"
}
// runElapsed is how long the party was down there, phrased the way somebody
// would say it. Preference order matters: the header clock is authoritative when
// it has both ends, and the beats are the fallback for a run whose `start` never
// arrived (which is exactly the run whose started_at is a later beat's clock).
func runElapsed(run storage.Run, beats []storage.RunBeat) string {
from, to := run.StartedAt, run.EndedAt
if from == 0 && len(beats) > 0 {
from = beats[0].OccurredAt
}
if to == 0 && len(beats) > 0 {
to = beats[len(beats)-1].OccurredAt
}
if from == 0 || to <= from {
return ""
}
d := time.Duration(to-from) * time.Second
switch {
case d < time.Minute:
return "under a minute"
case d < time.Hour:
return fmt.Sprintf("%d min", int(d.Minutes()))
case d < 24*time.Hour:
h := int(d.Hours())
m := int(d.Minutes()) % 60
if m == 0 {
return plural(h, "hour", "hours")
}
return fmt.Sprintf("%dh %dm", h, m)
}
return plural(int(d.Hours()/24), "day", "days")
}
// runStats rolls the beats up into the tiles above the log.
//
// Only non-zero tiles are emitted. A run that sprung no traps should say nothing
// about traps rather than display a confident 0 — the tile row is a summary of
// what this run *was*, and padding it out with absences makes every run look the
// same, which is the exact failure the report exists to fix.
func runStats(beats []storage.RunBeat) []runStat {
var (
fights, wins, damage, crits int
treasures, traps, gathered int
)
for _, b := range beats {
switch b.Kind {
case "combat":
fights++
if b.Outcome == "won" {
wins++
}
damage += b.Amount
crits += b.Crits
case "trap":
if b.Amount > 0 {
traps++
damage += b.Amount
}
case "treasure":
treasures++
case "haul":
gathered += b.Amount
}
}
var out []runStat
add := func(n int, label, plural string) {
if n <= 0 {
return
}
if n != 1 && plural != "" {
label = plural
}
out = append(out, runStat{Value: fmt.Sprintf("%d", n), Label: label})
}
if fights > 0 {
// Wins over fights rather than two tiles: on a run that ended badly the
// interesting number is the gap between them, and two separate tiles make a
// reader do the subtraction.
out = append(out, runStat{
Value: fmt.Sprintf("%d/%d", wins, fights),
Label: "fights won",
})
}
add(damage, "damage taken", "")
add(treasures, "treasure found", "treasures found")
add(traps, "trap sprung", "traps sprung")
add(crits, "critical hit", "critical hits")
add(gathered, "supplies gathered", "")
return out
}
// runReportLinkFor is the "read the run" link for a dispatch, or "" when there
// isn't one to offer.
//
// Three ways to have no link, all of them normal: the fact predates the run
// report (or isn't the end of an expedition) and carries no run id; the run has
// been swept by the fortnight retention; or its owner has since left the board.
// The last one is why this re-checks visibility rather than trusting the stored
// id — an opt-out has to close the door on links that were minted before it.
func runReportLinkFor(ev *storage.AdvEvent) string {
if ev == nil || ev.RunID == "" {
return ""
}
run, ok, err := storage.RunByID(ev.RunID)
if err != nil {
slog.Error("run report link: header lookup failed", "run", ev.RunID, "err", err)
return ""
}
if !ok || run.Token == "" {
return ""
}
if _, onBoard, err := storage.RosterEntryByToken(run.Token); err != nil || !onBoard {
return ""
}
return runReportPath(run.RunID)
}
// maxRunSummary caps the LLM run summary. Longer than a dispatch lede on purpose
// — it is three sentences over a whole expedition rather than one over a single
// fact — and still short enough that a runaway generation is rejected rather
// than printed.
const maxRunSummary = 1200
// runSummaryGuard decides whether gogobee's run summary is safe to print. It is
// the liveblog's half of proseGuard and it exists for the identical reason: the
// text is LLM output over player-chosen names, so the only defence that means
// anything is checking the RENDERED words rather than the structured fields
// beside them.
//
// The allow-list is the run's own adventurer, which is the only person a run
// summary has any business naming. A summary that names a *different* character
// on the board is either a hallucination or somebody who found an injection path,
// and both are the same answer: drop the prose, keep the report. The report
// without a summary is the log and the numbers, which is most of it.
func runSummaryGuard(text, name string) bool {
if strings.TrimSpace(text) == "" || len(text) > maxRunSummary {
return false
}
allow := map[string]bool{}
if name != "" {
allow[strings.ToLower(name)] = true
}
lowered := strings.ToLower(text)
for known := range storage.KnownCharacterNames() {
if allow[known] {
continue
}
if containsWholeWord(lowered, known) {
return false
}
}
return true
}