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.
This commit is contained in:
prosolis
2026-07-24 15:21:14 -07:00
parent 91d25e9da1
commit 23563b6a6a
10 changed files with 1148 additions and 2 deletions
+50
View File
@@ -94,6 +94,56 @@ CREATE INDEX IF NOT EXISTS idx_adv_events_subject ON adventure_events(subject, o
CREATE INDEX IF NOT EXISTS idx_adv_events_opponent ON adventure_events(opponent, occurred_at DESC) WHERE opponent IS NOT NULL AND opponent <> '';
CREATE INDEX IF NOT EXISTS idx_adv_events_type ON adventure_events(event_type, occurred_at DESC);
-- The Siege. Three tables, all fed by one gogobee push and all replaced whole,
-- because the Siege is state, not history — the same contract as the roster.
--
-- The split is by lifetime, not by convenience. adventure_siege is the single
-- live boss (a CHECK-pinned one-row table, like adventure_roster_meta, so "no
-- Siege camped" is a row saying active=0 rather than an ambiguous empty table).
-- adventure_siege_defenders is the muster for that one boss and dies with it.
-- adventure_siege_history outlives both, and is the reason the current Siege
-- feels like it counts: a health bar with nothing behind it is a progress bar.
CREATE TABLE IF NOT EXISTS adventure_siege (
id INTEGER PRIMARY KEY CHECK (id = 1),
active INTEGER NOT NULL DEFAULT 0,
boss_id INTEGER NOT NULL DEFAULT 0,
boss_name TEXT NOT NULL DEFAULT '',
tier INTEGER NOT NULL DEFAULT 0,
hp_current INTEGER NOT NULL DEFAULT 0,
hp_max INTEGER NOT NULL DEFAULT 0,
starts_at INTEGER NOT NULL DEFAULT 0,
ends_at INTEGER NOT NULL DEFAULT 0,
bouts_today INTEGER NOT NULL DEFAULT 0,
snapshot_at INTEGER NOT NULL DEFAULT 0
);
-- pos is the push order, which is gogobee's ranking (damage desc). Kept as the
-- key rather than the token because an opted-out defender carries NO token — the
-- board shows their rank and their damage as "an adventurer" and offers no link,
-- so several rows can legitimately be tokenless and they must not collide.
CREATE TABLE IF NOT EXISTS adventure_siege_defenders (
pos INTEGER PRIMARY KEY,
token TEXT NOT NULL DEFAULT '',
name TEXT NOT NULL,
level INTEGER NOT NULL DEFAULT 0,
fights INTEGER NOT NULL DEFAULT 0,
damage INTEGER NOT NULL DEFAULT 0,
fought_today INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS adventure_siege_history (
boss_id INTEGER PRIMARY KEY,
boss_name TEXT NOT NULL,
tier INTEGER NOT NULL DEFAULT 0,
outcome TEXT NOT NULL, -- "defeated" | "survived"
hp_remaining INTEGER NOT NULL DEFAULT 0,
hp_max INTEGER NOT NULL DEFAULT 0,
defenders INTEGER NOT NULL DEFAULT 0,
mvp TEXT NOT NULL DEFAULT '',
mvp_fights INTEGER NOT NULL DEFAULT 0,
ended_at INTEGER NOT NULL DEFAULT 0
);
-- A signed-in buyer's own euro balance, as of the last snapshot gogobee pushed.
-- Keyed by localpart (== Authentik preferred_username == the session's Username),
-- a *separate keyspace* from the anonymous roster tokens on purpose: it is only
+186
View File
@@ -0,0 +1,186 @@
package storage
import (
"database/sql"
)
// The Siege, as gogobee pushes it.
//
// Same shape of thing as the roster and stored the same way: a whole snapshot
// that replaces whatever we had. Nothing here is an event — the *events*
// (siege_start / siege_win / siege_loss) come down the dispatch queue like any
// other fact. This is the thing that is currently true, which is the only kind
// of thing a health bar can honestly draw.
// SiegeDefender is one adventurer's standing in the current muster.
//
// Token is the same public roster token the board uses, so the defender board
// can link a name to their page — and it is EMPTY for an opted-out player. That
// is the whole opt-out story here: their damage still counts and still holds its
// rank (the town's effort is the town's), but there is no name and no link. Name
// carries gogobee's anonymised label in that case.
type SiegeDefender struct {
Token string `json:"token,omitempty"`
Name string `json:"name"`
Level int `json:"level,omitempty"`
Fights int `json:"fights"`
Damage int `json:"damage"`
FoughtToday bool `json:"fought_today"`
}
// SiegePast is one closed-out Siege: what came, whether the town held, and who
// turned up most. The history is what makes the live bar mean anything.
type SiegePast struct {
BossID int64 `json:"boss_id"`
BossName string `json:"boss_name"`
Tier int `json:"tier"`
Outcome string `json:"outcome"` // "defeated" | "survived"
HPRemaining int `json:"hp_remaining"`
HPMax int `json:"hp_max"`
Defenders int `json:"defenders"`
MVP string `json:"mvp,omitempty"`
MVPFights int `json:"mvp_fights,omitempty"`
EndedAt int64 `json:"ended_at"`
}
// Siege is the complete war-room state: the live boss (if any), its muster, and
// every Siege that came before.
type Siege struct {
Active bool `json:"active"`
BossID int64 `json:"boss_id,omitempty"`
BossName string `json:"boss_name,omitempty"`
Tier int `json:"tier,omitempty"`
HPCurrent int `json:"hp_current"`
HPMax int `json:"hp_max"`
StartsAt int64 `json:"starts_at,omitempty"`
EndsAt int64 `json:"ends_at,omitempty"`
BoutsToday int `json:"bouts_today"`
Defenders []SiegeDefender `json:"defenders,omitempty"`
History []SiegePast `json:"history,omitempty"`
SnapshotAt int64 `json:"snapshot_at"`
}
// ReplaceSiege swaps the whole war room for a new snapshot, in one transaction.
//
// Replace, never merge — for the same reason the roster does it. A defender who
// dropped out of the payload (opted out, deleted character) has to leave the
// board, and a Siege that ended has to stop showing a live bar. The transaction
// means a reader mid-swap sees the old Siege or the new one, never a boss with
// somebody else's muster under it.
//
// History is replaced too, not appended: gogobee is the authority on what has
// happened, and rebuilding from its list each tick means a corrected or purged
// row upstream can't leave a ghost siege on Pete forever.
func ReplaceSiege(s Siege, snapshotAt int64) error {
tx, err := Get().Begin()
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
if _, err := tx.Exec(`DELETE FROM adventure_siege_defenders`); err != nil {
return err
}
if _, err := tx.Exec(`DELETE FROM adventure_siege_history`); err != nil {
return err
}
if _, err := tx.Exec(`
INSERT INTO adventure_siege
(id, active, boss_id, boss_name, tier, hp_current, hp_max,
starts_at, ends_at, bouts_today, snapshot_at)
VALUES (1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
active = excluded.active, boss_id = excluded.boss_id,
boss_name = excluded.boss_name, tier = excluded.tier,
hp_current = excluded.hp_current, hp_max = excluded.hp_max,
starts_at = excluded.starts_at, ends_at = excluded.ends_at,
bouts_today = excluded.bouts_today, snapshot_at = excluded.snapshot_at`,
s.Active, s.BossID, s.BossName, s.Tier, s.HPCurrent, s.HPMax,
s.StartsAt, s.EndsAt, s.BoutsToday, snapshotAt); err != nil {
return err
}
dstmt, err := tx.Prepare(`
INSERT INTO adventure_siege_defenders
(pos, token, name, level, fights, damage, fought_today)
VALUES (?, ?, ?, ?, ?, ?, ?)`)
if err != nil {
return err
}
defer dstmt.Close()
for i, d := range s.Defenders {
if _, err := dstmt.Exec(i, d.Token, d.Name, d.Level, d.Fights, d.Damage, d.FoughtToday); err != nil {
return err
}
}
hstmt, err := tx.Prepare(`
INSERT INTO adventure_siege_history
(boss_id, boss_name, tier, outcome, hp_remaining, hp_max, defenders, mvp, mvp_fights, ended_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
if err != nil {
return err
}
defer hstmt.Close()
for _, h := range s.History {
if _, err := hstmt.Exec(h.BossID, h.BossName, h.Tier, h.Outcome, h.HPRemaining,
h.HPMax, h.Defenders, h.MVP, h.MVPFights, h.EndedAt); err != nil {
return err
}
}
return tx.Commit()
}
// LoadSiege returns the war room as last pushed. ok is false when gogobee has
// never pushed one at all — distinct from a pushed snapshot that says no Siege
// is camped, which is a real answer the page can render.
func LoadSiege() (Siege, bool, error) {
var s Siege
err := Get().QueryRow(`
SELECT active, boss_id, boss_name, tier, hp_current, hp_max,
starts_at, ends_at, bouts_today, snapshot_at
FROM adventure_siege WHERE id = 1`).Scan(
&s.Active, &s.BossID, &s.BossName, &s.Tier, &s.HPCurrent, &s.HPMax,
&s.StartsAt, &s.EndsAt, &s.BoutsToday, &s.SnapshotAt)
if err == sql.ErrNoRows {
return Siege{}, false, nil
}
if err != nil {
return Siege{}, false, err
}
drows, err := Get().Query(`
SELECT token, name, level, fights, damage, fought_today
FROM adventure_siege_defenders ORDER BY pos ASC`)
if err != nil {
return s, true, err
}
defer drows.Close()
for drows.Next() {
var d SiegeDefender
if err := drows.Scan(&d.Token, &d.Name, &d.Level, &d.Fights, &d.Damage, &d.FoughtToday); err != nil {
return s, true, err
}
s.Defenders = append(s.Defenders, d)
}
if err := drows.Err(); err != nil {
return s, true, err
}
hrows, err := Get().Query(`
SELECT boss_id, boss_name, tier, outcome, hp_remaining, hp_max, defenders, mvp, mvp_fights, ended_at
FROM adventure_siege_history ORDER BY ended_at DESC, boss_id DESC`)
if err != nil {
return s, true, err
}
defer hrows.Close()
for hrows.Next() {
var h SiegePast
if err := hrows.Scan(&h.BossID, &h.BossName, &h.Tier, &h.Outcome, &h.HPRemaining,
&h.HPMax, &h.Defenders, &h.MVP, &h.MVPFights, &h.EndedAt); err != nil {
return s, true, err
}
s.History = append(s.History, h)
}
return s, true, hrows.Err()
}
+7
View File
@@ -126,6 +126,12 @@ type channelPage struct {
Roster []RosterView
RosterStale bool
ShowRoster bool
// Siege is the war room, summarised into a strip at the top of the section.
// Same page-1-only rule as the roster and for the same reason. It renders
// even with nothing camped, because the link to the history is the other half
// of what makes a live Siege feel like it counts.
Siege SiegeView
}
type indexPage struct {
@@ -353,6 +359,7 @@ func (s *Server) handleChannel(w http.ResponseWriter, r *http.Request, ch Channe
if ch.Slug == "adventure" && page == 1 {
data.Roster, data.RosterStale, _ = s.roster()
data.ShowRoster = true
data.Siege = s.siege()
}
s.render(w, "channel", data)
}
+12 -1
View File
@@ -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"}},
{"layout", []string{"_card"}, []string{"index", "channel", "weather", "bookmarks", "for-you", "status", "story", "who", "siege"}},
{"games_layout", []string{"_chipbar"}, []string{"games", "games_door", "blackjack", "hangman", "solitaire", "trivia", "uno", "holdem"}},
}
tpls := make(map[string]*template.Template)
@@ -231,6 +231,17 @@ 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 Siege war room. Ingest is bearer-authed like the roster; the page and
// its poll are public — the same exposure the board already has.
//
// GET /adventure/siege is a LITERAL two-segment pattern, so it beats
// /adventure/{guid} on Go's most-specific-match rule. Nothing is shadowed by
// it either: a dispatch guid is "<type>:<hash>:<ts>" and can never be the
// bare word "siege".
mux.HandleFunc("POST /api/ingest/siege", s.handleSiegeIngest)
mux.HandleFunc("GET /api/adventure/siege", s.handleSiegeAPI)
mux.HandleFunc("GET /adventure/siege", s.handleSiegePage)
// Per-dispatch permalink (the article_url every ingested story points at).
// Public GET; self-gates on adv.Enabled. Distinct from GET /adventure (the
// channel listing, registered in the channels loop above).
+267
View File
@@ -0,0 +1,267 @@
package web
import (
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"time"
"pete/internal/storage"
)
// The Siege war room.
//
// The Siege is the one thing in the realm everybody works on at once: a named
// boss camps outside town for 72 hours behind a single shared HP pool, and every
// adventurer gets one bout a day against it. Until now that existed only in
// Matrix, which means it was invisible to anyone not in the room at the time —
// a communal event nobody can see is a communal event that fails.
//
// It arrives the same way the board does: gogobee pushes the whole thing on the
// roster tick and Pete replaces its copy. That is the right shape here for the
// same reason it was there — the pool is state, not history. A retried snapshot
// would be a lie about how much HP is left, and the next tick carries the truth.
//
// The page's job is one thing above all others: make the bar visibly move. The
// whole point of a shared pool is watching the town chip it down, and a number
// that only changes when you reload is not a siege, it is a report about one.
const (
// siegeStaleAfter — how old the snapshot can get before the page stops
// claiming the bar is live. Same reasoning and same ticker as the roster, so
// the same window: several missed pushes, not one unlucky one.
siegeStaleAfter = 12 * time.Minute
// siegeMaxDefenders / siegeMaxHistory bound a push. A realm has tens of
// players and a Siege a month; these only stop a malformed or hostile payload
// spooling unbounded rows.
siegeMaxDefenders = 500
siegeMaxHistory = 200
)
// siegePush is the payload gogobee POSTs to /api/ingest/siege.
type siegePush struct {
SnapshotAt int64 `json:"snapshot_at"`
storage.Siege
}
// SiegeView is the war room as the page renders it: gogobee's facts plus the
// few presentational things Pete is allowed to decide (percentages, wording,
// the fought/waiting split).
type SiegeView struct {
Active bool
Stale bool
Known bool // gogobee has pushed at least one snapshot
BossName string
Tier int
HPCurrent int
HPMax int
HPPercent int
Damage int // HPMax - HPCurrent, the town's total contribution
StartsAt int64
EndsAt int64
BoutsToday int
Fought []storage.SiegeDefender // took today's bout
Waiting []storage.SiegeDefender // hasn't yet — the gap the page wants felt
Mustered int // defenders who have fought at least once
History []SiegePastView
SnapshotAt int64
LastSeenAgo string
}
// SiegePastView is one closed-out Siege, with the bar it ended on.
type SiegePastView struct {
storage.SiegePast
Won bool
HPPercent int
When string
}
type siegePage struct {
pageData
Siege SiegeView
}
// handleSiegeIngest replaces the war room with gogobee's latest snapshot.
func (s *Server) handleSiegeIngest(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 siegePush
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&push); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
if len(push.Defenders) > siegeMaxDefenders {
http.Error(w, "defender board too large", http.StatusBadRequest)
return
}
if len(push.History) > siegeMaxHistory {
http.Error(w, "history too large", http.StatusBadRequest)
return
}
if push.SnapshotAt <= 0 {
push.SnapshotAt = time.Now().Unix()
}
// A snapshot with no timestamp can't age, so it would claim to be live
// forever; the roster ingest treats that the same way.
push.Siege.SnapshotAt = push.SnapshotAt
// Never trust the channel with a name. gogobee already anonymises opted-out
// defenders (empty token, "an adventurer"), but a nameless row would render
// as a blank line on a public page, so it is rejected rather than drawn.
for i, d := range push.Defenders {
if d.Name == "" {
http.Error(w, fmt.Sprintf("defender %d: name is required", i), http.StatusBadRequest)
return
}
}
// An active Siege with no pool is not a Siege — it is a division by zero on
// the bar, and the page has no honest way to draw it.
if push.Active && push.HPMax <= 0 {
http.Error(w, "active siege needs hp_max", http.StatusBadRequest)
return
}
if err := storage.ReplaceSiege(push.Siege, push.SnapshotAt); err != nil {
slog.Error("siege ingest: replace failed", "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
slog.Info("siege ingest: war room replaced",
"active", push.Active, "boss", push.BossName,
"defenders", len(push.Defenders), "history", len(push.History))
w.WriteHeader(http.StatusOK)
}
// handleSiegePage serves the war room. Public: the Siege is a town-wide event
// and the defender board is the same anonymity model as the live board.
func (s *Server) handleSiegePage(w http.ResponseWriter, r *http.Request) {
if !s.adv.Enabled {
http.NotFound(w, r)
return
}
s.track(r, "adventure")
base := s.base(r)
base.Active = "adventure"
// Unlike the who page this one is NOT noindex: it names a boss and a town,
// and the defender list is character names that are already public on the
// board. There is nothing here that ties a page to a person more than
// /adventure already does.
s.render(w, "siege", siegePage{pageData: base, Siege: s.siege()})
}
// handleSiegeAPI serves the war room as JSON for the page's own re-poll. This
// is what makes the bar move without a reload, so it is deliberately cheap and
// deliberately public — the same exposure as the rendered page, no more.
func (s *Server) handleSiegeAPI(w http.ResponseWriter, r *http.Request) {
if !s.adv.Enabled {
http.NotFound(w, r)
return
}
v := s.siege()
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(map[string]any{
"active": v.Active,
"stale": v.Stale,
"known": v.Known,
"boss_name": v.BossName,
"tier": v.Tier,
"hp_current": v.HPCurrent,
"hp_max": v.HPMax,
"hp_percent": v.HPPercent,
"damage": v.Damage,
"ends_at": v.EndsAt,
"bouts_today": v.BoutsToday,
"mustered": v.Mustered,
"fought": v.Fought,
"waiting": v.Waiting,
"snapshot_at": v.SnapshotAt,
})
}
// siege builds the view from the last snapshot.
//
// A stale war room is still returned, dimmed and labelled, for the same reason
// the board is: "here is where the pool stood when we lost contact" beats an
// empty page, and it stops the bar from quietly lying about being live.
func (s *Server) siege() SiegeView {
snap, known, err := storage.LoadSiege()
if err != nil {
slog.Error("siege: load failed", "err", err)
return SiegeView{Stale: true}
}
v := SiegeView{
Active: snap.Active,
Known: known,
BossName: snap.BossName,
Tier: snap.Tier,
HPCurrent: snap.HPCurrent,
HPMax: snap.HPMax,
StartsAt: snap.StartsAt,
EndsAt: snap.EndsAt,
BoutsToday: snap.BoutsToday,
SnapshotAt: snap.SnapshotAt,
}
if !known || snap.SnapshotAt == 0 || time.Since(time.Unix(snap.SnapshotAt, 0)) > siegeStaleAfter {
v.Stale = true
}
if snap.SnapshotAt > 0 {
v.LastSeenAgo = shortTimeAgo(time.Unix(snap.SnapshotAt, 0))
}
if snap.HPMax > 0 {
v.HPPercent = clampPercent(snap.HPCurrent * 100 / snap.HPMax)
v.Damage = snap.HPMax - snap.HPCurrent
}
// The fought/waiting split is the mechanic made visible: one bout per person
// per day means an adventurer standing in the "yet to fight" column is a bout
// the town has not spent yet. gogobee sends every alive, non-opted-out
// adventurer — not just contributors — precisely so this column exists.
for _, d := range snap.Defenders {
if d.Fights > 0 {
v.Mustered++
}
if d.FoughtToday {
v.Fought = append(v.Fought, d)
} else {
v.Waiting = append(v.Waiting, d)
}
}
for _, h := range snap.History {
pv := SiegePastView{SiegePast: h, Won: h.Outcome == "defeated"}
if h.HPMax > 0 {
pv.HPPercent = clampPercent(h.HPRemaining * 100 / h.HPMax)
}
if h.EndedAt > 0 {
pv.When = time.Unix(h.EndedAt, 0).UTC().Format("Jan 2, 2006")
}
v.History = append(v.History, pv)
}
return v
}
// clampPercent keeps a computed bar width inside 0100 whatever the snapshot
// claimed. gogobee clamps its own pool at zero, but the bar is drawn from
// arithmetic on two numbers off the wire and must not be able to overflow its
// track on a malformed one.
func clampPercent(p int) int {
if p < 0 {
return 0
}
if p > 100 {
return 100
}
return p
}
+316
View File
@@ -0,0 +1,316 @@
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")
}
}
+75
View File
@@ -2757,3 +2757,78 @@ html[data-room] .pete-felt {
.cmp-delta-up { color: color-mix(in srgb, #3fa66a 65%, var(--ink)); background: color-mix(in srgb, #3fa66a 13%, var(--card)); }
.cmp-delta-down { color: color-mix(in srgb, #c0392b 60%, var(--ink)); background: color-mix(in srgb, #c0392b 12%, var(--card)); }
}
@layer components {
/* The Siege war room. One rule carries the whole page: 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 and reading a report about it, and it costs one line.
prefers-reduced-motion turns the slide off — the number is still correct,
it just arrives instantly.
The ember palette is deliberate and not the adventure purple: a siege is
the one thing on the site that should look like an emergency. It mixes a
fixed hue into --card/--ink like the map and compare chips do, so it lands
readable in all four phases without a dark: variant. */
.siege-track {
position: relative;
height: 1.75rem;
border-radius: 9999px;
overflow: hidden;
background: color-mix(in srgb, var(--ink) 12%, var(--card));
box-shadow: inset 0 2px 4px rgba(0,0,0,0.12);
}
.siege-fill {
height: 100%;
border-radius: 9999px;
background: linear-gradient(90deg, #e0562f 0%, #c0392b 60%, #8f1f16 100%);
transition: width 1.4s cubic-bezier(0.22, 0.61, 0.36, 1);
}
.siege-fill-spent { background: linear-gradient(90deg, #6b7280 0%, #4b5563 100%); }
/* A living siege breathes. Slow and low-contrast on purpose — it should read
as "this is happening now", not as a thing demanding to be clicked. */
.siege-live .siege-fill { animation: siege-pulse 3.2s ease-in-out infinite; }
@keyframes siege-pulse {
0%, 100% { filter: brightness(1); }
50% { filter: brightness(1.12); }
}
.siege-chip {
display: inline-flex; align-items: center; gap: 0.3rem;
font-size: 11px; font-weight: 600; line-height: 1;
border-radius: 9999px; padding: 0.22rem 0.6rem;
border: 1px solid transparent;
}
.siege-chip-fought {
color: color-mix(in srgb, #3fa66a 65%, var(--ink));
background: color-mix(in srgb, #3fa66a 15%, var(--card));
border-color: color-mix(in srgb, #3fa66a 36%, transparent);
}
.siege-chip-waiting {
color: var(--warn);
background: color-mix(in srgb, var(--warn) 14%, var(--card));
border-color: color-mix(in srgb, var(--warn) 34%, transparent);
}
.siege-chip-held {
color: color-mix(in srgb, #3fa66a 65%, var(--ink));
background: color-mix(in srgb, #3fa66a 15%, var(--card));
border-color: color-mix(in srgb, #3fa66a 36%, transparent);
}
.siege-chip-fell {
color: color-mix(in srgb, #c0392b 60%, var(--ink));
background: color-mix(in srgb, #c0392b 15%, var(--card));
border-color: color-mix(in srgb, #c0392b 36%, transparent);
}
/* The past-siege bar: same track, smaller, and frozen at the pool the Siege
ended on. A won Siege ends at zero and draws as an empty track, which is
exactly the right picture. */
.siege-track-sm { height: 0.5rem; }
.siege-track-sm .siege-fill { transition: none; }
@media (prefers-reduced-motion: reduce) {
.siege-fill { transition: none; }
.siege-live .siege-fill { animation: none; }
}
}
File diff suppressed because one or more lines are too long
+24
View File
@@ -14,6 +14,30 @@
</section>
{{if .ShowRoster}}
{{/* The Siege strip. Above the board on purpose: the board is where everyone is,
the Siege is where everyone should be. When one is camped this is a live bar
and a door into the war room; when none is, it stays as the quiet doorway to
the history, which is the other half of making the next one feel like it
counts. */}}
{{if .Siege.Active}}
<a href="/adventure/siege" class="block mb-6 rounded-3xl bg-theme-adventure text-white p-5 sm:p-6 shadow-pete relative overflow-hidden group {{if not .Siege.Stale}}siege-live{{end}}">
<div class="absolute -top-6 -right-4 text-[8rem] opacity-20 select-none" aria-hidden="true">🏰</div>
<div class="relative">
<p class="text-xs uppercase tracking-[0.2em] opacity-80">🏰 The Siege · now</p>
<h2 class="font-display text-2xl font-bold mt-1 group-hover:underline">{{.Siege.BossName}} is at the gates</h2>
<div class="mt-3 siege-track">
<div class="siege-fill" style="width: {{.Siege.HPPercent}}%"></div>
</div>
<p class="mt-2 text-sm opacity-90 tabular-nums">{{.Siege.HPCurrent}} / {{.Siege.HPMax}} HP · {{.Siege.HPPercent}}% standing · {{len .Siege.Waiting}} bout{{if ne (len .Siege.Waiting) 1}}s{{end}} still going spare</p>
</div>
</a>
{{else if .Siege.History}}
<a href="/adventure/siege" class="flex items-center gap-3 mb-6 rounded-2xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 px-5 py-3 shadow-pete hover:border-theme-adventure/40 transition">
<span class="text-lg" aria-hidden="true">🏰</span>
<span class="text-sm text-[color:var(--ink)]/70">Nothing camped outside town right now — <span class="font-semibold text-theme-adventure">the sieges we've fought</span></span>
</a>
{{end}}
<section class="mb-10" id="roster" data-stale="{{.RosterStale}}">
<div class="flex items-baseline justify-between mb-3">
<h2 class="font-display text-2xl font-bold">Out there right now</h2>
+210
View File
@@ -0,0 +1,210 @@
{{define "title"}}{{if .Siege.Active}}The Siege — {{.Siege.BossName}}{{else}}The Siege{{end}} — {{.SiteTitle}}{{end}}
{{/* One row of the defender board. Same shape whether they fought today or are
still to go; the column they're in is the message. An opted-out defender
carries no token, so they get no link — their damage still counts and still
holds its rank, they just aren't named. */}}
{{define "defenderrow"}}
<li class="flex items-baseline gap-3 py-1.5 border-b border-[color:var(--ink)]/5 last:border-0">
<span class="flex-1 min-w-0">
{{if .Token}}<a href="/adventure/who/{{.Token}}" class="font-semibold hover:text-theme-adventure transition truncate">{{.Name}}</a>
{{else}}<span class="font-semibold text-[color:var(--ink)]/55 italic truncate">{{.Name}}</span>{{end}}
{{if .Level}}<span class="text-xs text-[color:var(--ink)]/40 ml-1.5">lv {{.Level}}</span>{{end}}
</span>
{{if .Fights}}
<span class="text-xs text-[color:var(--ink)]/50 shrink-0 tabular-nums">{{.Damage}} dmg · {{.Fights}} bout{{if ne .Fights 1}}s{{end}}</span>
{{else}}
<span class="text-xs text-[color:var(--ink)]/35 shrink-0">not yet in it</span>
{{end}}
</li>
{{end}}
{{define "main"}}
<article class="mt-2 mb-10 max-w-3xl mx-auto" id="siege"
data-active="{{if .Siege.Active}}1{{else}}0{{end}}" data-ends-at="{{.Siege.EndsAt}}">
<nav class="mb-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>
</nav>
{{if .Siege.Active}}
<header class="rounded-3xl bg-theme-adventure text-white p-6 sm:p-10 shadow-pete relative overflow-hidden {{if not .Siege.Stale}}siege-live{{end}}">
<div class="absolute -top-8 -right-4 text-[12rem] opacity-20 select-none" aria-hidden="true">🏰</div>
<div class="relative">
<p class="text-sm uppercase tracking-[0.2em] opacity-80">🏰 The Siege · Tier {{.Siege.Tier}}</p>
<h1 class="font-display text-3xl sm:text-4xl font-bold mt-2 leading-tight">{{.Siege.BossName}}</h1>
<p class="mt-2 opacity-90">Camped outside town. One bout each, every day, until it falls or the window closes.</p>
<!-- The bar. This is the page. -->
<div class="mt-6 siege-track" role="progressbar" aria-label="Siege boss health"
aria-valuemin="0" aria-valuemax="100" aria-valuenow="{{.Siege.HPPercent}}" id="siege-bar-track">
<div class="siege-fill" id="siege-bar" style="width: {{.Siege.HPPercent}}%"></div>
</div>
<div class="mt-2 flex items-baseline justify-between text-sm">
<span class="font-semibold tabular-nums"><span id="siege-hp">{{.Siege.HPCurrent}}</span> / <span id="siege-hpmax">{{.Siege.HPMax}}</span> HP</span>
<span class="opacity-80 tabular-nums"><span id="siege-pct">{{.Siege.HPPercent}}</span>% standing</span>
</div>
<div class="mt-5 flex flex-wrap gap-x-6 gap-y-2 text-xs uppercase tracking-wider opacity-85">
<span>Time left · <span class="font-semibold normal-case tracking-normal" id="siege-countdown"></span></span>
<span>Bouts today · <span class="font-semibold tabular-nums" id="siege-bouts">{{.Siege.BoutsToday}}</span></span>
<span>Mustered · <span class="font-semibold tabular-nums" id="siege-mustered">{{.Siege.Mustered}}</span></span>
</div>
{{if .Siege.Stale}}
<p class="mt-4 text-xs bg-black/25 rounded-xl px-3 py-2">
We've lost the wire to the field — this is where the pool stood {{.Siege.LastSeenAgo}}. Treat the number as history until it comes back.
</p>
{{end}}
</div>
</header>
<!-- How to actually join in. The bout is a Matrix command today; saying so
plainly beats a page that shows a fight nobody can tell how to enter. -->
<div class="mt-6 rounded-3xl bg-[color:var(--card)] border-2 border-theme-adventure/30 p-5 shadow-pete">
<p class="text-sm text-[color:var(--ink)]/75">
<span class="font-semibold text-theme-adventure">Taking your bout:</span>
say <code class="rounded bg-[color:var(--ink)]/8 px-1.5 py-0.5 font-mono text-xs">!adventure siege fight</code>
to me in Matrix. One a day, each. Damage counts whether you win the fight or not — turning up is the mechanic.
</p>
</div>
<!-- The muster. Two columns, and the right-hand one is the point: a bout not
taken today is damage the pool never sees. -->
<section class="mt-6 grid gap-6 sm:grid-cols-2">
<div class="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-3">
<h2 class="font-display text-xl font-bold">In it today</h2>
<span class="siege-chip siege-chip-fought">{{len .Siege.Fought}}</span>
</div>
{{if .Siege.Fought}}
<ul class="text-sm">{{range .Siege.Fought}}{{template "defenderrow" .}}{{end}}</ul>
{{else}}
<p class="text-sm text-[color:var(--ink)]/50">Nobody's swung at it yet today. The pool doesn't move on its own.</p>
{{end}}
</div>
<div class="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-3">
<h2 class="font-display text-xl font-bold">Bout still going spare</h2>
<span class="siege-chip siege-chip-waiting">{{len .Siege.Waiting}}</span>
</div>
{{if .Siege.Waiting}}
<ul class="text-sm">{{range .Siege.Waiting}}{{template "defenderrow" .}}{{end}}</ul>
<p class="mt-3 text-xs text-[color:var(--ink)]/45">Each of these is a free hit the town hasn't taken.</p>
{{else}}
<p class="text-sm text-[color:var(--ink)]/50">Everyone's been out. Good turnout.</p>
{{end}}
</div>
</section>
{{else}}
<header class="rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 p-6 sm:p-10 shadow-pete relative overflow-hidden">
<div class="absolute -top-8 -right-4 text-[12rem] opacity-10 select-none" aria-hidden="true">🏰</div>
<div class="relative">
<p class="text-sm uppercase tracking-[0.2em] text-[color:var(--ink)]/50">🏰 The Siege</p>
<h1 class="font-display text-3xl sm:text-4xl font-bold mt-2 leading-tight">Nothing's camped outside town.</h1>
<p class="mt-3 text-[color:var(--ink)]/70">
{{if .Siege.Known}}
Quiet month so far. One comes for the town every month — a named thing with a shared health pool, and everybody gets a swing a day at it.
{{else}}
I haven't heard from the field about a Siege yet. When one turns up, the bar lives here.
{{end}}
</p>
</div>
</header>
{{end}}
{{if .Siege.History}}
<!-- The history is what makes the live bar mean something. A won siege ends
at zero and draws as an empty track; a lost one shows exactly how much
was left standing when the window shut. -->
<section class="mt-8 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-4">Sieges past</h2>
<ul class="space-y-4">
{{range .Siege.History}}
<li>
<div class="flex items-baseline justify-between gap-3">
<span class="font-semibold flex-1 min-w-0 truncate">{{.BossName}} <span class="text-xs font-normal text-[color:var(--ink)]/40">T{{.Tier}}</span></span>
<span class="siege-chip {{if .Won}}siege-chip-held{{else}}siege-chip-fell{{end}} shrink-0">{{if .Won}}town held{{else}}broke through{{end}}</span>
</div>
<div class="mt-1.5 siege-track siege-track-sm">
<div class="siege-fill {{if .Won}}siege-fill-spent{{end}}" style="width: {{.HPPercent}}%"></div>
</div>
<div class="mt-1.5 flex flex-wrap items-baseline justify-between gap-x-3 text-xs text-[color:var(--ink)]/50">
<span>
{{if .Won}}Felled by {{.Defenders}} defender{{if ne .Defenders 1}}s{{end}}{{else}}{{.HPRemaining}} HP still standing when the window shut{{end}}{{if .MVP}} · most bouts: <span class="font-semibold text-[color:var(--ink)]/70">{{.MVP}}</span>{{if .MVPFights}} ({{.MVPFights}}){{end}}{{end}}
</span>
{{if .When}}<span class="shrink-0">{{.When}}</span>{{end}}
</div>
</li>
{{end}}
</ul>
</section>
{{end}}
</article>
<script>
// The war room is state, like the board — an open tab should stay honest without
// a reload. Two moving parts:
//
// 1. The countdown, which ticks locally every second off the pushed ends_at.
// No network for that; the deadline is fixed the moment the Siege spawns.
// 2. The pool, re-polled every 15s. The bar's width is set here and CSS does
// the animating (a width transition), so a poll that lands a lower pool
// *slides* rather than snapping. That slide is the whole reason this page
// exists: it is what turns a number into the town chipping something down.
(function () {
var root = document.getElementById('siege');
if (!root) return;
var active = root.getAttribute('data-active') === '1';
var endsAt = parseInt(root.getAttribute('data-ends-at') || '0', 10);
function txt(id, v) { var el = document.getElementById(id); if (el && v != null) el.textContent = v; }
function tickCountdown() {
var el = document.getElementById('siege-countdown');
if (!el) return;
if (!endsAt) { el.textContent = '—'; return; }
var left = endsAt - Math.floor(Date.now() / 1000);
if (left <= 0) { el.textContent = 'window closed'; return; }
var h = Math.floor(left / 3600), m = Math.floor((left % 3600) / 60), s = left % 60;
el.textContent = h > 0 ? (h + 'h ' + m + 'm') : (m + 'm ' + s + 's');
}
if (!active) return; // nothing camped: no countdown, no poll, nothing to move
tickCountdown();
setInterval(tickCountdown, 1000);
var pollTimer = null;
function refresh() {
fetch('/api/adventure/siege', { headers: { 'Accept': 'application/json' } })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (data) {
if (!data) return;
if (!data.active) {
// It resolved while we were watching. The page we're holding is now a
// different page — reload rather than fake an ending, so the result and
// the fresh history come from the server that knows them.
if (pollTimer) clearInterval(pollTimer);
window.location.reload();
return;
}
var bar = document.getElementById('siege-bar');
if (bar) bar.style.width = data.hp_percent + '%';
var track = document.getElementById('siege-bar-track');
if (track) track.setAttribute('aria-valuenow', data.hp_percent);
txt('siege-hp', data.hp_current);
txt('siege-hpmax', data.hp_max);
txt('siege-pct', data.hp_percent);
txt('siege-bouts', data.bouts_today);
txt('siege-mustered', data.mustered);
if (data.ends_at) endsAt = data.ends_at;
})
.catch(function () { /* transient — the next tick will do */ });
}
pollTimer = setInterval(refresh, 15000);
})();
</script>
{{end}}