adventure: tell the story of a run, not just how it ended
Pete only ever heard that an expedition happened once it was over — a zone cleared, a retreat, a death. The run itself 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. Beats arrive on their own channel, append-only and idempotent on (run_id, seq). They are the one thing gogobee pushes that is history rather than state, so they accumulate instead of replacing — and they stay off the dispatch queue so a chatty run can never spend the retry budget a death dispatch depends on. The run header is derived from the beats rather than pushed: a run whose start beat never arrived still gets a readable, unattributed log instead of being dropped for want of a name. An unknown beat kind renders as its own noun rather than 400ing. That is the same lesson the dispatch ingest learned the hard way, and the regression test covers the class, not the case. Claude-Session: https://claude.ai/code/session_012bxpQQJDjC1mTtLN3VVtBQ
This commit is contained in:
@@ -0,0 +1,413 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"pete/internal/storage"
|
||||
)
|
||||
|
||||
func postBeats(t *testing.T, s *Server, token string, beats ...storage.RunBeat) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
body, _ := json.Marshal(runBeatsPush{Beats: beats})
|
||||
req := httptest.NewRequest("POST", "/api/ingest/run", bytes.NewReader(body))
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
s.handleRunIngest(w, req)
|
||||
return w
|
||||
}
|
||||
|
||||
// startBeat is the beat that names a run. Everything downstream keys on run_id
|
||||
// alone, so this is the only one that has to carry identity.
|
||||
func startBeat(now int64) storage.RunBeat {
|
||||
return storage.RunBeat{
|
||||
RunID: "run-1", Seq: 1, Kind: "start", OccurredAt: now,
|
||||
Token: "tok-abc", Name: "Josie", Level: 14, Zone: "Crypt of Valdris", TotalRooms: 9,
|
||||
}
|
||||
}
|
||||
|
||||
// TestUnknownBeatKindIsStoredAndRendered is the regression for a whole class of
|
||||
// bug, not for one beat kind.
|
||||
//
|
||||
// The dispatch channel learned this the hard way: an unknown event_type used to
|
||||
// 400, which parked the queue row upstream and silently deleted a game event
|
||||
// that had actually happened. The beat channel is a second chance to make the
|
||||
// same mistake, and this is the test that stops it — gogobee must be able to
|
||||
// invent a beat kind on any Tuesday and have it show up as a plain line rather
|
||||
// than as a 400 and a hole in the log.
|
||||
func TestUnknownBeatKindIsStoredAndRendered(t *testing.T) {
|
||||
const token = "tok"
|
||||
s, _ := newAdvServer(t, token)
|
||||
now := time.Now().Unix()
|
||||
|
||||
if w := postBeats(t, s, token,
|
||||
startBeat(now),
|
||||
storage.RunBeat{RunID: "run-1", Seq: 2, Kind: "seance", OccurredAt: now + 5,
|
||||
Room: 2, TotalRooms: 9, Target: "a cold draught"},
|
||||
); w.Code != 200 {
|
||||
t.Fatalf("unknown beat kind rejected: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
v := runLogFor("tok-abc")
|
||||
if !v.Has {
|
||||
t.Fatal("no log built for a run that has two beats")
|
||||
}
|
||||
if len(v.Lines) != 2 {
|
||||
t.Fatalf("want 2 lines, got %d: %+v", len(v.Lines), v.Lines)
|
||||
}
|
||||
last := v.Lines[1]
|
||||
if last.Text != "seance — a cold draught" {
|
||||
t.Errorf("unknown kind rendered as %q; it should degrade to its own noun", last.Text)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBeatIngestRequiresIdentity — run_id and seq ARE the row. Without both
|
||||
// there is nothing for the re-send to collapse onto, so this is the one thing
|
||||
// the ingest is strict about.
|
||||
func TestBeatIngestRequiresIdentity(t *testing.T) {
|
||||
const token = "tok"
|
||||
s, _ := newAdvServer(t, token)
|
||||
now := time.Now().Unix()
|
||||
|
||||
if w := postBeats(t, s, token, storage.RunBeat{Seq: 1, Kind: "room", OccurredAt: now}); w.Code != 400 {
|
||||
t.Errorf("beat with no run_id: want 400, got %d", w.Code)
|
||||
}
|
||||
if w := postBeats(t, s, token,
|
||||
storage.RunBeat{RunID: "run-1", Kind: "room", OccurredAt: now}); w.Code != 400 {
|
||||
t.Errorf("beat with no seq: want 400, got %d", w.Code)
|
||||
}
|
||||
if w := postBeats(t, s, "wrong-token", startBeat(now)); w.Code != 401 {
|
||||
t.Errorf("unauthed beat: want 401, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBeatsAreIdempotentOnRunAndSeq. gogobee re-sends a batch whenever it
|
||||
// delivered it but failed to mark it locally, which is a normal outcome of a
|
||||
// crash between two writes — so a duplicate batch has to be free.
|
||||
func TestBeatsAreIdempotentOnRunAndSeq(t *testing.T) {
|
||||
const token = "tok"
|
||||
s, _ := newAdvServer(t, token)
|
||||
now := time.Now().Unix()
|
||||
|
||||
beats := []storage.RunBeat{
|
||||
startBeat(now),
|
||||
{RunID: "run-1", Seq: 2, Kind: "room", OccurredAt: now + 10, Room: 2, TotalRooms: 9, RoomKind: "exploration"},
|
||||
}
|
||||
for i := 0; i < 3; i++ {
|
||||
if w := postBeats(t, s, token, beats...); w.Code != 200 {
|
||||
t.Fatalf("push %d: %d %s", i, w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
stored, err := storage.RunBeats("run-1", 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(stored) != 2 {
|
||||
t.Fatalf("three identical pushes produced %d beats, want 2", len(stored))
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunHeaderIsDerivedAndSticky. The header is not pushed as its own object —
|
||||
// it is folded out of the beats. The forty beats after `start` carry no name and
|
||||
// no zone, and none of them may erase the one that did.
|
||||
func TestRunHeaderIsDerivedAndSticky(t *testing.T) {
|
||||
const token = "tok"
|
||||
s, _ := newAdvServer(t, token)
|
||||
now := time.Now().Unix()
|
||||
|
||||
postBeats(t, s, token, startBeat(now))
|
||||
postBeats(t, s, token,
|
||||
storage.RunBeat{RunID: "run-1", Seq: 2, Kind: "room", OccurredAt: now + 10, Room: 2, TotalRooms: 9},
|
||||
storage.RunBeat{RunID: "run-1", Seq: 3, Kind: "combat", OccurredAt: now + 20,
|
||||
Room: 2, TotalRooms: 9, Target: "Bone Chanter", Outcome: "won", Amount: 7, HP: 61, HPMax: 68},
|
||||
)
|
||||
|
||||
run, ok, err := storage.RunByID("run-1")
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("run header missing: ok=%v err=%v", ok, err)
|
||||
}
|
||||
if run.Name != "Josie" || run.Zone != "Crypt of Valdris" || run.Level != 14 {
|
||||
t.Errorf("later beats clobbered the start beat's identity: %+v", run)
|
||||
}
|
||||
if !run.Live() {
|
||||
t.Error("run with no end beat should still be live")
|
||||
}
|
||||
|
||||
// Now close it, then try to reopen it with a second, less specific end.
|
||||
postBeats(t, s, token,
|
||||
storage.RunBeat{RunID: "run-1", Seq: 4, Kind: "end", OccurredAt: now + 30, Outcome: "died"},
|
||||
storage.RunBeat{RunID: "run-1", Seq: 5, Kind: "end", OccurredAt: now + 31, Outcome: "abandoned"},
|
||||
)
|
||||
run, _, _ = storage.RunByID("run-1")
|
||||
if run.Live() {
|
||||
t.Error("run with an end beat should not be live")
|
||||
}
|
||||
if run.Outcome != "died" {
|
||||
t.Errorf("outcome = %q, want %q — the first, specific close must win", run.Outcome, "died")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunWithNoStartBeatStillHasALog. A start beat can be lost (retention on the
|
||||
// game box, an opt-out flipped mid-run, a batch that never made it). The run
|
||||
// that follows is unattributed, which is a reason not to hang it off an
|
||||
// adventurer page — not a reason to throw the log away.
|
||||
func TestRunWithNoStartBeatStillHasALog(t *testing.T) {
|
||||
const token = "tok"
|
||||
s, _ := newAdvServer(t, token)
|
||||
now := time.Now().Unix()
|
||||
|
||||
if w := postBeats(t, s, token,
|
||||
storage.RunBeat{RunID: "orphan", Seq: 7, Kind: "combat", OccurredAt: now,
|
||||
Room: 3, TotalRooms: 9, Target: "Gravewright", Outcome: "won"},
|
||||
); w.Code != 200 {
|
||||
t.Fatalf("orphan beat rejected: %d", w.Code)
|
||||
}
|
||||
run, ok, err := storage.RunByID("orphan")
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("orphan run has no header: ok=%v err=%v", ok, err)
|
||||
}
|
||||
if run.Token != "" {
|
||||
t.Errorf("orphan run claimed token %q", run.Token)
|
||||
}
|
||||
// ...and it is unreachable from any adventurer page, which is the point.
|
||||
if v := runLogFor(""); v.Has {
|
||||
t.Error("empty token resolved to a log")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFinishedRunAgesOffThePage. The adventurer page is about now. A run that
|
||||
// ended days ago sitting under a live map reads as the live one, which is worse
|
||||
// than showing nothing — the rows stay in the database for the dispatch that
|
||||
// links to them.
|
||||
func TestFinishedRunAgesOffThePage(t *testing.T) {
|
||||
const token = "tok"
|
||||
s, _ := newAdvServer(t, token)
|
||||
old := time.Now().Add(-24 * time.Hour).Unix()
|
||||
|
||||
postBeats(t, s, token,
|
||||
storage.RunBeat{RunID: "run-old", Seq: 1, Kind: "start", OccurredAt: old,
|
||||
Token: "tok-abc", Name: "Josie", Zone: "Underforge", TotalRooms: 8},
|
||||
storage.RunBeat{RunID: "run-old", Seq: 2, Kind: "end", OccurredAt: old + 600, Outcome: "cleared"},
|
||||
)
|
||||
if v := runLogFor("tok-abc"); v.Has {
|
||||
t.Error("a run that ended a day ago is still on the page")
|
||||
}
|
||||
if beats, _ := storage.RunBeats("run-old", 0); len(beats) != 2 {
|
||||
t.Errorf("aged-off run lost its stored beats: %d", len(beats))
|
||||
}
|
||||
}
|
||||
|
||||
// TestLiveRunBeatsAFinishedOne is the border-crossing case, and it is the reason
|
||||
// the page picks a run by liveness before recency.
|
||||
//
|
||||
// A multi-region expedition closes one run and opens the next in the same
|
||||
// breath: the outgoing `end` beat and the incoming `start` beat carry the same
|
||||
// second, and which one has the later updated_at is a coin flip. Losing it means
|
||||
// the page shows the log of a region the party has already walked out of, with a
|
||||
// "cleared" chip on it, while they are three rooms into the next one.
|
||||
func TestLiveRunBeatsAFinishedOne(t *testing.T) {
|
||||
const token = "tok"
|
||||
s, _ := newAdvServer(t, token)
|
||||
now := time.Now().Unix()
|
||||
|
||||
postBeats(t, s, token,
|
||||
storage.RunBeat{RunID: "region-1", Seq: 1, Kind: "start", OccurredAt: now - 60,
|
||||
Token: "tok-abc", Name: "Josie", Zone: "The Slagworks", TotalRooms: 6},
|
||||
// The crossing and the next region's opening land on the same clock tick.
|
||||
storage.RunBeat{RunID: "region-1", Seq: 2, Kind: "end", OccurredAt: now, Outcome: "cleared"},
|
||||
storage.RunBeat{RunID: "region-2", Seq: 1, Kind: "start", OccurredAt: now,
|
||||
Token: "tok-abc", Name: "Josie", Zone: "The Deep Bellows", TotalRooms: 7},
|
||||
)
|
||||
|
||||
run, ok, err := storage.LatestRunForToken("tok-abc")
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("no run resolved: ok=%v err=%v", ok, err)
|
||||
}
|
||||
if run.RunID != "region-2" {
|
||||
t.Fatalf("page picked %q (%s); the live run must win over the finished one",
|
||||
run.RunID, run.Outcome)
|
||||
}
|
||||
if v := runLogFor("tok-abc"); !v.Live || v.Zone != "The Deep Bellows" {
|
||||
t.Errorf("log = %q live:%v, want the region they are actually in", v.Zone, v.Live)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunLogShowsTheTail. A log is read for what just happened. A party deep
|
||||
// into a long expedition must not be showing its first morning.
|
||||
func TestRunLogShowsTheTail(t *testing.T) {
|
||||
const token = "tok"
|
||||
s, _ := newAdvServer(t, token)
|
||||
now := time.Now().Unix()
|
||||
|
||||
beats := []storage.RunBeat{startBeat(now)}
|
||||
for i := 2; i <= runLogCap+20; i++ {
|
||||
beats = append(beats, storage.RunBeat{
|
||||
RunID: "run-1", Seq: int64(i), Kind: "room", OccurredAt: now + int64(i),
|
||||
Room: i, TotalRooms: 400, RoomKind: "exploration",
|
||||
})
|
||||
}
|
||||
if w := postBeats(t, s, token, beats...); w.Code != 200 {
|
||||
t.Fatalf("push: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
v := runLogFor("tok-abc")
|
||||
if len(v.Lines) != runLogCap {
|
||||
t.Fatalf("want %d lines, got %d", runLogCap, len(v.Lines))
|
||||
}
|
||||
// Oldest-first within the tail, and the tail ends at the newest beat.
|
||||
if got := v.Lines[len(v.Lines)-1].Room; got != "80/400" {
|
||||
t.Errorf("last line room = %q, want the newest beat", got)
|
||||
}
|
||||
if v.Rooms != "80 / 400" {
|
||||
t.Errorf("header room = %q", v.Rooms)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOffTheBoardShipsNoLog. Coming off the board means opted out or removed —
|
||||
// finishing a run leaves an adventurer on it as idle. So the branch that answers
|
||||
// "this token is no longer listed" must not hand back a room-by-room account of
|
||||
// where its owner is; the page 404s in the same situation, and an API that is
|
||||
// more forthcoming than the page it backs is a leak with extra steps.
|
||||
func TestOffTheBoardShipsNoLog(t *testing.T) {
|
||||
const token = "tok"
|
||||
s, _ := newAdvServer(t, token)
|
||||
now := time.Now().Unix()
|
||||
|
||||
postBeats(t, s, token, startBeat(now),
|
||||
storage.RunBeat{RunID: "run-1", Seq: 2, Kind: "combat", OccurredAt: now + 10,
|
||||
Room: 2, TotalRooms: 9, Target: "Bone Chanter", Outcome: "won"})
|
||||
|
||||
// Never pushed a roster, so no token is on the board — the opted-out case.
|
||||
req := httptest.NewRequest("GET", "/api/adventure/who/tok-abc", nil)
|
||||
req.SetPathValue("token", "tok-abc")
|
||||
w := httptest.NewRecorder()
|
||||
s.handleAdventureWhoAPI(w, req)
|
||||
|
||||
var got map[string]any
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
|
||||
t.Fatalf("decode: %v (%s)", err, w.Body.String())
|
||||
}
|
||||
if got["live"] != false {
|
||||
t.Errorf("live = %v, want false", got["live"])
|
||||
}
|
||||
if _, leaked := got["run_log"]; leaked {
|
||||
t.Errorf("an off-the-board token was handed its run log: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenderRunBeatCarriesTheNouns pins the shape of the lines: they are built
|
||||
// out of the beat and nothing else. A line that reads better than the facts
|
||||
// support is a line lying about a run somebody actually walked.
|
||||
func TestRenderRunBeatCarriesTheNouns(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
beat storage.RunBeat
|
||||
want string
|
||||
hurt bool
|
||||
good bool
|
||||
}{
|
||||
{"kill", storage.RunBeat{Kind: "combat", Outcome: "won", Target: "Bone Chanter",
|
||||
Amount: 7, HP: 61, HPMax: 68}, "Bone Chanter down — took 7 · 61/68 HP", false, true},
|
||||
{"clean kill", storage.RunBeat{Kind: "combat", Outcome: "won", Target: "Rat",
|
||||
HP: 68, HPMax: 68}, "Rat down — untouched · 68/68 HP", false, true},
|
||||
{"death", storage.RunBeat{Kind: "combat", Outcome: "down", Target: "The Rotmother",
|
||||
HP: 0, HPMax: 68}, "Fell to The Rotmother · 0/68 HP", true, false},
|
||||
{"timeout", storage.RunBeat{Kind: "combat", Outcome: "retreat", Target: "Aldric"},
|
||||
"Outlasted by Aldric — withdrew", true, false},
|
||||
{"trap", storage.RunBeat{Kind: "trap", Amount: 12, HP: 40, HPMax: 68},
|
||||
"Trap sprung — 12 damage · 40/68 HP", true, false},
|
||||
{"trap avoided", storage.RunBeat{Kind: "trap"}, "Trap — stepped over it", false, true},
|
||||
{"treasure", storage.RunBeat{Kind: "treasure", Target: "Coin Pouch", Outcome: "cache"},
|
||||
"Found Coin Pouch in a cache", false, true},
|
||||
{"haul", storage.RunBeat{Kind: "haul", Amount: 6, Target: "Ironcap", Count: 3},
|
||||
"Gathered 6 — mostly Ironcap (3 kinds)", false, false},
|
||||
{"region", storage.RunBeat{Kind: "region", Region: "The Shallows", Target: "The Deep"},
|
||||
"Left The Shallows for The Deep", false, false},
|
||||
{"cleared", storage.RunBeat{Kind: "end", Outcome: "cleared"}, "Run complete", false, true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
l, ok := renderRunBeat(c.beat)
|
||||
if !ok {
|
||||
t.Fatal("beat produced no line")
|
||||
}
|
||||
if l.Text != c.want {
|
||||
t.Errorf("text = %q, want %q", l.Text, c.want)
|
||||
}
|
||||
if l.Hurt != c.hurt || l.Good != c.good {
|
||||
t.Errorf("tint = hurt:%v good:%v, want hurt:%v good:%v", l.Hurt, l.Good, c.hurt, c.good)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// A haul of nothing is not a beat. gogobee already skips it, but the renderer
|
||||
// is the second line of defence against a column of "Gathered 0".
|
||||
if _, ok := renderRunBeat(storage.RunBeat{Kind: "haul"}); ok {
|
||||
t.Error("empty haul produced a line")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHPTailOnlyWhenReal. A zero max means gogobee didn't send a pair, not that
|
||||
// the adventurer has no health — and "0/0 HP" on a winning line reads as a death.
|
||||
func TestHPTailOnlyWhenReal(t *testing.T) {
|
||||
l, _ := renderRunBeat(storage.RunBeat{Kind: "combat", Outcome: "won", Target: "Rat"})
|
||||
if got := l.Text; got != "Rat down — untouched" {
|
||||
t.Errorf("text = %q; a missing HP pair must not be drawn", got)
|
||||
}
|
||||
}
|
||||
@@ -231,6 +231,11 @@ 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.
|
||||
mux.HandleFunc("POST /api/ingest/run", s.handleRunIngest)
|
||||
|
||||
// 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.
|
||||
//
|
||||
|
||||
@@ -2831,4 +2831,33 @@ html[data-room] .pete-felt {
|
||||
.siege-fill { transition: none; }
|
||||
.siege-live .siege-fill { animation: none; }
|
||||
}
|
||||
|
||||
/* The expedition liveblog: a column of what happened, room by room, under the
|
||||
map that says where. Deliberately plainer than the rest of the page — this
|
||||
is a log, and forty decorated cards would be unreadable at the length a real
|
||||
run reaches. The rail down the left is what makes it read as one journey
|
||||
rather than as a list of unrelated lines. */
|
||||
.runlog {
|
||||
list-style: none; margin: 0; padding: 0 0 0 1.1rem;
|
||||
border-left: 2px solid color-mix(in srgb, var(--ink) 12%, transparent);
|
||||
display: flex; flex-direction: column; gap: 0.55rem;
|
||||
max-height: 26rem; overflow-y: auto;
|
||||
}
|
||||
.runlog-line {
|
||||
display: grid; grid-template-columns: 1.4rem 1fr auto;
|
||||
align-items: baseline; gap: 0.5rem;
|
||||
font-size: 13px; line-height: 1.4;
|
||||
color: color-mix(in srgb, var(--ink) 78%, transparent);
|
||||
}
|
||||
.runlog-emoji { font-size: 14px; }
|
||||
.runlog-text { min-width: 0; overflow-wrap: anywhere; }
|
||||
.runlog-meta {
|
||||
font-size: 11px; font-variant-numeric: tabular-nums; white-space: nowrap;
|
||||
color: color-mix(in srgb, var(--ink) 42%, transparent);
|
||||
}
|
||||
/* Two tints and no more. A log where every second line is coloured is a log
|
||||
with no emphasis at all; these mark the beats a reader is scanning for —
|
||||
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)); }
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -167,6 +167,27 @@
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
<!-- The liveblog. The map above says where they are; this says what happened
|
||||
there. Sits outside the HasDetail gate on purpose: the log arrives on its
|
||||
own channel and can outlive the snapshot that drew the map — a run that
|
||||
just ended still has a story after the board has moved the mark back to
|
||||
town. Rebuilt in place by the same poll that moves the HP bar. -->
|
||||
<section id="who-runlog-section" class="mt-6 rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 p-6 shadow-pete{{if not .RunLog.Has}} hidden{{end}}">
|
||||
<div class="flex items-baseline justify-between mb-4 gap-3">
|
||||
<h2 class="font-display text-xl font-bold">The run</h2>
|
||||
<span id="who-runlog-status" class="text-sm text-[color:var(--ink)]/50">{{if .RunLog.Live}}under way{{else if .RunLog.Outcome}}{{.RunLog.Outcome}}{{else}}finished{{end}}{{if .RunLog.Rooms}} · room {{.RunLog.Rooms}}{{end}}</span>
|
||||
</div>
|
||||
<ol id="who-runlog" class="runlog">
|
||||
{{range .RunLog.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>
|
||||
</section>
|
||||
|
||||
{{if .HasHistory}}
|
||||
<!-- The record. Public, like the dispatches it's counted from — this is the
|
||||
same information the /adventure feed already printed, only as numbers
|
||||
@@ -421,6 +442,53 @@
|
||||
|
||||
function txt(id, v) { var el = document.getElementById(id); if (el && v != null) el.textContent = v; }
|
||||
|
||||
// The liveblog is rebuilt from the JSON rather than patched: beats only ever
|
||||
// arrive at the end, but a run can also END between polls, which changes the
|
||||
// header and can drop the section entirely. Redrawing forty short lines is
|
||||
// cheaper than getting the incremental case wrong.
|
||||
//
|
||||
// Built with textContent throughout — a beat carries a monster name that came
|
||||
// off the wire, and innerHTML here would make the game box able to inject
|
||||
// markup into a public page.
|
||||
function drawRunLog(log) {
|
||||
var section = document.getElementById('who-runlog-section');
|
||||
var list = document.getElementById('who-runlog');
|
||||
if (!section || !list) return;
|
||||
if (!log || !log.Has || !log.Lines || !log.Lines.length) {
|
||||
section.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
// Only scroll to the newest beat when the reader was already at the bottom.
|
||||
// Yanking them back down while they're reading four rooms ago is worse than
|
||||
// making them scroll.
|
||||
var pinned = list.scrollHeight - list.scrollTop - list.clientHeight < 24;
|
||||
|
||||
section.classList.remove('hidden');
|
||||
txt('who-runlog-status',
|
||||
(log.Live ? 'under way' : (log.Outcome || 'finished')) +
|
||||
(log.Rooms ? ' · room ' + log.Rooms : ''));
|
||||
|
||||
var frag = document.createDocumentFragment();
|
||||
log.Lines.forEach(function (ln) {
|
||||
var li = document.createElement('li');
|
||||
li.className = 'runlog-line' + (ln.Hurt ? ' runlog-hurt' : '') + (ln.Good ? ' runlog-good' : '');
|
||||
var e = document.createElement('span');
|
||||
e.className = 'runlog-emoji'; e.setAttribute('aria-hidden', 'true'); e.textContent = ln.Emoji || '';
|
||||
var t = document.createElement('span');
|
||||
t.className = 'runlog-text'; t.textContent = ln.Text || '';
|
||||
var m = document.createElement('span');
|
||||
m.className = 'runlog-meta'; m.textContent = (ln.Room ? ln.Room + ' · ' : '') + (ln.When || '');
|
||||
li.appendChild(e); li.appendChild(t); li.appendChild(m);
|
||||
frag.appendChild(li);
|
||||
});
|
||||
list.replaceChildren(frag);
|
||||
if (pinned) list.scrollTop = list.scrollHeight;
|
||||
}
|
||||
(function () {
|
||||
var list = document.getElementById('who-runlog');
|
||||
if (list) list.scrollTop = list.scrollHeight;
|
||||
})();
|
||||
|
||||
var timer = null;
|
||||
function refresh() {
|
||||
fetch('/api/adventure/who/' + encodeURIComponent(token), { headers: { 'Accept': 'application/json' } })
|
||||
@@ -430,7 +498,10 @@
|
||||
if (!data.live) {
|
||||
// Off the board entirely (expedition ended, opted out): nothing more to
|
||||
// poll for, so stop the timer rather than hammer a token that's gone.
|
||||
// The log goes with them: off the board is off the page, and the API
|
||||
// sends none in this branch.
|
||||
txt('who-where', 'Back in town');
|
||||
drawRunLog(null);
|
||||
if (timer) clearInterval(timer);
|
||||
return;
|
||||
}
|
||||
@@ -452,6 +523,7 @@
|
||||
} else {
|
||||
txt('who-where', 'In town' + (mark.Idle ? ' · ' + mark.Idle : ''));
|
||||
}
|
||||
drawRunLog(data.run_log);
|
||||
})
|
||||
.catch(function () { /* transient — next tick will do */ });
|
||||
}
|
||||
|
||||
+21
-4
@@ -83,8 +83,13 @@ type whoPage struct {
|
||||
Detail whoDetail
|
||||
Abilities []abilityRow
|
||||
MapView *mapView // laid-out dungeon map, nil when not on a run or no graph
|
||||
HasSelf bool
|
||||
Self storage.PlayerDetail
|
||||
// RunLog is the liveblog for the run the map is showing: what happened in
|
||||
// those rooms, in order. Deliberately independent of MapView — the map rides
|
||||
// the roster snapshot and the log rides the beat channel, so either can be
|
||||
// present without the other and the page must not assume they arrive together.
|
||||
RunLog RunLogView
|
||||
HasSelf bool
|
||||
Self storage.PlayerDetail
|
||||
// The private panels, wrapped so a row knows where it is sitting. Bond state
|
||||
// only means something on a worn item — see itemRow.
|
||||
Worn []itemRow
|
||||
@@ -197,6 +202,7 @@ func (s *Server) handleAdventureWho(w http.ResponseWriter, r *http.Request) {
|
||||
page.Abilities = abil
|
||||
page.MapView = buildMapView(d.Map)
|
||||
}
|
||||
page.RunLog = runLogFor(token)
|
||||
|
||||
// History: one read feeds both the trophy case and the trail. Keyed on the
|
||||
// character *name* rather than the page token, because that is what a fact
|
||||
@@ -268,8 +274,15 @@ func (s *Server) handleAdventureWhoAPI(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
if !ok {
|
||||
// Gone from the board (expedition ended, opted out): tell the poller so it
|
||||
// can stop, rather than 404-ing an open tab into an error.
|
||||
// Gone from the board: tell the poller so it can stop, rather than 404-ing
|
||||
// an open tab into an error.
|
||||
//
|
||||
// No run log here, deliberately. Finishing a run does NOT take an
|
||||
// adventurer off the board — they stay on it as idle, and their last log
|
||||
// keeps rendering through this endpoint's normal path. What DOES take them
|
||||
// off it is opting out or being removed, and shipping a room-by-room
|
||||
// account of where somebody is from the one branch that means "this player
|
||||
// asked not to be listed" would make the API say what the page refuses to.
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"live": false})
|
||||
return
|
||||
}
|
||||
@@ -280,6 +293,10 @@ func (s *Server) handleAdventureWhoAPI(w http.ResponseWriter, r *http.Request) {
|
||||
"has_detail": hasDetail,
|
||||
"detail": d,
|
||||
"abilities": abil,
|
||||
// The liveblog rides the sheet's poll rather than a second timer: the two
|
||||
// move on the same 2-minute push and a separate request would just be a
|
||||
// second way to be out of step with the map beside it.
|
||||
"run_log": runLogFor(token),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user