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" } // 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 } 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) } // 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) } } 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 "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 }