Files
Pete/internal/web/realm_test.go
T
prosolis 1dfd3ac9fb adventure: give the realm a map, a board and a history
Everything Pete published so far was either the present moment (the roster,
the Siege bar) or one thing that happened (a dispatch, a run report). None of
it said what this place IS — how many zones there are, which are harder, or
whether anybody has ever actually beaten them.

Three pages off one snapshot, because they are one question and every number
on all three comes off the same scan of the same run history upstream:

  /adventure/realm      the world, in difficulty order, with who is inside it
  /adventure/standings  the board, plus Pete's own duel record
  /adventure/firsts     the hall of firsts, as a dated history

The map is deliberately not who_map.go's layout engine. That lays out a graph,
and the realm has no edges — zones aren't connected, you pick one and go.
Forcing a graph onto a set would imply a topology the game doesn't have. What
it has is an order, so tier bands are what get drawn.

A zone nobody has ever cleared looks different rather than saying so in small
text, and that styling keys off the clear count, never off whether a name is
attached — otherwise an opt-out would silently redraw a conquered place as one
nobody has come out of.

The per-kind first dot goes through a custom property instead of a
.firsts-entry-zone::before rule: input.css is in Tailwind's content glob, so a
hand-written class survives the purge only if its literal name can be lifted
out of the file, and a name glued to ::before cannot. It failed silently.

Claude-Session: https://claude.ai/code/session_012bxpQQJDjC1mTtLN3VVtBQ
2026-07-24 17:54:49 -07:00

363 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package web
import (
"bytes"
"encoding/json"
"net/http/httptest"
"testing"
"time"
"pete/internal/storage"
)
func postRealm(t *testing.T, s *Server, token string, push realmPush) *httptest.ResponseRecorder {
t.Helper()
body, _ := json.Marshal(push)
req := httptest.NewRequest("POST", "/api/ingest/realm", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
w := httptest.NewRecorder()
s.handleRealmIngest(w, req)
return w
}
func zone(id, display string, tier, clears, clearers int) storage.RealmZone {
return storage.RealmZone{
ID: id, Display: display, Tier: tier,
LevelMin: tier * 3, LevelMax: tier*3 + 3,
Clears: clears, Clearers: clearers,
}
}
// TestRealmReplacesNeverMerges is the realm's core contract and it is the same
// one the board and the war room have: gogobee sends the whole thing, Pete's
// copy becomes it. Everything on these pages is a *derived* answer recomputed
// upstream — a clear count, a first-clearer, who is inside — so a merge would
// let a correction upstream leave a wrong number here permanently, and an
// occupant who came home would never leave the map.
func TestRealmReplacesNeverMerges(t *testing.T) {
s, _ := newAdvServer(t, "tok")
now := time.Now().Unix()
first := zone("warren", "Goblin Warren", 1, 4, 2)
first.Occupants = []storage.RealmOccupant{{Token: "t1", Name: "Josie", Level: 9, Day: 2}}
if w := postRealm(t, s, "tok", realmPush{SnapshotAt: now, Realm: storage.Realm{
Zones: []storage.RealmZone{first, zone("vault", "Sunken Vault", 2, 0, 0)},
Standings: []storage.RealmStanding{{Token: "t1", Name: "Josie", Level: 9, Clears: 4}},
Firsts: []storage.RealmFirst{{Kind: "zone", Target: "warren", Display: "Goblin Warren", AtUnix: now - 86400}},
}}); w.Code != 200 {
t.Fatalf("first push = %d, want 200", w.Code)
}
// Josie comes home, the Vault gets beaten, and the second zone drops out of
// the push entirely (say it was retired upstream).
if w := postRealm(t, s, "tok", realmPush{SnapshotAt: now + 600, Realm: storage.Realm{
Zones: []storage.RealmZone{zone("warren", "Goblin Warren", 1, 5, 2)},
Standings: []storage.RealmStanding{{Token: "t1", Name: "Josie", Level: 10, Clears: 5}},
}}); w.Code != 200 {
t.Fatalf("second push = %d, want 200", w.Code)
}
v := s.realm()
if v.ZoneCount != 1 {
t.Fatalf("zone count = %d, want 1 — a dropped zone survived the swap", v.ZoneCount)
}
if v.OutThere != 0 {
t.Errorf("out-there = %d, want 0 — an occupant who came home is still on the map", v.OutThere)
}
if got := v.Tiers[0].Zones[0].Clears; got != 5 {
t.Errorf("clears = %d, want 5 — the count didn't follow the snapshot", got)
}
if fv := s.firsts(); fv.Total != 0 {
t.Errorf("firsts total = %d, want 0 — the ledger didn't follow the snapshot", fv.Total)
}
if sv := s.standings(); len(sv.Rows) != 1 || sv.Rows[0].Level != 10 {
t.Errorf("standings didn't follow the snapshot: %+v", sv.Rows)
}
}
// TestAnonymisedFirstClearIsNotAnUnbeatenZone is the one that matters most on
// this page.
//
// gogobee anonymises an opted-out first-clearer rather than deleting the claim:
// the zone HAS been beaten and the clear counts still add up, there is just no
// name on it. If Pete keyed the ominous never-beaten styling off "is there a
// name" instead of off "are there any clears", an opt-out would silently rewrite
// the history of the realm — a place somebody conquered would be drawn as a
// place nobody has ever come out of.
func TestAnonymisedFirstClearIsNotAnUnbeatenZone(t *testing.T) {
s, _ := newAdvServer(t, "tok")
now := time.Now().Unix()
beaten := zone("vault", "Sunken Vault", 2, 3, 1) // cleared, but no name on it
beaten.FirstClearAt = now - 86400
untouched := zone("abyss", "Abyss Portal", 5, 0, 0)
named := zone("warren", "Goblin Warren", 1, 2, 1)
named.FirstClearBy, named.FirstClearToken = "Josie", "t1"
if w := postRealm(t, s, "tok", realmPush{SnapshotAt: now, Realm: storage.Realm{
Zones: []storage.RealmZone{named, beaten, untouched},
}}); w.Code != 200 {
t.Fatalf("push = %d, want 200", w.Code)
}
byID := map[string]RealmZoneView{}
for _, tier := range s.realm().Tiers {
for _, z := range tier.Zones {
byID[z.ID] = z
}
}
if byID["vault"].Unbeaten {
t.Error("an anonymised clear drew as never-beaten — an opt-out rewrote the realm's history")
}
if !byID["vault"].Cleared {
t.Error("a zone with clears > 0 did not read as cleared")
}
if !byID["abyss"].Unbeaten {
t.Error("a zone with no clears at all did not read as unbeaten — the ominous state is the point")
}
if byID["warren"].Unbeaten {
t.Error("a named clear drew as never-beaten")
}
v := s.realm()
if v.ClearedZones != 2 || v.Unbeaten != 1 {
t.Errorf("header totals = %d cleared / %d unbeaten, want 2/1", v.ClearedZones, v.Unbeaten)
}
}
// TestRealmStaleWhenTheWireGoesQuiet. The realm is pushed every ten minutes
// rather than every two, so its staleness window is proportionally wider — but
// it still has to exist. An occupant list that stopped updating an hour ago must
// not keep claiming somebody is standing in a dungeon.
func TestRealmStaleWhenTheWireGoesQuiet(t *testing.T) {
s, _ := newAdvServer(t, "tok")
old := time.Now().Add(-2 * time.Hour).Unix()
if w := postRealm(t, s, "tok", realmPush{SnapshotAt: old, Realm: storage.Realm{
Zones: []storage.RealmZone{zone("warren", "Goblin Warren", 1, 1, 1)},
}}); w.Code != 200 {
t.Fatalf("push = %d, want 200", w.Code)
}
v := s.realm()
if !v.Known {
t.Fatal("a pushed realm reads as never-pushed")
}
if !v.Stale {
t.Error("a two-hour-old realm claims to be live")
}
// All three pages share one snapshot read, so they must agree about its age.
if !s.standings().Stale || !s.firsts().Stale {
t.Error("the three realm pages disagree about how old the realm is")
}
}
// TestUnpushedRealmIsNotAnEmptyRealm. "gogobee has never pushed" and "gogobee
// pushed a realm with nothing in it" are different states and the pages say
// different things about them — the first is Pete admitting he has no survey,
// the second is a real answer about a quiet realm.
func TestUnpushedRealmIsNotAnEmptyRealm(t *testing.T) {
s, _ := newAdvServer(t, "tok")
if v := s.realm(); v.Known {
t.Error("an unpushed realm claims to be known")
}
if v := s.standings(); v.Known {
t.Error("unpushed standings claim to be known")
}
if w := postRealm(t, s, "tok", realmPush{SnapshotAt: time.Now().Unix(), Realm: storage.Realm{}}); w.Code != 200 {
t.Fatalf("empty push = %d, want 200", w.Code)
}
v := s.realm()
if !v.Known {
t.Error("an empty-but-pushed realm reads as never-pushed")
}
if v.ZoneCount != 0 {
t.Errorf("zone count = %d, want 0", v.ZoneCount)
}
}
// TestRealmIngestRejectsNamelessRows. A nameless row renders as a blank line on
// a public page. gogobee already refuses to send one (it skips a character with
// no name rather than falling back to a Matrix handle), so this is the wire
// refusing to be the thing that puts a hole in the page.
func TestRealmIngestRejectsNamelessRows(t *testing.T) {
s, _ := newAdvServer(t, "tok")
now := time.Now().Unix()
nameless := zone("warren", "Goblin Warren", 1, 1, 1)
nameless.Occupants = []storage.RealmOccupant{{Token: "t1", Name: ""}}
if w := postRealm(t, s, "tok", realmPush{SnapshotAt: now, Realm: storage.Realm{
Zones: []storage.RealmZone{nameless},
}}); w.Code != 400 {
t.Errorf("nameless occupant = %d, want 400", w.Code)
}
if w := postRealm(t, s, "tok", realmPush{SnapshotAt: now, Realm: storage.Realm{
Standings: []storage.RealmStanding{{Token: "t1", Name: ""}},
}}); w.Code != 400 {
t.Errorf("nameless standing = %d, want 400", w.Code)
}
if w := postRealm(t, s, "tok", realmPush{SnapshotAt: now, Realm: storage.Realm{
Zones: []storage.RealmZone{{ID: "", Display: "Nowhere"}},
}}); w.Code != 400 {
t.Errorf("idless zone = %d, want 400", w.Code)
}
}
// TestUnknownFirstKindStillGetsIntoTheHall. The ledger is open-ended: gogobee
// claims a realm-first on (kind, target) and nothing stops a third kind shipping
// later. A first Pete has no words for is still a thing that happened exactly
// once, so it renders with a generic label and its raw target as its name — the
// same degrade-don't-drop rule W0 settled on for an untemplated event_type. A
// missing display is repaired at ingest rather than rejected.
func TestUnknownFirstKindStillGetsIntoTheHall(t *testing.T) {
s, _ := newAdvServer(t, "tok")
now := time.Now().Unix()
if w := postRealm(t, s, "tok", realmPush{SnapshotAt: now, Realm: storage.Realm{
Firsts: []storage.RealmFirst{
{Kind: "zone", Target: "warren", Display: "Goblin Warren", Holder: "Josie", Token: "t1", AtUnix: now - 86400},
{Kind: "hat", Target: "very_big_hat", AtUnix: now - 3600}, // no display: repaired, not rejected
},
}}); w.Code != 200 {
t.Fatalf("push = %d, want 200", w.Code)
}
v := s.firsts()
if v.Total != 2 {
t.Fatalf("hall has %d entries, want 2 — an unknown kind was dropped", v.Total)
}
var hat *RealmFirstView
for i := range v.Years {
for j := range v.Years[i].Firsts {
if v.Years[i].Firsts[j].Kind == "hat" {
hat = &v.Years[i].Firsts[j]
}
}
}
if hat == nil {
t.Fatal("the unknown-kind first is not in any year")
}
if hat.Display != "very_big_hat" {
t.Errorf("display = %q, want the raw target — a blank row is worse than an ugly one", hat.Display)
}
if hat.Label == "" {
t.Error("an unknown kind got no label at all")
}
}
// TestFirstsAreNewestYearFirstButChronologicalWithinAYear. A history book that
// opens on the oldest page is an archive; this is meant to read as "look what
// has been happening". Within a year it stays forward-ordered, the way a year
// did. An undated entry sorts to the bottom — it is real, but it is not news.
func TestFirstsAreNewestYearFirstButChronologicalWithinAYear(t *testing.T) {
s, _ := newAdvServer(t, "tok")
y2025 := time.Date(2025, 6, 1, 0, 0, 0, 0, time.UTC).Unix()
y2026a := time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC).Unix()
y2026b := time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC).Unix()
// Pushed oldest-first, which is how gogobee sends it.
if w := postRealm(t, s, "tok", realmPush{SnapshotAt: time.Now().Unix(), Realm: storage.Realm{
Firsts: []storage.RealmFirst{
{Kind: "zone", Target: "undated", Display: "Somewhere", AtUnix: 0},
{Kind: "zone", Target: "a", Display: "First Place", AtUnix: y2025},
{Kind: "zone", Target: "b", Display: "Second Place", AtUnix: y2026a},
{Kind: "zone", Target: "c", Display: "Third Place", AtUnix: y2026b},
},
}}); w.Code != 200 {
t.Fatalf("push = %d, want 200", w.Code)
}
v := s.firsts()
if len(v.Years) != 3 {
t.Fatalf("got %d year groups, want 3 (2026, 2025, undated)", len(v.Years))
}
if v.Years[0].Year != 2026 || v.Years[1].Year != 2025 || v.Years[2].Year != 0 {
t.Fatalf("year order = %d, %d, %d; want 2026, 2025, 0",
v.Years[0].Year, v.Years[1].Year, v.Years[2].Year)
}
if got := v.Years[0].Firsts; got[0].Display != "Second Place" || got[1].Display != "Third Place" {
t.Errorf("within 2026 the order is %q then %q; want chronological", got[0].Display, got[1].Display)
}
}
// TestStandingsKeepGogobeesRank. The ordering is a statement about what the game
// values ("deepest tier beaten, then how much of the realm you have beaten") and
// the game is entitled to make it. Pete renumbers nothing — a board that
// re-sorted on a column Pete happened to find interesting would disagree with
// the game about who is ahead.
func TestStandingsKeepGogobeesRank(t *testing.T) {
s, _ := newAdvServer(t, "tok")
if w := postRealm(t, s, "tok", realmPush{SnapshotAt: time.Now().Unix(), Realm: storage.Realm{
Standings: []storage.RealmStanding{
{Token: "t1", Name: "Josie", Level: 14, DeepestTier: 5, Zones: 3, Clears: 9},
// Higher level and more clears, but shallower — and gogobee put them
// second, so second is where they render.
{Token: "t2", Name: "Quack", Level: 20, DeepestTier: 3, Zones: 8, Clears: 40},
},
}}); w.Code != 200 {
t.Fatalf("push = %d, want 200", w.Code)
}
rows := s.standings().Rows
if len(rows) != 2 {
t.Fatalf("got %d rows, want 2", len(rows))
}
if rows[0].Name != "Josie" || rows[0].Rank != 1 {
t.Errorf("rank 1 = %q (rank field %d), want Josie/1 — Pete re-sorted the game's board",
rows[0].Name, rows[0].Rank)
}
if rows[1].Rank != 2 {
t.Errorf("second row has rank %d, want 2", rows[1].Rank)
}
}
// TestPeteHasNoRecordUntilHeFilesOne. The pete_duel_win/loss templates have
// existed in the renderer since before anything emitted them, so the record
// reads zero-zero today. Zero-zero has to render as "no bouts yet" and not as a
// 0% win rate, which would be a claim about bouts that never happened.
func TestPeteHasNoRecordUntilHeFilesOne(t *testing.T) {
s, _ := newAdvServer(t, "tok")
if w := postRealm(t, s, "tok", realmPush{SnapshotAt: time.Now().Unix(), Realm: storage.Realm{}}); w.Code != 200 {
t.Fatalf("push = %d, want 200", w.Code)
}
v := s.standings()
if v.PeteFought {
t.Error("Pete claims a duel record with no duel dispatches filed")
}
if v.PeteWins != 0 || v.PeteLosses != 0 {
t.Errorf("record = %d-%d, want 0-0", v.PeteWins, v.PeteLosses)
}
}
// TestLevelBandReadsLikeWords. "levels 55" reads as a typo and "levels 00" as
// a bug; neither is a thing to print on a page about a place.
func TestLevelBandReadsLikeWords(t *testing.T) {
cases := []struct {
min, max int
want string
}{
{5, 8, "levels 58"},
{5, 5, "level 5"},
{0, 0, ""},
{0, 4, "up to level 4"},
{18, 0, "level 18 and up"},
}
for _, c := range cases {
if got := levelBand(c.min, c.max); got != c.want {
t.Errorf("levelBand(%d, %d) = %q, want %q", c.min, c.max, got, c.want)
}
}
}
// TestRealmIngestNeedsTheBearer. Same gate as every other ingest: the realm is
// public to read and authenticated to write.
func TestRealmIngestNeedsTheBearer(t *testing.T) {
s, _ := newAdvServer(t, "tok")
if w := postRealm(t, s, "wrong", realmPush{SnapshotAt: time.Now().Unix()}); w.Code != 401 {
t.Errorf("bad bearer = %d, want 401", w.Code)
}
}