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:
@@ -74,6 +74,11 @@ type RunLogView struct {
|
||||
Outcome string // "" while live
|
||||
Lines []runLogLine
|
||||
Rooms string // "4 / 9"
|
||||
// ReportURL points at the run's permalink, and only once the run is over.
|
||||
// While it is still walking the log on this page IS the report, and offering a
|
||||
// link to a second copy of what somebody is already reading is just a way to
|
||||
// lose them.
|
||||
ReportURL string
|
||||
}
|
||||
|
||||
// handleRunIngest stores a batch of beats.
|
||||
@@ -117,6 +122,19 @@ func (s *Server) handleRunIngest(w http.ResponseWriter, r *http.Request) {
|
||||
if b.OccurredAt <= 0 {
|
||||
b.OccurredAt = now
|
||||
}
|
||||
// Prose only rides the one kind that has any, and only after it clears the
|
||||
// guard. A rejection drops the words and keeps the beat: the row is what
|
||||
// stops gogobee re-authoring the same summary every tick forever, and a
|
||||
// report with no summary is still a report.
|
||||
if b.Kind == "summary" {
|
||||
if !runSummaryGuard(b.Prose, runSummaryName(b)) {
|
||||
slog.Warn("run ingest: prose-guard rejected run summary",
|
||||
"run", b.RunID, "seq", b.Seq, "len", len(b.Prose))
|
||||
b.Prose = ""
|
||||
}
|
||||
} else {
|
||||
b.Prose = ""
|
||||
}
|
||||
kept = append(kept, b)
|
||||
}
|
||||
if len(kept) == 0 {
|
||||
@@ -133,6 +151,24 @@ func (s *Server) handleRunIngest(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// runSummaryName is the one character name a run summary is allowed to use.
|
||||
//
|
||||
// The beat carries it (gogobee knows who it is writing about), but a summary
|
||||
// arrives a tick or two after the run ended and could be the first beat of that
|
||||
// run Pete ever sees if an earlier batch was lost — so the stored header is the
|
||||
// fallback. With neither, the guard runs with an empty allow-list, which rejects
|
||||
// any summary naming anyone on the board. That is the right way to fail: a
|
||||
// nameless summary about a nameless run is not worth the exposure.
|
||||
func runSummaryName(b storage.RunBeat) string {
|
||||
if b.Name != "" {
|
||||
return b.Name
|
||||
}
|
||||
if run, ok, err := storage.RunByID(b.RunID); err == nil && ok {
|
||||
return run.Name
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// runLogFor builds the liveblog for one adventurer, or an empty view when there
|
||||
// is nothing worth showing.
|
||||
func runLogFor(token string) RunLogView {
|
||||
@@ -172,6 +208,9 @@ func runLogFor(token string) RunLogView {
|
||||
v.Rooms = fmt.Sprintf("%d / %d", last.Room, run.TotalRooms)
|
||||
}
|
||||
}
|
||||
if !v.Live {
|
||||
v.ReportURL = runReportPath(run.RunID)
|
||||
}
|
||||
for _, b := range beats {
|
||||
// The zone lives on the header, not on every beat — gogobee sends it once,
|
||||
// on `start`, and the beat table has no column for it. Handing it back here
|
||||
@@ -201,6 +240,12 @@ func renderRunBeat(b storage.RunBeat) (runLogLine, bool) {
|
||||
}
|
||||
|
||||
switch b.Kind {
|
||||
case "summary":
|
||||
// Prose about the whole run, not a moment in it. It belongs at the top of
|
||||
// the report, and dropped into the middle of a log it would read as a beat
|
||||
// that somehow saw the ending coming.
|
||||
return runLogLine{}, false
|
||||
|
||||
case "start":
|
||||
l.Emoji = "🚪"
|
||||
l.Text = "Set out into " + orUnknown(b.Zone)
|
||||
|
||||
Reference in New Issue
Block a user