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 }