Files
Pete/internal/web/run.go
T
prosolis b4a276da36 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
2026-07-24 17:11:04 -07:00

459 lines
14 KiB
Go

package web
import (
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"strconv"
"strings"
"time"
"pete/internal/storage"
)
// The expedition liveblog.
//
// Until now Pete only ever heard how a run *ended*: a zone cleared, a retreat, a
// death. The run itself — the fight that nearly went wrong two rooms back, the
// trap, the haul — 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, which is the half that makes it a story instead of a position.
//
// It arrives on its own channel, deliberately not the dispatch queue: beats are
// high-volume and low-stakes, and a chatty run must never be able to spend the
// retry budget a death dispatch depends on. They are also the one thing gogobee
// pushes that is history rather than state, so they append instead of replacing.
//
// The log is a *log*. Lines are short, factual and stacked; Pete does not
// narrate them. His voice is for the dispatch that gets filed when the run ends
// — a running commentary in the same register would drown it out.
const (
// runBeatsMaxBatch bounds one push. gogobee batches on its 2-minute roster
// tick and caps itself well below this; the limit is here to stop a
// malformed or hostile payload spooling unbounded rows.
runBeatsMaxBatch = 1000
// runLogCap is how many beats the page shows. Read from the END — a log is
// read for what just happened, and a party deep into its third region would
// otherwise be showing its first morning forever.
runLogCap = 60
// runFinishedGrace is how long a finished run stays on the adventurer page.
// The interesting moment is the one right after it ends ("what happened?"),
// and that question is asked in minutes, not days. After this the page goes
// back to being a sheet.
runFinishedGrace = 6 * time.Hour
// runRetentionDays is how long a finished run's beats are kept at all.
runRetentionDays = 14
)
// runBeatsPush is the payload gogobee POSTs to /api/ingest/run.
type runBeatsPush struct {
Beats []storage.RunBeat `json:"beats"`
}
// runLogLine is one beat rendered for the column.
type runLogLine struct {
Emoji string
Text string
Room string // "4/9", or empty for a beat that isn't in a room
When string
Hurt bool // the party took damage or lost: worth an eye
Good bool // a find, a kill, a clear
}
// RunLogView is the liveblog as the page draws it.
type RunLogView struct {
Has bool
Live bool
Zone string
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.
//
// Note what is NOT rejected here: an unknown beat kind. That is the same lesson
// the dispatch ingest learned the hard way — a beat Pete has no line for is a
// styling problem, not a validity problem, and 400ing it would silently delete a
// game event and park the row upstream forever. An unknown kind is stored, and
// renders as its own bare noun rather than not at all.
func (s *Server) handleRunIngest(w http.ResponseWriter, r *http.Request) {
if !s.adv.Enabled {
http.NotFound(w, r)
return
}
if !s.bearerOK(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
var push runBeatsPush
if err := json.NewDecoder(io.LimitReader(r.Body, 4<<20)).Decode(&push); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
if len(push.Beats) > runBeatsMaxBatch {
http.Error(w, "batch too large", http.StatusBadRequest)
return
}
now := time.Now().Unix()
kept := make([]storage.RunBeat, 0, len(push.Beats))
for i, b := range push.Beats {
// run_id and seq ARE the row. Without both there is nothing to be
// idempotent on, and a re-send would duplicate the story.
if b.RunID == "" || b.Seq <= 0 {
http.Error(w, fmt.Sprintf("beat %d: run_id and a positive seq are required", i), http.StatusBadRequest)
return
}
// A beat with no clock can't be ordered against the rest of the run and
// would break the retention sweep, which keys on when a run ended.
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 {
w.WriteHeader(http.StatusOK)
return
}
if err := storage.AppendRunBeats(kept); err != nil {
slog.Error("run ingest: append failed", "err", err, "beats", len(kept))
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
slog.Debug("run ingest: beats stored", "beats", len(kept))
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 {
run, ok, err := storage.LatestRunForToken(token)
if err != nil {
slog.Error("run log: header lookup failed", "err", err)
return RunLogView{}
}
if !ok {
return RunLogView{}
}
// A run that finished days ago is not news. It stays in the database — the
// dispatch that announced it links to it — but the adventurer page is about
// now, and an old log sitting under a live map reads as the live one.
if !run.Live() && time.Since(time.Unix(run.EndedAt, 0)) > runFinishedGrace {
return RunLogView{}
}
beats, err := storage.RunBeats(run.RunID, runLogCap)
if err != nil {
slog.Error("run log: beats lookup failed", "run", run.RunID, "err", err)
return RunLogView{}
}
if len(beats) == 0 {
return RunLogView{}
}
v := RunLogView{
Has: true,
Live: run.Live(),
Zone: run.Zone,
Outcome: run.Outcome,
}
if run.TotalRooms > 0 {
last := beats[len(beats)-1]
if last.Room > 0 {
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
// is what stops the opening line reading "Set out into something", which is
// what a straight render of the stored row produces.
if b.Zone == "" {
b.Zone = run.Zone
}
if line, ok := renderRunBeat(b); ok {
v.Lines = append(v.Lines, line)
}
}
return v
}
// renderRunBeat turns one beat into one line. ok is false for a beat with
// nothing to say — a haul of nothing, a room with no identity.
//
// Everything here is assembled from the beat's own nouns and numbers. gogobee
// sends no prose down this channel and Pete invents none: the point of the log
// is that it is what happened, in order, and a line that reads better than the
// facts support is a line that is lying about a run somebody actually walked.
func renderRunBeat(b storage.RunBeat) (runLogLine, bool) {
l := runLogLine{When: time.Unix(b.OccurredAt, 0).UTC().Format("15:04")}
if b.Room > 0 && b.TotalRooms > 0 {
l.Room = fmt.Sprintf("%d/%d", b.Room, b.TotalRooms)
}
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)
if b.TotalRooms > 0 {
l.Text += fmt.Sprintf(" — %d rooms deep", b.TotalRooms)
}
case "room":
l.Emoji = roomEmoji(b.RoomKind)
what, named := roomWord(b.RoomKind)
if b.Outcome == "doubled back" {
// "Doubled back to the next room" is a contradiction — the room behind
// you is the last one, not the next. Only a room with a name of its own
// is worth pointing at on the way back.
if !named {
l.Text = "Doubled back a room"
return l, true
}
l.Text = "Doubled back to the " + what
return l, true
}
l.Text = "Into the " + what
case "combat":
switch b.Outcome {
case "won":
l.Emoji = "⚔️"
l.Good = true
l.Text = orUnknown(b.Target) + " down"
if b.Amount > 0 {
l.Text += fmt.Sprintf(" — took %d", b.Amount)
} else {
l.Text += " — untouched"
}
case "retreat":
l.Emoji = "⏳"
l.Hurt = true
l.Text = "Outlasted by " + orUnknown(b.Target) + " — withdrew"
default:
l.Emoji = "💀"
l.Hurt = true
l.Text = "Fell to " + orUnknown(b.Target)
}
// The crown marks a boss BEATEN. On a boss that killed you it reads as
// congratulating the wrong party, so a loss keeps its skull whatever room
// it happened in.
if b.RoomKind == "boss" && b.Outcome == "won" {
l.Emoji = "👑"
} else if b.RoomKind == "elite" && b.Outcome == "won" {
l.Text = "Elite " + l.Text
}
if hp := hpTail(b); hp != "" {
l.Text += hp
}
if b.Crits > 0 {
l.Text += fmt.Sprintf(" · %s", plural(b.Crits, "critical hit", "critical hits"))
}
case "trap":
l.Emoji = "🕳"
if b.Amount <= 0 {
l.Text = "Trap — stepped over it"
l.Good = true
break
}
l.Hurt = true
l.Text = fmt.Sprintf("Trap sprung — %d damage", b.Amount)
if hp := hpTail(b); hp != "" {
l.Text += hp
}
case "treasure":
l.Emoji = "💎"
l.Good = true
l.Text = "Found " + orUnknown(b.Target)
switch b.Outcome {
case "cache":
l.Text += " in a cache"
case "boss":
l.Text += " on the boss"
}
case "haul":
if b.Amount <= 0 {
return runLogLine{}, false
}
l.Emoji = "🧺"
l.Text = fmt.Sprintf("Gathered %d", b.Amount)
if b.Target != "" {
l.Text += " — mostly " + b.Target
}
if b.Count > 1 {
l.Text += fmt.Sprintf(" (%d kinds)", b.Count)
}
case "lock":
l.Emoji = "🔒"
if b.Outcome == "picked" {
l.Good = true
l.Text = "Picked the lock"
if b.Target != "" {
l.Text += " — " + b.Target
}
break
}
l.Hurt = true
l.Text = "Every way on sealed — doubled back"
case "region":
l.Emoji = "🗺"
l.Room = "" // a border is between rooms, not in one
l.Text = "Crossed into " + orUnknown(b.Target)
if b.Region != "" {
l.Text = "Left " + b.Region + " for " + orUnknown(b.Target)
}
case "end":
switch b.Outcome {
case "cleared":
l.Emoji = "🏆"
l.Good = true
l.Text = "Run complete"
case "died":
l.Emoji = "💀"
l.Hurt = true
l.Text = "Run ended — didn't make it out"
case "retreated":
l.Emoji = "🚑"
l.Hurt = true
l.Text = "Withdrew, wounded but alive"
default:
l.Emoji = "🚪"
l.Text = "Run ended"
}
default:
// A kind Pete has no line for. Show the noun rather than nothing — the
// same call the dispatch ingest makes for an unknown event type, and for
// the same reason: silence here is indistinguishable from a bug.
l.Emoji = "•"
l.Text = strings.ReplaceAll(b.Kind, "_", " ")
if b.Target != "" {
l.Text += " — " + b.Target
}
}
if l.Text == "" {
return runLogLine{}, false
}
return l, true
}
// hpTail is the " (HP 21/34)" suffix, and only when the pair is real. A zero max
// means gogobee didn't send one, not that the adventurer has no health.
func hpTail(b storage.RunBeat) string {
if b.HPMax <= 0 {
return ""
}
return fmt.Sprintf(" · %d/%d HP", b.HP, b.HPMax)
}
// roomWord names a room the way somebody walking through it would. "exploration"
// is the engine's word for "a room", and echoing it back reads like a database
// column; the rooms with an actual identity get named and the rest are just the
// next one along.
// named is false for a room with no identity of its own, which is most of them.
// Callers that need to say something about a *particular* room have to know the
// difference — see the doubled-back branch.
func roomWord(kind string) (word string, named bool) {
switch kind {
case "entry":
return "entrance", true
case "trap":
return "trapped room", true
case "elite":
return "elite's room", true
case "boss":
return "boss chamber", true
case "secret":
return "hidden room", true
}
return "next room", false
}
func roomEmoji(kind string) string {
switch kind {
case "trap":
return "🕳"
case "elite":
return "🛡"
case "boss":
return "👑"
case "entry":
return "🚪"
}
return "🚶"
}
func orUnknown(s string) string {
if s == "" {
return "something"
}
return s
}
func plural(n int, one, many string) string {
if n == 1 {
return "1 " + one
}
return strconv.Itoa(n) + " " + many
}