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,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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user