Files
Pete/internal/web/siege_test.go
T
prosolis 23563b6a6a adventure: give the Siege a war room instead of a Matrix-only rumour
The Siege is the one mechanic where the whole town works on a single
object, and it existed exclusively in Matrix — so anybody not in the room
at the time never knew it happened. A communal event nobody can see is a
communal event that fails.

gogobee now pushes the war room on the roster tick and Pete replaces its
copy, the same snapshot contract the board has and for the same reason:
the shared pool is state, not history, and a retried snapshot would be a
lie about how much HP is left.

/adventure/siege draws it. The load-bearing detail is one CSS line — the
health bar has a width transition, so a poll that lands a lower pool
slides the bar down instead of snapping it. That is the difference
between watching the town chip a boss down and reading a report about it.
Alongside: a countdown to the window's close, past sieges with the bar
each one ended on, and the muster split into who took today's bout and
who still has one going spare — the mechanic is one fight per person per
day, so that second column is a hit the town hasn't taken yet.

The opt-out rule here deliberately differs from the board's. The board
omits an opted-out player outright, because class + level + zone
re-identifies them. A Siege contributor is anonymised instead: their
damage is part of what the town did to the boss, and deleting it would
understate the shared effort and stop the totals adding up. They keep
their rank, lose their name, and carry no token — so there is no link
back to a page that names them. An opted-out player who never fought is
still omitted; there is nothing to account for.

siege_start / siege_win / siege_loss are wired on the gogobee side; the
templates for all three have been sitting unused in renderAdventure
since the section shipped.
2026-07-24 15:21:14 -07:00

317 lines
12 KiB
Go

package web
import (
"bytes"
"encoding/json"
"net/http/httptest"
"strings"
"testing"
"time"
"pete/internal/storage"
)
func postSiege(t *testing.T, s *Server, token string, push siegePush) *httptest.ResponseRecorder {
t.Helper()
body, _ := json.Marshal(push)
req := httptest.NewRequest("POST", "/api/ingest/siege", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
w := httptest.NewRecorder()
s.handleSiegeIngest(w, req)
return w
}
func liveSiege(now int64, hpCurrent int, defenders ...storage.SiegeDefender) siegePush {
return siegePush{SnapshotAt: now, Siege: storage.Siege{
Active: true,
BossID: 7,
BossName: "Gorloth the Sunderer",
Tier: 4,
HPCurrent: hpCurrent,
HPMax: 1000,
StartsAt: now - 3600,
EndsAt: now + 68*3600,
Defenders: defenders,
}}
}
// TestSiegeReplacesNeverMerges is the war room's core contract, and it is the
// same one the board has: gogobee sends the whole thing and Pete's copy becomes
// it. A defender who dropped out of a later snapshot has to leave the board, and
// a Siege that resolved has to stop showing a live bar. An upsert would leave
// both standing forever.
func TestSiegeReplacesNeverMerges(t *testing.T) {
s, _ := newAdvServer(t, "tok")
now := time.Now().Unix()
if w := postSiege(t, s, "tok", liveSiege(now, 800,
storage.SiegeDefender{Token: "t1", Name: "Josie", Fights: 2, Damage: 150, FoughtToday: true},
storage.SiegeDefender{Token: "t2", Name: "Quack", Fights: 1, Damage: 50},
)); w.Code != 200 {
t.Fatalf("first push = %d, want 200", w.Code)
}
// Quack opts out; gogobee stops sending her under a name.
if w := postSiege(t, s, "tok", liveSiege(now+120, 700,
storage.SiegeDefender{Token: "t1", Name: "Josie", Fights: 3, Damage: 250, FoughtToday: true},
)); w.Code != 200 {
t.Fatalf("second push = %d, want 200", w.Code)
}
v := s.siege()
if got := len(v.Fought) + len(v.Waiting); got != 1 {
t.Fatalf("muster has %d rows, want 1 — a dropped defender survived the swap", got)
}
if v.HPCurrent != 700 {
t.Errorf("hp_current = %d, want 700 — the pool didn't follow the snapshot", v.HPCurrent)
}
// And a snapshot saying the Siege ended must clear the live bar entirely.
if w := postSiege(t, s, "tok", siegePush{SnapshotAt: now + 240, Siege: storage.Siege{Active: false}}); w.Code != 200 {
t.Fatalf("resolution push = %d, want 200", w.Code)
}
if v := s.siege(); v.Active {
t.Error("war room still reads active after a snapshot said the Siege was over")
}
}
// TestSiegeSplitsFoughtFromWaiting is the mechanic made visible. One bout per
// person per day means a defender who hasn't swung today is damage the pool has
// not seen — the page's whole nudge — so the split has to come off FoughtToday
// and not off "has any fights at all". A veteran of nine bouts who hasn't been
// out today belongs in the waiting column.
func TestSiegeSplitsFoughtFromWaiting(t *testing.T) {
s, _ := newAdvServer(t, "tok")
now := time.Now().Unix()
postSiege(t, s, "tok", liveSiege(now, 500,
storage.SiegeDefender{Token: "t1", Name: "Josie", Fights: 9, Damage: 400, FoughtToday: false},
storage.SiegeDefender{Token: "t2", Name: "Quack", Fights: 1, Damage: 100, FoughtToday: true},
storage.SiegeDefender{Token: "t3", Name: "Newbie", Fights: 0, Damage: 0},
))
v := s.siege()
if len(v.Fought) != 1 || v.Fought[0].Name != "Quack" {
t.Errorf("fought-today = %+v, want just Quack", v.Fought)
}
if len(v.Waiting) != 2 {
t.Fatalf("waiting = %d rows, want 2 (Josie has bouts but not today, Newbie has none)", len(v.Waiting))
}
if v.Mustered != 2 {
t.Errorf("mustered = %d, want 2 — that counts anyone who has ever fought, not today's turnout", v.Mustered)
}
}
// TestSiegeAnonDefenderKeepsRankLosesLink is the opt-out rule, and it is
// deliberately NOT the board's rule. The board omits an opted-out player
// outright, because a row showing class + level + zone re-identifies them. Here
// the damage is part of what the town did to the boss: dropping it would
// understate the shared effort and stop the numbers adding up. So the row stays,
// anonymous, with no token — and therefore no link to a page that would name them.
func TestSiegeAnonDefenderKeepsRankLosesLink(t *testing.T) {
s, _ := newAdvServer(t, "tok")
now := time.Now().Unix()
postSiege(t, s, "tok", liveSiege(now, 100,
storage.SiegeDefender{Name: "an adventurer", Fights: 5, Damage: 700, FoughtToday: true},
storage.SiegeDefender{Token: "t1", Name: "Josie", Fights: 1, Damage: 200, FoughtToday: true},
))
v := s.siege()
if len(v.Fought) != 2 {
t.Fatalf("fought = %d rows, want 2 — the anonymous contributor was dropped", len(v.Fought))
}
// Push order is gogobee's ranking and Pete must preserve it: the anonymous
// defender out-damaged Josie and holds the top of the board.
if v.Fought[0].Name != "an adventurer" {
t.Errorf("top of the board is %q, want the anonymous defender — rank was lost", v.Fought[0].Name)
}
if v.Fought[0].Token != "" {
t.Error("anonymous defender carries a token — that is a link back to a page that names them")
}
}
// TestSiegeGoesStale: if gogobee stops talking, the bar must stop claiming to be
// live. A health bar that confidently shows a pool level from an hour ago is
// worse than one that admits it lost the wire, because the whole promise of the
// page is that the number is true *right now*.
func TestSiegeGoesStale(t *testing.T) {
s, _ := newAdvServer(t, "tok")
old := time.Now().Add(-30 * time.Minute).Unix()
postSiege(t, s, "tok", liveSiege(old, 900))
v := s.siege()
if !v.Stale {
t.Error("a 30-minute-old snapshot reads as live")
}
if v.HPCurrent != 900 {
t.Errorf("hp_current = %d, want 900 — a stale war room must still show the last known pool", v.HPCurrent)
}
}
// TestSiegeNeverPushedIsNotAnEmptySiege distinguishes the two states that look
// alike from the outside: gogobee has never told us about a Siege, versus it has
// told us there isn't one. Only the second can honestly say "quiet month".
func TestSiegeNeverPushedIsNotAnEmptySiege(t *testing.T) {
s, _ := newAdvServer(t, "tok")
if v := s.siege(); v.Known {
t.Error("war room claims to know the Siege state before gogobee ever pushed one")
}
postSiege(t, s, "tok", siegePush{SnapshotAt: time.Now().Unix(), Siege: storage.Siege{Active: false}})
v := s.siege()
if !v.Known {
t.Error("war room still reads unknown after a snapshot said no Siege is camped")
}
if v.Active {
t.Error("no-siege snapshot rendered as an active Siege")
}
}
// TestSiegeRejectsUnrenderableSnapshots. Two things a public page cannot draw:
// a defender with no name (a blank row), and an active Siege with no pool (a
// divide-by-zero on the bar). Both are 400s — unlike an unknown *event type*,
// which W0 deliberately made a 200, because that one is a styling gap where
// these are malformed state.
func TestSiegeRejectsUnrenderableSnapshots(t *testing.T) {
s, _ := newAdvServer(t, "tok")
now := time.Now().Unix()
w := postSiege(t, s, "tok", liveSiege(now, 500, storage.SiegeDefender{Token: "t1", Fights: 1}))
if w.Code != 400 {
t.Errorf("nameless defender = %d, want 400", w.Code)
}
bad := liveSiege(now, 500)
bad.HPMax = 0
if w := postSiege(t, s, "tok", bad); w.Code != 400 {
t.Errorf("active siege with no pool = %d, want 400", w.Code)
}
req := httptest.NewRequest("POST", "/api/ingest/siege", strings.NewReader("{}"))
req.Header.Set("Authorization", "Bearer wrong")
rec := httptest.NewRecorder()
s.handleSiegeIngest(rec, req)
if rec.Code != 401 {
t.Errorf("bad bearer = %d, want 401", rec.Code)
}
}
// TestSiegeHistoryRendersEndedBar. The history is what makes the live bar mean
// anything, so the numbers behind it have to survive the round trip: a won Siege
// ended at zero (an empty track), a lost one shows what was still standing.
func TestSiegeHistoryRendersEndedBar(t *testing.T) {
s, _ := newAdvServer(t, "tok")
now := time.Now().Unix()
push := siegePush{SnapshotAt: now, Siege: storage.Siege{
Active: false,
History: []storage.SiegePast{
{BossID: 2, BossName: "The Ashen Wyrm", Tier: 5, Outcome: "survived",
HPRemaining: 300, HPMax: 1200, Defenders: 3, MVP: "Josie", MVPFights: 4, EndedAt: now - 86400},
{BossID: 1, BossName: "The Iron Colossus", Tier: 4, Outcome: "defeated",
HPRemaining: 0, HPMax: 800, Defenders: 5, MVP: "Quack", MVPFights: 6, EndedAt: now - 172800},
},
}}
if w := postSiege(t, s, "tok", push); w.Code != 200 {
t.Fatalf("history push = %d, want 200", w.Code)
}
v := s.siege()
if len(v.History) != 2 {
t.Fatalf("history = %d rows, want 2", len(v.History))
}
// Newest first: the Wyrm ended a day ago, the Colossus two.
if v.History[0].BossName != "The Ashen Wyrm" {
t.Errorf("history[0] = %q, want the most recent Siege first", v.History[0].BossName)
}
if v.History[0].Won {
t.Error("a survived Siege reads as a win")
}
if v.History[0].HPPercent != 25 {
t.Errorf("survived bar = %d%%, want 25 (300 of 1200 still standing)", v.History[0].HPPercent)
}
if !v.History[1].Won || v.History[1].HPPercent != 0 {
t.Errorf("defeated Siege = won %v at %d%%, want won at 0%%", v.History[1].Won, v.History[1].HPPercent)
}
}
// TestSiegeAPIFeedsTheBar. The bar only moves because this endpoint answers, so
// the field names it emits are load-bearing: the page's JS reads hp_percent to
// set the width and active to decide whether to keep polling at all.
func TestSiegeAPIFeedsTheBar(t *testing.T) {
s, _ := newAdvServer(t, "tok")
now := time.Now().Unix()
postSiege(t, s, "tok", liveSiege(now, 250,
storage.SiegeDefender{Token: "t1", Name: "Josie", Fights: 1, Damage: 750, FoughtToday: true}))
rec := httptest.NewRecorder()
s.handleSiegeAPI(rec, httptest.NewRequest("GET", "/api/adventure/siege", nil))
if rec.Code != 200 {
t.Fatalf("api = %d, want 200", rec.Code)
}
var got map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatal(err)
}
if got["active"] != true {
t.Error("api says the Siege isn't active")
}
if got["hp_percent"].(float64) != 25 {
t.Errorf("hp_percent = %v, want 25 — the bar would draw at the wrong width", got["hp_percent"])
}
if got["bouts_today"] == nil || got["mustered"].(float64) != 1 {
t.Errorf("api dropped the turnout counters: %v", got)
}
}
// TestSiegeTemplateExecutes renders all three states the page has to survive —
// a live Siege, a quiet realm with history, and a realm that has never had one.
// Template execution errors are silent in production (render logs and returns a
// half-written body), so a parse-clean template that blows up on a nil field is
// exactly the class of bug that reaches a visitor before it reaches a log.
func TestSiegeTemplateExecutes(t *testing.T) {
s, _ := newAdvServer(t, "tok")
now := time.Now().Unix()
render := func(v SiegeView) string {
t.Helper()
var b strings.Builder
if err := s.tpls["siege"].ExecuteTemplate(&b, "layout",
siegePage{pageData: pageData{SiteTitle: "Pete", Channels: channels}, Siege: v}); err != nil {
t.Fatal(err)
}
return b.String()
}
postSiege(t, s, "tok", liveSiege(now, 250,
storage.SiegeDefender{Token: "t1", Name: "Josie", Level: 12, Fights: 3, Damage: 600, FoughtToday: true},
storage.SiegeDefender{Name: "an adventurer", Fights: 1, Damage: 150},
storage.SiegeDefender{Token: "t3", Name: "Camcast", Level: 4},
))
live := render(s.siege())
if !strings.Contains(live, "Gorloth the Sunderer") {
t.Error("live page doesn't name the boss")
}
if !strings.Contains(live, `style="width: 25%"`) {
t.Error("live page didn't draw the bar at the pool's width")
}
if !strings.Contains(live, `/adventure/who/t1`) {
t.Error("live page doesn't link a named defender to their page")
}
if strings.Contains(live, `/adventure/who/"`) {
t.Error("live page emitted an empty who link — the anonymous defender got a link anyway")
}
// Quiet realm with history, and a realm that has never seen one.
quiet := render(SiegeView{Known: true, History: []SiegePastView{{
SiegePast: storage.SiegePast{BossName: "The Iron Colossus", Tier: 4, Outcome: "defeated",
HPMax: 800, Defenders: 5, MVP: "Quack", MVPFights: 6}, Won: true, When: "Jun 1, 2026"}}})
if !strings.Contains(quiet, "Nothing's camped outside town") || !strings.Contains(quiet, "The Iron Colossus") {
t.Error("quiet page lost either the empty state or the history")
}
if fresh := render(SiegeView{}); !strings.Contains(fresh, "haven't heard from the field") {
t.Error("never-pushed page doesn't say it hasn't heard from the field")
}
}