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:
@@ -38,6 +38,10 @@ type AdvEvent struct {
|
||||
Milestone string `json:"milestone"`
|
||||
Stakes string `json:"stakes"`
|
||||
Actors []string `json:"actors"`
|
||||
// RunID is set on the dispatches that are the *ending* of an expedition — a
|
||||
// clear, a retreat, a death. It is the join from "how it went" to "what
|
||||
// happened", and it is empty on every other kind of fact.
|
||||
RunID string `json:"run_id,omitempty"`
|
||||
OccurredAt int64 `json:"occurred_at"`
|
||||
}
|
||||
|
||||
@@ -54,10 +58,11 @@ func InsertAdventureEvent(e *AdvEvent) error {
|
||||
_, err = Get().Exec(`
|
||||
INSERT OR IGNORE INTO adventure_events
|
||||
(guid, event_type, tier, subject, opponent, boss, zone, region,
|
||||
level, tally, outcome, milestone, stakes, actors, occurred_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
level, tally, outcome, milestone, stakes, actors, run_id, occurred_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
e.GUID, e.EventType, e.Tier, e.Subject, e.Opponent, e.Boss, e.Zone,
|
||||
e.Region, e.Level, e.Tally, e.Outcome, e.Milestone, e.Stakes, string(actors), e.OccurredAt)
|
||||
e.Region, e.Level, e.Tally, e.Outcome, e.Milestone, e.Stakes, string(actors),
|
||||
e.RunID, e.OccurredAt)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -71,13 +76,13 @@ func AdventureEventByGUID(guid string) (*AdvEvent, error) {
|
||||
return nil, nil
|
||||
}
|
||||
var e AdvEvent
|
||||
var tier, subject, opponent, boss, zone, region, outcome, milestone, stakes, actors sql.NullString
|
||||
var tier, subject, opponent, boss, zone, region, outcome, milestone, stakes, actors, runID sql.NullString
|
||||
err := Get().QueryRow(`
|
||||
SELECT guid, event_type, tier, subject, opponent, boss, zone, region,
|
||||
level, tally, outcome, milestone, stakes, actors, occurred_at
|
||||
level, tally, outcome, milestone, stakes, actors, run_id, occurred_at
|
||||
FROM adventure_events WHERE guid = ?`, guid).Scan(
|
||||
&e.GUID, &e.EventType, &tier, &subject, &opponent, &boss, &zone, ®ion,
|
||||
&e.Level, &e.Tally, &outcome, &milestone, &stakes, &actors, &e.OccurredAt)
|
||||
&e.Level, &e.Tally, &outcome, &milestone, &stakes, &actors, &runID, &e.OccurredAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -87,6 +92,7 @@ func AdventureEventByGUID(guid string) (*AdvEvent, error) {
|
||||
e.Tier, e.Subject, e.Opponent = tier.String, subject.String, opponent.String
|
||||
e.Boss, e.Zone, e.Region = boss.String, zone.String, region.String
|
||||
e.Outcome, e.Milestone, e.Stakes = outcome.String, milestone.String, stakes.String
|
||||
e.RunID = runID.String
|
||||
if actors.String != "" {
|
||||
_ = json.Unmarshal([]byte(actors.String), &e.Actors)
|
||||
}
|
||||
|
||||
@@ -105,6 +105,16 @@ func runMigrations(d *sql.DB) error {
|
||||
// recorded before the treasure_found event existed carry NULL, which is right:
|
||||
// they had no such noun to keep.
|
||||
addColumnIfMissing(d, "adventure_events", "stakes", "TEXT")
|
||||
// The run behind a dispatch that *ended* one. NULL on every fact filed before
|
||||
// the run report existed and on every fact that isn't the end of an
|
||||
// expedition; both simply render without the "read the run" link.
|
||||
addColumnIfMissing(d, "adventure_events", "run_id", "TEXT")
|
||||
// The liveblog's late-arriving prose and the column that carries it. Both are
|
||||
// in their tables' CREATE TABLE — those tables have never shipped — so these
|
||||
// two adds exist only for a database that already ran an earlier build of the
|
||||
// run-liveblog branch.
|
||||
addColumnIfMissing(d, "adventure_run", "summary", "TEXT NOT NULL DEFAULT ''")
|
||||
addColumnIfMissing(d, "adventure_run_beat", "prose", "TEXT NOT NULL DEFAULT ''")
|
||||
// Ask 7: upgrade orders carry a target tier for the 5 standard equipment slots.
|
||||
addColumnIfMissing(d, "equip_orders", "tier", "INTEGER NOT NULL DEFAULT 0")
|
||||
|
||||
|
||||
+30
-14
@@ -43,6 +43,13 @@ type RunBeat struct {
|
||||
HPMax int `json:"hp_max,omitempty"`
|
||||
Crits int `json:"crits,omitempty"`
|
||||
Fumbles int `json:"fumbles,omitempty"`
|
||||
|
||||
// Prose is the single exception to "nouns and numbers only", and it is
|
||||
// deliberately confined to one beat kind ("summary"). gogobee's LLM reads the
|
||||
// finished run back and says what it was about; Pete guards that text at
|
||||
// ingest exactly as it guards a dispatch lede, then folds it onto the run
|
||||
// header. It is never rendered as a log line — see renderRunBeat.
|
||||
Prose string `json:"prose,omitempty"`
|
||||
}
|
||||
|
||||
// Run is the header: who walked, where, and how it ended (if it has).
|
||||
@@ -57,6 +64,7 @@ type Run struct {
|
||||
UpdatedAt int64
|
||||
EndedAt int64 // 0 = still walking
|
||||
Outcome string
|
||||
Summary string // LLM run summary, empty until the summary beat lands (or forever)
|
||||
}
|
||||
|
||||
// Live reports whether this run is still in progress.
|
||||
@@ -82,8 +90,8 @@ func AppendRunBeats(beats []RunBeat) error {
|
||||
bstmt, err := tx.Prepare(`
|
||||
INSERT OR IGNORE INTO adventure_run_beat
|
||||
(run_id, seq, kind, occurred_at, room, total_rooms, room_kind,
|
||||
target, outcome, amount, qty, hp, hp_max, crits, fumbles, region)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
||||
target, outcome, amount, qty, hp, hp_max, crits, fumbles, region, prose)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -95,8 +103,8 @@ func AppendRunBeats(beats []RunBeat) error {
|
||||
// beat's name and zone survive the forty beats after it that have neither.
|
||||
hstmt, err := tx.Prepare(`
|
||||
INSERT INTO adventure_run
|
||||
(run_id, token, name, level, zone, total_rooms, started_at, updated_at, ended_at, outcome)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
(run_id, token, name, level, zone, total_rooms, started_at, updated_at, ended_at, outcome, summary)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(run_id) DO UPDATE SET
|
||||
token = COALESCE(NULLIF(excluded.token, ''), adventure_run.token),
|
||||
name = COALESCE(NULLIF(excluded.name, ''), adventure_run.name),
|
||||
@@ -106,7 +114,8 @@ func AppendRunBeats(beats []RunBeat) error {
|
||||
started_at = COALESCE(NULLIF(adventure_run.started_at, 0), excluded.started_at),
|
||||
updated_at = MAX(adventure_run.updated_at, excluded.updated_at),
|
||||
ended_at = COALESCE(NULLIF(adventure_run.ended_at, 0), excluded.ended_at),
|
||||
outcome = COALESCE(NULLIF(adventure_run.outcome, ''), excluded.outcome)`)
|
||||
outcome = COALESCE(NULLIF(adventure_run.outcome, ''), excluded.outcome),
|
||||
summary = COALESCE(NULLIF(adventure_run.summary, ''), excluded.summary)`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -118,7 +127,7 @@ func AppendRunBeats(beats []RunBeat) error {
|
||||
}
|
||||
if _, err := bstmt.Exec(b.RunID, b.Seq, b.Kind, b.OccurredAt, b.Room, b.TotalRooms,
|
||||
b.RoomKind, b.Target, b.Outcome, b.Amount, b.Count, b.HP, b.HPMax,
|
||||
b.Crits, b.Fumbles, b.Region); err != nil {
|
||||
b.Crits, b.Fumbles, b.Region, b.Prose); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -135,8 +144,15 @@ func AppendRunBeats(beats []RunBeat) error {
|
||||
if b.Kind == "start" {
|
||||
started = b.OccurredAt
|
||||
}
|
||||
// Only the summary beat may set the summary. Any other kind carrying prose
|
||||
// is upstream noise, and letting it through would put unguarded text on the
|
||||
// header — the guard at ingest only inspects the kind it knows about.
|
||||
summary := ""
|
||||
if b.Kind == "summary" {
|
||||
summary = b.Prose
|
||||
}
|
||||
if _, err := hstmt.Exec(b.RunID, b.Token, b.Name, b.Level, b.Zone,
|
||||
b.TotalRooms, started, b.OccurredAt, endedAt, outcome); err != nil {
|
||||
b.TotalRooms, started, b.OccurredAt, endedAt, outcome, summary); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -162,13 +178,13 @@ func LatestRunForToken(token string) (Run, bool, error) {
|
||||
var r Run
|
||||
err := Get().QueryRow(`
|
||||
SELECT run_id, token, name, level, zone, total_rooms,
|
||||
started_at, updated_at, ended_at, outcome
|
||||
started_at, updated_at, ended_at, outcome, summary
|
||||
FROM adventure_run
|
||||
WHERE token = ?
|
||||
ORDER BY (ended_at = 0) DESC, updated_at DESC, started_at DESC
|
||||
LIMIT 1`, token).Scan(
|
||||
&r.RunID, &r.Token, &r.Name, &r.Level, &r.Zone, &r.TotalRooms,
|
||||
&r.StartedAt, &r.UpdatedAt, &r.EndedAt, &r.Outcome)
|
||||
&r.StartedAt, &r.UpdatedAt, &r.EndedAt, &r.Outcome, &r.Summary)
|
||||
if err == sql.ErrNoRows {
|
||||
return Run{}, false, nil
|
||||
}
|
||||
@@ -183,10 +199,10 @@ func RunByID(runID string) (Run, bool, error) {
|
||||
var r Run
|
||||
err := Get().QueryRow(`
|
||||
SELECT run_id, token, name, level, zone, total_rooms,
|
||||
started_at, updated_at, ended_at, outcome
|
||||
started_at, updated_at, ended_at, outcome, summary
|
||||
FROM adventure_run WHERE run_id = ?`, runID).Scan(
|
||||
&r.RunID, &r.Token, &r.Name, &r.Level, &r.Zone, &r.TotalRooms,
|
||||
&r.StartedAt, &r.UpdatedAt, &r.EndedAt, &r.Outcome)
|
||||
&r.StartedAt, &r.UpdatedAt, &r.EndedAt, &r.Outcome, &r.Summary)
|
||||
if err == sql.ErrNoRows {
|
||||
return Run{}, false, nil
|
||||
}
|
||||
@@ -206,14 +222,14 @@ func RunBeats(runID string, limit int) ([]RunBeat, error) {
|
||||
return nil, nil
|
||||
}
|
||||
q := `SELECT run_id, seq, kind, occurred_at, room, total_rooms, room_kind,
|
||||
target, outcome, amount, qty, hp, hp_max, crits, fumbles, region
|
||||
target, outcome, amount, qty, hp, hp_max, crits, fumbles, region, prose
|
||||
FROM adventure_run_beat WHERE run_id = ? ORDER BY seq ASC`
|
||||
args := []any{runID}
|
||||
if limit > 0 {
|
||||
// Innermost query takes the tail, the wrapper puts it back in order.
|
||||
q = `SELECT * FROM (
|
||||
SELECT run_id, seq, kind, occurred_at, room, total_rooms, room_kind,
|
||||
target, outcome, amount, qty, hp, hp_max, crits, fumbles, region
|
||||
target, outcome, amount, qty, hp, hp_max, crits, fumbles, region, prose
|
||||
FROM adventure_run_beat WHERE run_id = ? ORDER BY seq DESC LIMIT ?
|
||||
) ORDER BY seq ASC`
|
||||
args = append(args, limit)
|
||||
@@ -229,7 +245,7 @@ func RunBeats(runID string, limit int) ([]RunBeat, error) {
|
||||
var b RunBeat
|
||||
if err := rows.Scan(&b.RunID, &b.Seq, &b.Kind, &b.OccurredAt, &b.Room, &b.TotalRooms,
|
||||
&b.RoomKind, &b.Target, &b.Outcome, &b.Amount, &b.Count, &b.HP, &b.HPMax,
|
||||
&b.Crits, &b.Fumbles, &b.Region); err != nil {
|
||||
&b.Crits, &b.Fumbles, &b.Region, &b.Prose); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, b)
|
||||
|
||||
@@ -88,6 +88,13 @@ CREATE TABLE IF NOT EXISTS adventure_events (
|
||||
stakes TEXT, -- free-text noun the fact is about: a bounty, a found treasure's name
|
||||
|
||||
actors TEXT, -- JSON array; the fact-guard allow-list, kept for audit
|
||||
-- The expedition this dispatch is the ending of, when it is the ending of one
|
||||
-- (a clear, a retreat, a death). It is the join from "how it went" to "what
|
||||
-- happened", and it is the reason Pete keeps a finished run's beats for two
|
||||
-- weeks while only *showing* them for six hours: the dispatch outlives the run
|
||||
-- it announced, and a story that can't reach its own log is the whole point
|
||||
-- of the log going missing.
|
||||
run_id TEXT,
|
||||
occurred_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_adv_events_subject ON adventure_events(subject, occurred_at DESC);
|
||||
@@ -154,6 +161,13 @@ CREATE TABLE IF NOT EXISTS adventure_siege_history (
|
||||
-- That means a run whose start beat never arrived still gets a row (created by
|
||||
-- whatever beat did arrive) and is simply unattributed — the log survives
|
||||
-- nameless instead of being dropped for want of a name.
|
||||
--
|
||||
-- summary is the one piece of PROSE anywhere in the liveblog. Every log line is
|
||||
-- assembled by Pete out of a beat's own nouns and numbers; this is gogobee's LLM
|
||||
-- reading the finished run back and saying what it was *about*, which is a
|
||||
-- judgement no template can make. It arrives late — its own beat, a tick or two
|
||||
-- after the run ends — and it is optional forever: with the model off, the report
|
||||
-- is the log plus the numbers, which is still the report.
|
||||
CREATE TABLE IF NOT EXISTS adventure_run (
|
||||
run_id TEXT PRIMARY KEY,
|
||||
token TEXT NOT NULL DEFAULT '', -- public board token; '' = unattributed
|
||||
@@ -164,7 +178,8 @@ CREATE TABLE IF NOT EXISTS adventure_run (
|
||||
started_at INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at INTEGER NOT NULL DEFAULT 0,
|
||||
ended_at INTEGER NOT NULL DEFAULT 0, -- 0 = still walking
|
||||
outcome TEXT NOT NULL DEFAULT '' -- cleared|died|retreated|abandoned
|
||||
outcome TEXT NOT NULL DEFAULT '', -- cleared|died|retreated|abandoned
|
||||
summary TEXT NOT NULL DEFAULT '' -- LLM run summary, post prose-guard
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_adv_run_token ON adventure_run(token, started_at DESC);
|
||||
|
||||
@@ -189,6 +204,11 @@ CREATE TABLE IF NOT EXISTS adventure_run_beat (
|
||||
crits INTEGER NOT NULL DEFAULT 0,
|
||||
fumbles INTEGER NOT NULL DEFAULT 0,
|
||||
region TEXT NOT NULL DEFAULT '',
|
||||
-- prose is carried by exactly one beat kind ("summary") and is never rendered
|
||||
-- as a log line. It lives on the beat rather than on its own endpoint so the
|
||||
-- summary inherits the whole channel: idempotent on (run_id, seq), retried
|
||||
-- until delivered, and impossible to attach to a run that doesn't exist.
|
||||
prose TEXT NOT NULL DEFAULT '',
|
||||
PRIMARY KEY (run_id, seq)
|
||||
);
|
||||
|
||||
|
||||
@@ -38,6 +38,12 @@ type AdvFact struct {
|
||||
Milestone string `json:"milestone"`
|
||||
OccurredAt int64 `json:"occurred_at"`
|
||||
NoPush bool `json:"no_push"`
|
||||
// RunID names the expedition this dispatch is the ending of, on the three
|
||||
// event types that are one (a clear, a retreat, a death). It is what lets the
|
||||
// permalink offer the run's own report — the log, the numbers, the moment it
|
||||
// turned — instead of leaving a paragraph about an outcome with no way back to
|
||||
// what produced it. Empty on every other fact.
|
||||
RunID string `json:"run_id,omitempty"`
|
||||
// Headline/Lede are gogobee's LLM-authored prose, both optional. When present
|
||||
// and past the prose-guard they replace the template render; otherwise Pete
|
||||
// falls back to renderAdventure. gogobee is compute here, Pete is the editor:
|
||||
@@ -196,6 +202,7 @@ func (s *Server) handleAdventureIngest(w http.ResponseWriter, r *http.Request) {
|
||||
Milestone: f.Milestone,
|
||||
Stakes: f.Stakes,
|
||||
Actors: f.Actors,
|
||||
RunID: f.RunID,
|
||||
OccurredAt: occurredAt,
|
||||
}); err != nil {
|
||||
slog.Error("adventure ingest: event record failed", "guid", f.GUID, "err", err)
|
||||
@@ -367,6 +374,10 @@ type advStoryPage struct {
|
||||
Region string
|
||||
When string
|
||||
Permalink string
|
||||
// RunReportURL is the link to the expedition behind this dispatch, when the
|
||||
// dispatch is the end of one and the run is still reachable. Empty is the
|
||||
// common case and renders nothing.
|
||||
RunReportURL string
|
||||
}
|
||||
|
||||
// handleAdventureStory serves the server-rendered permalink for one dispatch
|
||||
@@ -397,6 +408,17 @@ func (s *Server) handleAdventureStory(w http.ResponseWriter, r *http.Request) {
|
||||
body = st.Lede // template-only dispatches carry the write-up in the lede
|
||||
}
|
||||
|
||||
// The way back to what actually happened. Best-effort and usually absent: only
|
||||
// the three end-of-expedition types carry a run id at all, and the run behind
|
||||
// one is swept after a fortnight. A dispatch without it reads exactly as it
|
||||
// did before the report existed.
|
||||
runReport := ""
|
||||
if ev, err := storage.AdventureEventByGUID(guid); err != nil {
|
||||
slog.Error("adventure story: fact lookup failed", "guid", guid, "err", err)
|
||||
} else {
|
||||
runReport = runReportLinkFor(ev)
|
||||
}
|
||||
|
||||
base := s.base(r)
|
||||
base.Active = "adventure"
|
||||
base.NoIndex = true // player-named page; keep out of search indexes (gap #5)
|
||||
@@ -412,6 +434,7 @@ func (s *Server) handleAdventureStory(w http.ResponseWriter, r *http.Request) {
|
||||
Region: "", // reserved: region isn't stored on the row yet
|
||||
When: time.Unix(st.SeenAt, 0).UTC().Format("Jan 2, 2006"),
|
||||
Permalink: s.advPermalink(guid),
|
||||
RunReportURL: runReport,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"pete/internal/storage"
|
||||
)
|
||||
|
||||
// onBoard puts the run's owner on the roster. Every report test needs it: the
|
||||
// report's visibility gate is the adventurer page's, and with no board at all
|
||||
// every token reads as opted-out.
|
||||
func onBoard(t *testing.T, s *Server, ingest, token, name string) {
|
||||
t.Helper()
|
||||
if w := postRoster(t, s, ingest, rosterPush{
|
||||
SnapshotAt: time.Now().Unix(),
|
||||
Adventurers: []storage.RosterEntry{entry(token, name, "idle", "")},
|
||||
}); w.Code != 200 {
|
||||
t.Fatalf("roster push failed: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// aFinishedRun is a small but realistic expedition: two fights, a trap that hurt
|
||||
// more than either of them, a find, and a clean ending.
|
||||
func aFinishedRun(now int64) []storage.RunBeat {
|
||||
return []storage.RunBeat{
|
||||
startBeat(now),
|
||||
{RunID: "run-1", Seq: 2, Kind: "combat", OccurredAt: now + 60, Room: 2, TotalRooms: 9,
|
||||
Target: "Bone Chanter", Outcome: "won", Amount: 7, HP: 61, HPMax: 68, Crits: 1},
|
||||
{RunID: "run-1", Seq: 3, Kind: "trap", OccurredAt: now + 120, Room: 3, TotalRooms: 9,
|
||||
RoomKind: "trap", Outcome: "sprung", Amount: 22, HP: 39, HPMax: 68},
|
||||
{RunID: "run-1", Seq: 4, Kind: "treasure", OccurredAt: now + 200, Room: 4, TotalRooms: 9,
|
||||
Target: "Ashlight Pendant", Outcome: "cache"},
|
||||
{RunID: "run-1", Seq: 5, Kind: "combat", OccurredAt: now + 300, Room: 5, TotalRooms: 9,
|
||||
RoomKind: "boss", Target: "Valdris", Outcome: "won", Amount: 12, HP: 27, HPMax: 68},
|
||||
{RunID: "run-1", Seq: 6, Kind: "end", OccurredAt: now + 360, Room: 5, TotalRooms: 9,
|
||||
Outcome: "cleared"},
|
||||
}
|
||||
}
|
||||
|
||||
func getReport(t *testing.T, s *Server, runID string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest("GET", "/adventure/run/"+runID, nil)
|
||||
req.SetPathValue("run_id", runID)
|
||||
w := httptest.NewRecorder()
|
||||
s.handleRunReport(w, req)
|
||||
return w
|
||||
}
|
||||
|
||||
// TestRunReportRendersTheWholeRun. The liveblog is capped and expires; the
|
||||
// report is the artefact, so what it has to get right is that everything is
|
||||
// there — every beat, the rollup, and the moment it turned.
|
||||
func TestRunReportRendersTheWholeRun(t *testing.T) {
|
||||
const token = "tok"
|
||||
s, _ := newAdvServer(t, token)
|
||||
now := time.Now().Unix()
|
||||
onBoard(t, s, token, "tok-abc", "Josie")
|
||||
postBeats(t, s, token, aFinishedRun(now)...)
|
||||
|
||||
w := getReport(t, s, "run-1")
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("report: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
body := w.Body.String()
|
||||
for _, want := range []string{
|
||||
"Josie in Crypt of Valdris",
|
||||
"Cleared it",
|
||||
"Bone Chanter down",
|
||||
"Trap sprung — 22 damage",
|
||||
"Found Ashlight Pendant",
|
||||
"Valdris down",
|
||||
"Run complete",
|
||||
"2/2", // fights won, as one tile rather than two
|
||||
"41", // damage taken: 7 + 22 + 12
|
||||
"Where it turned", // the trap, being the biggest single hit
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("report is missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestTurningPointIsTheBiggestHit. The plan's word for it is "turning point" and
|
||||
// the temptation is to pick the boss, because a boss is the most *important*
|
||||
// thing in a run. It isn't the thing that turned it: a boss killed without a
|
||||
// scratch turned nothing, and the trap two rooms earlier that took a third of
|
||||
// the party's health is the beat the reader is looking for.
|
||||
func TestTurningPointIsTheBiggestHit(t *testing.T) {
|
||||
now := time.Now().Unix()
|
||||
run := storage.Run{RunID: "r", Token: "t", Name: "Josie", Zone: "Crypt", TotalRooms: 9,
|
||||
StartedAt: now, EndedAt: now + 360, Outcome: "cleared"}
|
||||
v := buildRunReport(run, aFinishedRun(now))
|
||||
if v.Turning == nil {
|
||||
t.Fatal("no turning point on a run with a 22-damage trap in it")
|
||||
}
|
||||
if !strings.Contains(v.Turning.Text, "Trap sprung") {
|
||||
t.Errorf("turning point = %q, want the trap (22) over the boss (12)", v.Turning.Text)
|
||||
}
|
||||
|
||||
// A run where nothing landed has no turning point rather than a made-up one.
|
||||
quiet := []storage.RunBeat{
|
||||
{RunID: "r", Seq: 1, Kind: "start", OccurredAt: now, Zone: "Crypt", TotalRooms: 3},
|
||||
{RunID: "r", Seq: 2, Kind: "combat", OccurredAt: now + 10, Target: "Rat", Outcome: "won"},
|
||||
{RunID: "r", Seq: 3, Kind: "end", OccurredAt: now + 20, Outcome: "cleared"},
|
||||
}
|
||||
if q := buildRunReport(run, quiet); q.Turning != nil {
|
||||
t.Errorf("invented a turning point on an untouched run: %q", q.Turning.Text)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHowFarTheyGotOnlyMattersWhenTheyFellShort. A dungeon graph forks, so a run
|
||||
// that cleared it never walks every room — "room 7 / 9" printed under the words
|
||||
// "Cleared it" says they came up two short of something they in fact finished.
|
||||
// On a run that ended badly the same number is the whole story.
|
||||
func TestHowFarTheyGotOnlyMattersWhenTheyFellShort(t *testing.T) {
|
||||
now := time.Now().Unix()
|
||||
beats := aFinishedRun(now)
|
||||
base := storage.Run{RunID: "r", Token: "t", Name: "Josie", Zone: "Crypt", TotalRooms: 9,
|
||||
StartedAt: now, EndedAt: now + 360}
|
||||
|
||||
cleared := base
|
||||
cleared.Outcome = "cleared"
|
||||
if v := buildRunReport(cleared, beats); v.Rooms != "" {
|
||||
t.Errorf("a cleared run advertised how far it got: %q", v.Rooms)
|
||||
}
|
||||
|
||||
died := base
|
||||
died.Outcome = "died"
|
||||
if v := buildRunReport(died, beats); v.Rooms != "got as far as room 5 of 9" {
|
||||
t.Errorf("Rooms = %q, want the depth on a run that ended badly", v.Rooms)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunStatsSkipTheZeroes. The tile row is meant to say what THIS run was. A
|
||||
// run that sprung no traps and found no treasure rendering two confident zeroes
|
||||
// makes every run look identical, which is the exact failure the report exists
|
||||
// to fix.
|
||||
func TestRunStatsSkipTheZeroes(t *testing.T) {
|
||||
now := time.Now().Unix()
|
||||
stats := runStats([]storage.RunBeat{
|
||||
{Kind: "combat", Outcome: "won", Target: "Rat", Amount: 3, OccurredAt: now},
|
||||
{Kind: "trap", Outcome: "avoided", Amount: 0, OccurredAt: now + 1}, // stepped over it
|
||||
})
|
||||
for _, s := range stats {
|
||||
if strings.Contains(s.Label, "trap") {
|
||||
t.Errorf("a trap that was avoided produced a tile: %+v", s)
|
||||
}
|
||||
if strings.Contains(s.Label, "treasure") {
|
||||
t.Errorf("a run with no finds produced a treasure tile: %+v", s)
|
||||
}
|
||||
if strings.Contains(s.Label, "critical") {
|
||||
t.Errorf("a run with no crits produced a crit tile: %+v", s)
|
||||
}
|
||||
}
|
||||
if len(stats) != 2 { // fights won + damage taken
|
||||
t.Fatalf("want 2 tiles, got %d: %+v", len(stats), stats)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunReportIsGatedOnTheBoard is the report's half of TestOffTheBoardShipsNoLog.
|
||||
//
|
||||
// The report outlives the liveblog 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 is what an opt-out looks like from Pete's side,
|
||||
// and from that moment a room-by-room account of where they went has to stop
|
||||
// resolving — including through the link a dispatch minted days earlier.
|
||||
func TestRunReportIsGatedOnTheBoard(t *testing.T) {
|
||||
const token = "tok"
|
||||
s, _ := newAdvServer(t, token)
|
||||
now := time.Now().Unix()
|
||||
onBoard(t, s, token, "tok-abc", "Josie")
|
||||
postBeats(t, s, token, aFinishedRun(now)...)
|
||||
|
||||
if w := getReport(t, s, "run-1"); w.Code != 200 {
|
||||
t.Fatalf("report should serve while its owner is on the board: %d", w.Code)
|
||||
}
|
||||
ev := &storage.AdvEvent{GUID: "zone_clear:x:1", EventType: "zone_clear", Subject: "Josie",
|
||||
RunID: "run-1", OccurredAt: now}
|
||||
if err := storage.InsertAdventureEvent(ev); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if link := runReportLinkFor(ev); link != "/adventure/run/run-1" {
|
||||
t.Fatalf("dispatch link = %q, want the report path", link)
|
||||
}
|
||||
|
||||
// They opt out: gogobee stops sending them, so the next board has no such
|
||||
// token. The beats Pete already holds are append-only and can't be recalled —
|
||||
// what has to happen is that they become unreachable.
|
||||
if w := postRoster(t, s, token, rosterPush{
|
||||
SnapshotAt: now + 1,
|
||||
Adventurers: []storage.RosterEntry{entry("someone-else", "Quack", "idle", "")},
|
||||
}); w.Code != 200 {
|
||||
t.Fatalf("roster push failed: %d", w.Code)
|
||||
}
|
||||
if w := getReport(t, s, "run-1"); w.Code != 404 {
|
||||
t.Errorf("report still served after its owner left the board: %d", w.Code)
|
||||
}
|
||||
if link := runReportLinkFor(ev); link != "" {
|
||||
t.Errorf("dispatch still offers a link to an opted-out player's run: %q", link)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUnattributedRunHasNoReport. A run whose `start` beat never arrived still
|
||||
// gets a readable log — that is deliberate, and W2a pinned it. But it has no
|
||||
// token, so Pete cannot establish whose run it is, and "don't know" is not a
|
||||
// basis on which to publish where somebody went.
|
||||
func TestUnattributedRunHasNoReport(t *testing.T) {
|
||||
const token = "tok"
|
||||
s, _ := newAdvServer(t, token)
|
||||
now := time.Now().Unix()
|
||||
onBoard(t, s, token, "tok-abc", "Josie")
|
||||
|
||||
postBeats(t, s, token,
|
||||
storage.RunBeat{RunID: "orphan", Seq: 2, Kind: "combat", OccurredAt: now,
|
||||
Room: 2, TotalRooms: 9, Target: "Bone Chanter", Outcome: "won"},
|
||||
storage.RunBeat{RunID: "orphan", Seq: 3, Kind: "end", OccurredAt: now + 5, Outcome: "cleared"},
|
||||
)
|
||||
if w := getReport(t, s, "orphan"); w.Code != 404 {
|
||||
t.Errorf("served a report for a run with no owner: %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunSummaryIsGuardedLikeADispatch. The summary is the only prose on the
|
||||
// beat channel and it is LLM output over player-chosen names, so the field
|
||||
// checks that make a *fact* safe are worth nothing here — the words are the
|
||||
// thing being rendered. A summary that names a different adventurer on the board
|
||||
// is either a hallucination or an injection, and both get the same answer: keep
|
||||
// the report, drop the prose.
|
||||
func TestRunSummaryIsGuardedLikeADispatch(t *testing.T) {
|
||||
const token = "tok"
|
||||
s, _ := newAdvServer(t, token)
|
||||
now := time.Now().Unix()
|
||||
if w := postRoster(t, s, token, rosterPush{SnapshotAt: now, Adventurers: []storage.RosterEntry{
|
||||
entry("tok-abc", "Josie", "idle", ""),
|
||||
entry("tok-def", "Quack", "idle", ""),
|
||||
}}); w.Code != 200 {
|
||||
t.Fatalf("roster push failed: %d", w.Code)
|
||||
}
|
||||
postBeats(t, s, token, aFinishedRun(now)...)
|
||||
|
||||
// Names a bystander who was never on this expedition.
|
||||
if w := postBeats(t, s, token, storage.RunBeat{
|
||||
RunID: "run-1", Seq: 7, Kind: "summary", OccurredAt: now + 400, Name: "Josie",
|
||||
Prose: "Josie and Quack went down into the crypt together and only one came back.",
|
||||
}); w.Code != 200 {
|
||||
t.Fatalf("a rejected summary should still be a 200: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
run, _, err := storage.RunByID("run-1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if run.Summary != "" {
|
||||
t.Errorf("guard let through a summary naming a bystander: %q", run.Summary)
|
||||
}
|
||||
|
||||
// The same beat, about the right person only. The seq differs because the
|
||||
// rejected row is still stored — that is what stops gogobee re-authoring it
|
||||
// forever — so a retry has to be a new beat.
|
||||
good := "Josie took a bad trap on the way in and finished the boss on a quarter of her health."
|
||||
if w := postBeats(t, s, token, storage.RunBeat{
|
||||
RunID: "run-1", Seq: 8, Kind: "summary", OccurredAt: now + 401, Name: "Josie", Prose: good,
|
||||
}); w.Code != 200 {
|
||||
t.Fatalf("summary rejected: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
run, _, err = storage.RunByID("run-1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if run.Summary != good {
|
||||
t.Errorf("summary = %q, want it stored", run.Summary)
|
||||
}
|
||||
|
||||
// And it renders on the report, above the log rather than inside it.
|
||||
w := getReport(t, s, "run-1")
|
||||
if !strings.Contains(w.Body.String(), good) {
|
||||
t.Error("the summary didn't reach the report page")
|
||||
}
|
||||
v := runLogFor("tok-abc")
|
||||
for _, ln := range v.Lines {
|
||||
if strings.Contains(ln.Text, "bad trap on the way in") {
|
||||
t.Errorf("the summary rendered as a log line: %q", ln.Text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestOnlyTheSummaryBeatCarriesProse. The guard at ingest only inspects the kind
|
||||
// it knows about, so any other kind arriving with prose would put unguarded text
|
||||
// onto the header. Both halves have to hold — the beat must be scrubbed, and the
|
||||
// header fold must ignore it even if it weren't.
|
||||
func TestOnlyTheSummaryBeatCarriesProse(t *testing.T) {
|
||||
const token = "tok"
|
||||
s, _ := newAdvServer(t, token)
|
||||
now := time.Now().Unix()
|
||||
onBoard(t, s, token, "tok-abc", "Josie")
|
||||
|
||||
postBeats(t, s, token, startBeat(now), storage.RunBeat{
|
||||
RunID: "run-1", Seq: 2, Kind: "combat", OccurredAt: now + 10,
|
||||
Target: "Bone Chanter", Outcome: "won",
|
||||
Prose: "and then Quack showed up out of nowhere",
|
||||
})
|
||||
run, _, err := storage.RunByID("run-1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if run.Summary != "" {
|
||||
t.Errorf("a combat beat wrote the run summary: %q", run.Summary)
|
||||
}
|
||||
beats, err := storage.RunBeats("run-1", 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, b := range beats {
|
||||
if b.Prose != "" {
|
||||
t.Errorf("beat %d (%s) kept prose it isn't allowed to carry: %q", b.Seq, b.Kind, b.Prose)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestFinishedRunOffersItsReport / a live one doesn't. While a run is still
|
||||
// walking, the column on the adventurer page IS the report; a link to a second
|
||||
// copy of what somebody is already reading is only a way to lose them.
|
||||
func TestFinishedRunOffersItsReport(t *testing.T) {
|
||||
const token = "tok"
|
||||
s, _ := newAdvServer(t, token)
|
||||
now := time.Now().Unix()
|
||||
onBoard(t, s, token, "tok-abc", "Josie")
|
||||
|
||||
postBeats(t, s, token, startBeat(now))
|
||||
if v := runLogFor("tok-abc"); v.ReportURL != "" {
|
||||
t.Errorf("a live run offered a report link: %q", v.ReportURL)
|
||||
}
|
||||
postBeats(t, s, token, storage.RunBeat{
|
||||
RunID: "run-1", Seq: 9, Kind: "end", OccurredAt: now + 60, Outcome: "cleared"})
|
||||
if v := runLogFor("tok-abc"); v.ReportURL != "/adventure/run/run-1" {
|
||||
t.Errorf("ReportURL = %q, want the report path once the run is over", v.ReportURL)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunElapsedFallsBackToTheBeats. started_at comes off the `start` beat, so a
|
||||
// run that lost it has a zero clock on the header and would otherwise report no
|
||||
// duration at all — on precisely the run where the log is the only record there is.
|
||||
func TestRunElapsedFallsBackToTheBeats(t *testing.T) {
|
||||
now := time.Now().Unix()
|
||||
beats := []storage.RunBeat{
|
||||
{RunID: "r", Seq: 2, Kind: "combat", OccurredAt: now, Target: "Rat", Outcome: "won"},
|
||||
{RunID: "r", Seq: 3, Kind: "end", OccurredAt: now + 5400, Outcome: "cleared"},
|
||||
}
|
||||
// No StartedAt: the beat that would have set it never arrived.
|
||||
got := runElapsed(storage.Run{RunID: "r", EndedAt: now + 5400}, beats)
|
||||
if got != "1h 30m" {
|
||||
t.Errorf("elapsed = %q, want 1h 30m off the beats", got)
|
||||
}
|
||||
}
|
||||
@@ -102,7 +102,7 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo
|
||||
shared []string
|
||||
pages []string
|
||||
}{
|
||||
{"layout", []string{"_card"}, []string{"index", "channel", "weather", "bookmarks", "for-you", "status", "story", "who", "siege"}},
|
||||
{"layout", []string{"_card"}, []string{"index", "channel", "weather", "bookmarks", "for-you", "status", "story", "who", "siege", "run_report"}},
|
||||
{"games_layout", []string{"_chipbar"}, []string{"games", "games_door", "blackjack", "hangman", "solitaire", "trivia", "uno", "holdem"}},
|
||||
}
|
||||
tpls := make(map[string]*template.Template)
|
||||
@@ -231,10 +231,14 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo
|
||||
mux.HandleFunc("GET /adventure/who/{token}", s.handleAdventureWho)
|
||||
mux.HandleFunc("GET /api/adventure/who/{token}", s.handleAdventureWhoAPI)
|
||||
|
||||
// The expedition liveblog. Bearer-authed ingest and nothing else: the beats
|
||||
// have no page of their own, they render inside the adventurer page under
|
||||
// the map and ride that page's existing poll.
|
||||
// The expedition liveblog. Beats arrive bearer-authed and render two ways:
|
||||
// live, inside the adventurer page under the map, on that page's own poll —
|
||||
// and afterwards as the run's own report, which is the artefact a dispatch
|
||||
// links to and a player shares. Three segments, so the report never overlaps
|
||||
// /adventure/{guid}, and its middle segment is a literal, so it never
|
||||
// overlaps /adventure/art/{type} either.
|
||||
mux.HandleFunc("POST /api/ingest/run", s.handleRunIngest)
|
||||
mux.HandleFunc("GET /adventure/run/{run_id}", s.handleRunReport)
|
||||
|
||||
// The Siege war room. Ingest is bearer-authed like the roster; the page and
|
||||
// its poll are public — the same exposure the board already has.
|
||||
|
||||
@@ -2860,4 +2860,8 @@ html[data-room] .pete-felt {
|
||||
what hurt, and what was worth having. */
|
||||
.runlog-hurt .runlog-text { color: color-mix(in srgb, #c0392b 62%, var(--ink)); font-weight: 600; }
|
||||
.runlog-good .runlog-text { color: color-mix(in srgb, #3fa66a 60%, var(--ink)); }
|
||||
/* On the report the log is the page, not a panel inside one: it scrolls with
|
||||
the document instead of trapping the whole run in a 26rem window that a
|
||||
reader has to find the edge of before they can move through it. */
|
||||
.runlog-full { max-height: none; overflow-y: visible; }
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,83 @@
|
||||
{{define "title"}}{{.Name}} in {{.Zone}} — {{.SiteTitle}}{{end}}
|
||||
|
||||
{{define "main"}}
|
||||
<article class="mt-2 mb-10 max-w-3xl mx-auto">
|
||||
<nav class="mb-4 flex items-center gap-4">
|
||||
<a href="/adventure" class="inline-flex items-center gap-1.5 text-sm font-semibold text-[color:var(--ink)]/60 hover:text-[color:var(--ink)] transition">
|
||||
<span aria-hidden="true">←</span> All dispatches
|
||||
</a>
|
||||
{{if .WhoURL}}
|
||||
<a href="{{.WhoURL}}" class="inline-flex items-center gap-1.5 text-sm font-semibold text-[color:var(--ink)]/60 hover:text-[color:var(--ink)] transition">
|
||||
{{.Name}}'s sheet <span aria-hidden="true">→</span>
|
||||
</a>
|
||||
{{end}}
|
||||
</nav>
|
||||
|
||||
<header class="rounded-3xl bg-theme-adventure text-white p-6 sm:p-10 shadow-pete relative overflow-hidden">
|
||||
<div class="absolute -top-6 -right-6 text-[12rem] opacity-20 select-none" aria-hidden="true">{{.Emoji}}</div>
|
||||
<div class="relative">
|
||||
<p class="text-sm uppercase tracking-[0.2em] opacity-80">{{.Emoji}} {{.Verdict}}</p>
|
||||
<h1 class="font-display text-3xl sm:text-4xl font-bold mt-2 leading-tight">{{.Name}} in {{.Zone}}</h1>
|
||||
<p class="mt-4 text-xs uppercase tracking-wider opacity-75">
|
||||
{{if .When}}{{.When}}{{end}}{{if .Level}} · level {{.Level}}{{end}}{{if .Elapsed}} · {{.Elapsed}} down there{{end}}{{if .Rooms}} · {{.Rooms}}{{end}}
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{{if .Summary}}
|
||||
<!-- The one paragraph on this page nobody in the realm wrote down as it
|
||||
happened: the game's own model reading the finished run back. Guarded at
|
||||
ingest, and absent entirely when the model is off — which is why it sits
|
||||
above the numbers rather than instead of them. -->
|
||||
<section class="mt-6 rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 p-6 sm:p-8 shadow-pete">
|
||||
<p class="text-lg leading-relaxed text-[color:var(--ink)]/90 whitespace-pre-line">{{.Summary}}</p>
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
{{if .Stats}}
|
||||
<section class="mt-6 grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||
{{range .Stats}}
|
||||
<div class="rounded-2xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 px-2 py-3 text-center shadow-pete">
|
||||
<div class="font-display text-2xl font-bold leading-none tabular-nums">{{.Value}}</div>
|
||||
<div class="text-[10px] uppercase tracking-wider text-[color:var(--ink)]/50 mt-1.5">{{.Label}}</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
{{if .Turning}}
|
||||
<!-- The worst single thing that happened, pulled out of the middle of the log
|
||||
where it would otherwise read as one line among forty. -->
|
||||
<section class="mt-6 rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 p-6 shadow-pete">
|
||||
<h2 class="font-display text-xl font-bold mb-3">Where it turned</h2>
|
||||
<p class="flex items-baseline gap-2.5">
|
||||
<span aria-hidden="true">{{.Turning.Emoji}}</span>
|
||||
<span class="flex-1 font-semibold">{{.Turning.Text}}</span>
|
||||
<span class="runlog-meta">{{if .Turning.Room}}{{.Turning.Room}} · {{end}}{{.Turning.When}}</span>
|
||||
</p>
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
<section class="mt-6 rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 p-6 shadow-pete">
|
||||
<div class="flex items-baseline justify-between mb-4 gap-3">
|
||||
<h2 class="font-display text-xl font-bold">Room by room</h2>
|
||||
{{if .Live}}<span class="text-sm text-[color:var(--ink)]/50">still under way</span>{{end}}
|
||||
</div>
|
||||
<ol class="runlog runlog-full">
|
||||
{{range .Lines}}
|
||||
<li class="runlog-line{{if .Hurt}} runlog-hurt{{end}}{{if .Good}} runlog-good{{end}}">
|
||||
<span class="runlog-emoji" aria-hidden="true">{{.Emoji}}</span>
|
||||
<span class="runlog-text">{{.Text}}</span>
|
||||
<span class="runlog-meta">{{if .Room}}{{.Room}} · {{end}}{{.When}}</span>
|
||||
</li>
|
||||
{{end}}
|
||||
</ol>
|
||||
{{if .Truncated}}
|
||||
<p class="mt-4 text-xs text-[color:var(--ink)]/45">Only the last {{len .Lines}} moments of this run are shown — it beat out more than the report keeps.</p>
|
||||
{{end}}
|
||||
<p class="mt-8 pt-6 border-t border-[color:var(--ink)]/10 text-sm italic text-theme-adventure font-semibold">
|
||||
Reporting from the realm, this is Pete.
|
||||
</p>
|
||||
</section>
|
||||
</article>
|
||||
{{end}}
|
||||
@@ -19,6 +19,13 @@
|
||||
|
||||
<div class="mt-8 rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 p-6 sm:p-8 shadow-pete">
|
||||
<p class="text-lg leading-relaxed text-[color:var(--ink)]/90 whitespace-pre-line">{{.Body}}</p>
|
||||
{{if .RunReportURL}}
|
||||
<!-- The way back to what actually happened. This dispatch is the ending of an
|
||||
expedition; the report is the expedition. -->
|
||||
<a href="{{.RunReportURL}}" class="mt-6 inline-flex items-center gap-2 rounded-2xl bg-theme-adventure text-white px-4 py-2.5 text-sm font-semibold shadow-pete hover:opacity-90 transition">
|
||||
<span aria-hidden="true">📜</span> Read the run, room by room <span aria-hidden="true">→</span>
|
||||
</a>
|
||||
{{end}}
|
||||
<p class="mt-8 pt-6 border-t border-[color:var(--ink)]/10 text-sm italic text-theme-adventure font-semibold">
|
||||
Reporting from the realm, this is Pete.
|
||||
</p>
|
||||
|
||||
@@ -186,6 +186,13 @@
|
||||
</li>
|
||||
{{end}}
|
||||
</ol>
|
||||
<!-- Only once the run is over: while it's still walking, this column IS the
|
||||
report, and a link to a second copy of it is just a way to lose the
|
||||
reader. Revealed in place when a run ends under an open tab. -->
|
||||
<a id="who-runlog-report" href="{{.RunLog.ReportURL}}"
|
||||
class="mt-4 inline-flex items-center gap-1.5 text-sm font-semibold text-theme-adventure hover:opacity-80 transition{{if not .RunLog.ReportURL}} hidden{{end}}">
|
||||
<span aria-hidden="true">📜</span> The full report <span aria-hidden="true">→</span>
|
||||
</a>
|
||||
</section>
|
||||
|
||||
{{if .HasHistory}}
|
||||
@@ -483,6 +490,19 @@
|
||||
});
|
||||
list.replaceChildren(frag);
|
||||
if (pinned) list.scrollTop = list.scrollHeight;
|
||||
|
||||
// The run can end between two polls, which is exactly the moment the report
|
||||
// becomes worth offering. href is set from the payload rather than built here
|
||||
// so the page never invents a URL for a run Pete won't serve.
|
||||
var report = document.getElementById('who-runlog-report');
|
||||
if (report) {
|
||||
if (log.ReportURL) {
|
||||
report.href = log.ReportURL;
|
||||
report.classList.remove('hidden');
|
||||
} else {
|
||||
report.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
}
|
||||
(function () {
|
||||
var list = document.getElementById('who-runlog');
|
||||
|
||||
Reference in New Issue
Block a user