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
This commit is contained in:
prosolis
2026-07-24 17:54:49 -07:00
parent b4a276da36
commit 1dfd3ac9fb
12 changed files with 1745 additions and 7 deletions
+369
View File
@@ -0,0 +1,369 @@
package storage
import (
"database/sql"
)
// The realm, as gogobee pushes it.
//
// Three pages ride one snapshot: the world map, the board, and the hall of
// firsts. They are one push rather than three because they are one *question* —
// "what is this place, and what has happened here" — and because every number in
// all three comes off the same scan of the same run history. Splitting them would
// mean three ways for the same fact to be a different number depending on which
// page you were looking at.
//
// Nothing here is an event. The events (a zone_first dispatch, a death) come down
// the dispatch queue like any other fact. This is the standing state of the world,
// which is the only kind of thing a map can honestly draw.
// RealmOccupant is somebody on an expedition in a zone right now. Token is the
// public board token and is EMPTY only in the sense that this row never exists
// for an opted-out player — unlike a siege contributor, presence is dropped
// outright upstream rather than anonymised, so every row here has a name and a
// link.
type RealmOccupant struct {
Token string `json:"token,omitempty"`
Name string `json:"name"`
Level int `json:"level,omitempty"`
Day int `json:"day,omitempty"`
}
// RealmZone is one place in the world: what it is, who first got through it, how
// many have since, and who is inside it.
//
// FirstBy with an empty FirstToken is the anonymised case — the zone HAS been
// cleared and the claim stands, but the clearer opted out and gets no name and no
// link. Clears > 0 with no FirstBy at all is the same state seen from the other
// side, and both render as "cleared, by somebody" rather than as never-cleared,
// which would be a false statement about the realm rather than a withheld one.
type RealmZone struct {
ID string `json:"id"`
Display string `json:"display"`
Tier int `json:"tier"`
LevelMin int `json:"level_min"`
LevelMax int `json:"level_max"`
Faction string `json:"faction,omitempty"`
Atmosphere string `json:"atmosphere,omitempty"`
Postgame bool `json:"postgame,omitempty"`
FirstClearBy string `json:"first_clear_by,omitempty"`
FirstClearToken string `json:"first_clear_token,omitempty"`
FirstClearAt int64 `json:"first_clear_at,omitempty"`
Clears int `json:"clears"`
Clearers int `json:"clearers"`
Occupants []RealmOccupant `json:"occupants,omitempty"`
}
// RealmFirst is one entry in the hall of firsts.
type RealmFirst struct {
Kind string `json:"kind"`
Target string `json:"target"`
Display string `json:"display"`
Tier int `json:"tier,omitempty"`
Holder string `json:"holder,omitempty"`
Token string `json:"token,omitempty"`
AtUnix int64 `json:"at_unix"`
}
// RealmStanding is one line on the board.
type RealmStanding struct {
Token string `json:"token,omitempty"`
Name string `json:"name"`
Level int `json:"level"`
ClassRace string `json:"class_race,omitempty"`
DeepestTier int `json:"deepest_tier"`
Clears int `json:"clears"`
Zones int `json:"zones"`
Firsts int `json:"firsts"`
SiegeDamage int `json:"siege_damage"`
SiegeFights int `json:"siege_fights"`
}
// Realm is the whole snapshot.
type Realm struct {
Zones []RealmZone `json:"zones,omitempty"`
Firsts []RealmFirst `json:"firsts,omitempty"`
Standings []RealmStanding `json:"standings,omitempty"`
SnapshotAt int64 `json:"snapshot_at"`
}
// ReplaceRealm swaps the whole realm for a new snapshot, in one transaction.
//
// Replace, never merge, for the reason the siege does it: a zone whose clear
// count was corrected upstream, a player who opted out, an occupant who came
// home — all of those are *removals*, and a merge has no way to express one. The
// transaction means a reader mid-swap sees the old realm or the new one, never a
// zone list with the previous board under it.
func ReplaceRealm(r Realm, snapshotAt int64) error {
tx, err := Get().Begin()
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
for _, t := range []string{
"adventure_realm_zone",
"adventure_realm_occupant",
"adventure_realm_first",
"adventure_realm_standing",
} {
if _, err := tx.Exec(`DELETE FROM ` + t); err != nil {
return err
}
}
zstmt, err := tx.Prepare(`
INSERT INTO adventure_realm_zone
(pos, zone_id, display, tier, level_min, level_max, faction, atmosphere,
postgame, first_by, first_token, first_at, clears, clearers)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
if err != nil {
return err
}
defer zstmt.Close()
ostmt, err := tx.Prepare(`
INSERT INTO adventure_realm_occupant (pos, zone_id, token, name, level, day)
VALUES (?, ?, ?, ?, ?, ?)`)
if err != nil {
return err
}
defer ostmt.Close()
opos := 0
for i, z := range r.Zones {
if _, err := zstmt.Exec(i, z.ID, z.Display, z.Tier, z.LevelMin, z.LevelMax,
z.Faction, z.Atmosphere, z.Postgame, z.FirstClearBy, z.FirstClearToken,
z.FirstClearAt, z.Clears, z.Clearers); err != nil {
return err
}
for _, o := range z.Occupants {
if _, err := ostmt.Exec(opos, z.ID, o.Token, o.Name, o.Level, o.Day); err != nil {
return err
}
opos++
}
}
fstmt, err := tx.Prepare(`
INSERT INTO adventure_realm_first (pos, kind, target, display, tier, holder, token, at_unix)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
if err != nil {
return err
}
defer fstmt.Close()
for i, f := range r.Firsts {
if _, err := fstmt.Exec(i, f.Kind, f.Target, f.Display, f.Tier, f.Holder, f.Token, f.AtUnix); err != nil {
return err
}
}
sstmt, err := tx.Prepare(`
INSERT INTO adventure_realm_standing
(pos, token, name, level, class_race, deepest_tier, clears, zones, firsts,
siege_damage, siege_fights)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
if err != nil {
return err
}
defer sstmt.Close()
for i, s := range r.Standings {
if _, err := sstmt.Exec(i, s.Token, s.Name, s.Level, s.ClassRace, s.DeepestTier,
s.Clears, s.Zones, s.Firsts, s.SiegeDamage, s.SiegeFights); err != nil {
return err
}
}
if _, err := tx.Exec(`
INSERT INTO adventure_realm_meta (id, snapshot_at) VALUES (1, ?)
ON CONFLICT(id) DO UPDATE SET snapshot_at = excluded.snapshot_at`, snapshotAt); err != nil {
return err
}
return tx.Commit()
}
// LoadRealm returns the realm as last pushed. ok is false when gogobee has never
// pushed one — distinct from a pushed snapshot that happens to be empty, which is
// a real answer (a realm with no living adventurers on the board is a thing that
// can be true) and which the pages render differently.
func LoadRealm() (Realm, bool, error) {
var r Realm
err := Get().QueryRow(`SELECT snapshot_at FROM adventure_realm_meta WHERE id = 1`).
Scan(&r.SnapshotAt)
if err == sql.ErrNoRows {
return Realm{}, false, nil
}
if err != nil {
return Realm{}, false, err
}
// Each cursor is drained fully before the next query opens. The pool is one
// connection wide, and a nested read is the deadlock the run-beat batch
// shipped with and then had to have cut out of it.
zones, err := loadRealmZones()
if err != nil {
return r, true, err
}
occ, err := loadRealmOccupants()
if err != nil {
return r, true, err
}
for i := range zones {
zones[i].Occupants = occ[zones[i].ID]
}
r.Zones = zones
if r.Firsts, err = loadRealmFirsts(); err != nil {
return r, true, err
}
if r.Standings, err = loadRealmStandings(); err != nil {
return r, true, err
}
return r, true, nil
}
func loadRealmZones() ([]RealmZone, error) {
rows, err := Get().Query(`
SELECT zone_id, display, tier, level_min, level_max, faction, atmosphere,
postgame, first_by, first_token, first_at, clears, clearers
FROM adventure_realm_zone ORDER BY pos ASC`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []RealmZone
for rows.Next() {
var z RealmZone
if err := rows.Scan(&z.ID, &z.Display, &z.Tier, &z.LevelMin, &z.LevelMax,
&z.Faction, &z.Atmosphere, &z.Postgame, &z.FirstClearBy, &z.FirstClearToken,
&z.FirstClearAt, &z.Clears, &z.Clearers); err != nil {
return nil, err
}
out = append(out, z)
}
return out, rows.Err()
}
func loadRealmOccupants() (map[string][]RealmOccupant, error) {
rows, err := Get().Query(`
SELECT zone_id, token, name, level, day
FROM adventure_realm_occupant ORDER BY pos ASC`)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[string][]RealmOccupant{}
for rows.Next() {
var zoneID string
var o RealmOccupant
if err := rows.Scan(&zoneID, &o.Token, &o.Name, &o.Level, &o.Day); err != nil {
return nil, err
}
out[zoneID] = append(out[zoneID], o)
}
return out, rows.Err()
}
func loadRealmFirsts() ([]RealmFirst, error) {
rows, err := Get().Query(`
SELECT kind, target, display, tier, holder, token, at_unix
FROM adventure_realm_first ORDER BY pos ASC`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []RealmFirst
for rows.Next() {
var f RealmFirst
if err := rows.Scan(&f.Kind, &f.Target, &f.Display, &f.Tier, &f.Holder, &f.Token, &f.AtUnix); err != nil {
return nil, err
}
out = append(out, f)
}
return out, rows.Err()
}
func loadRealmStandings() ([]RealmStanding, error) {
rows, err := Get().Query(`
SELECT token, name, level, class_race, deepest_tier, clears, zones, firsts,
siege_damage, siege_fights
FROM adventure_realm_standing ORDER BY pos ASC`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []RealmStanding
for rows.Next() {
var s RealmStanding
if err := rows.Scan(&s.Token, &s.Name, &s.Level, &s.ClassRace, &s.DeepestTier,
&s.Clears, &s.Zones, &s.Firsts, &s.SiegeDamage, &s.SiegeFights); err != nil {
return nil, err
}
out = append(out, s)
}
return out, rows.Err()
}
// DeathsBySubject counts the deaths Pete has reported for each named adventurer.
//
// This is the one standings number that does NOT come off the gogobee push, and
// the reason is that the game has nowhere to read it from: a character carries
// its most recent death (source, place, date) and no running total, so there is
// no lifetime count on the game box to send. Pete does have one — his own back
// catalogue of death dispatches, seeded at news launch by the backfill and
// complete since — so he counts them himself, which is a thing a newspaper is
// entitled to do about its own reporting.
//
// Keyed on the character name because that is the only join the fact table
// offers: a dispatch carries a name, never a token. Names are unique per realm in
// practice; a collision would merge two adventurers' death counts, which is why
// this is the only column derived this way and not, say, clears.
func DeathsBySubject() (map[string]int, error) {
rows, err := Get().Query(`
SELECT subject, COUNT(*) FROM adventure_events
WHERE event_type = 'death' AND subject IS NOT NULL AND subject <> ''
GROUP BY subject`)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[string]int{}
for rows.Next() {
var name string
var n int
if err := rows.Scan(&name, &n); err != nil {
return nil, err
}
out[name] = n
}
return out, rows.Err()
}
// PeteDuelRecord is Pete's own won/lost tally, from the dispatches he filed about
// himself. He is a companion who can be hired onto an expedition and he has a
// record; keeping score on himself is in voice, and it is the one line on the
// board that is not about a player.
//
// Both types have had templates in the renderer since before anything emitted
// them, so this reads zero until gogobee starts filing them — and zero-zero is
// rendered as "no bouts yet" rather than as a 0% win rate.
func PeteDuelRecord() (wins, losses int, err error) {
err = Get().QueryRow(`
SELECT
COALESCE(SUM(CASE WHEN event_type = 'pete_duel_win' THEN 1 ELSE 0 END), 0),
COALESCE(SUM(CASE WHEN event_type = 'pete_duel_loss' THEN 1 ELSE 0 END), 0)
FROM adventure_events
WHERE event_type IN ('pete_duel_win', 'pete_duel_loss')`).Scan(&wins, &losses)
if err == sql.ErrNoRows {
return 0, 0, nil
}
return wins, losses, err
}
+84
View File
@@ -212,6 +212,90 @@ CREATE TABLE IF NOT EXISTS adventure_run_beat (
PRIMARY KEY (run_id, seq)
);
-- The realm: the world map, the hall of firsts, and the board. Four tables from
-- one gogobee push, all replaced whole — the same contract as the roster and the
-- Siege, and for the same reason. Every row here is a *derived* answer (how many
-- clears, who was first, who is inside right now) recomputed on the game box from
-- its own run history. Pete keeping a stale one and merging into it would let a
-- correction upstream leave a wrong number here permanently.
--
-- Unlike the Siege there is no live/history lifetime split, because none of this
-- has a lifetime: a zone does not end. What varies is only how often it changes,
-- and that is handled on the gogobee side by pushing every ten minutes instead of
-- every two.
--
-- pos is the push order throughout, kept as the key for the same reason the siege
-- muster does: an opted-out player carries NO token, so several rows can
-- legitimately be tokenless and must not collide on one.
CREATE TABLE IF NOT EXISTS adventure_realm_zone (
pos INTEGER PRIMARY KEY, -- gogobee's design-doc zone order
zone_id TEXT NOT NULL,
display TEXT NOT NULL,
tier INTEGER NOT NULL DEFAULT 0,
level_min INTEGER NOT NULL DEFAULT 0,
level_max INTEGER NOT NULL DEFAULT 0,
faction TEXT NOT NULL DEFAULT '',
atmosphere TEXT NOT NULL DEFAULT '',
postgame INTEGER NOT NULL DEFAULT 0,
first_by TEXT NOT NULL DEFAULT '',
first_token TEXT NOT NULL DEFAULT '',
first_at INTEGER NOT NULL DEFAULT 0,
clears INTEGER NOT NULL DEFAULT 0,
clearers INTEGER NOT NULL DEFAULT 0
);
-- Who is standing in a zone right now. Its own table rather than a JSON blob on
-- the zone row so the map can be drawn with one join and "there are people in
-- there" is a count, not a parse.
CREATE TABLE IF NOT EXISTS adventure_realm_occupant (
pos INTEGER PRIMARY KEY,
zone_id TEXT NOT NULL,
token TEXT NOT NULL DEFAULT '',
name TEXT NOT NULL,
level INTEGER NOT NULL DEFAULT 0,
day INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_adv_realm_occ_zone ON adventure_realm_occupant(zone_id);
-- The hall of firsts: every thing that has happened in the realm exactly once.
-- holder is empty when the game can no longer say who did it (a treasure found
-- and later discarded leaves no owner anywhere) — an unattributed first is still
-- a first and is rendered as one.
CREATE TABLE IF NOT EXISTS adventure_realm_first (
pos INTEGER PRIMARY KEY, -- gogobee's order: oldest first
kind TEXT NOT NULL, -- "zone" | "treasure" | whatever comes next
target TEXT NOT NULL,
display TEXT NOT NULL,
tier INTEGER NOT NULL DEFAULT 0,
holder TEXT NOT NULL DEFAULT '',
token TEXT NOT NULL DEFAULT '',
at_unix INTEGER NOT NULL DEFAULT 0
);
-- The board. pos IS the rank, and the ranking is gogobee's — the ordering is a
-- statement about what the game values, and the game gets to make it.
CREATE TABLE IF NOT EXISTS adventure_realm_standing (
pos INTEGER PRIMARY KEY,
token TEXT NOT NULL DEFAULT '',
name TEXT NOT NULL,
level INTEGER NOT NULL DEFAULT 0,
class_race TEXT NOT NULL DEFAULT '',
deepest_tier INTEGER NOT NULL DEFAULT 0,
clears INTEGER NOT NULL DEFAULT 0,
zones INTEGER NOT NULL DEFAULT 0,
firsts INTEGER NOT NULL DEFAULT 0,
siege_damage INTEGER NOT NULL DEFAULT 0,
siege_fights INTEGER NOT NULL DEFAULT 0
);
-- One row, like adventure_siege: when the realm last arrived. Its own table
-- because "gogobee has never pushed a realm" and "gogobee pushed a realm that is
-- empty" are different states, and the page says different things about them.
CREATE TABLE IF NOT EXISTS adventure_realm_meta (
id INTEGER PRIMARY KEY CHECK (id = 1),
snapshot_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
+462
View File
@@ -0,0 +1,462 @@
package web
import (
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"sort"
"time"
"pete/internal/storage"
)
// The realm pages: the world map, the board, and the hall of firsts.
//
// Everything Pete has published so far is either the present moment (the roster,
// the Siege bar) or one thing that happened (a dispatch, a run report). None of
// it says what this place IS. A visitor who reads every dispatch on the site
// still cannot answer "how many zones are there", "is the Drowned Star harder
// than the Sunken Vault", or "has anybody ever actually beaten it" — and those
// are the questions that turn a feed of incidents into a world.
//
// The three pages are one snapshot because they are one question. Every number
// on all three comes off the same scan of the same run history on the game box;
// splitting the wire would mean three ways for the same fact to disagree with
// itself depending on which page you were standing on.
//
// The map is deliberately NOT who_map.go's layout engine, which the plan
// expected it to be. That engine lays out a *graph* — nodes joined by edges, at
// BFS depth from an entrance — and the realm has no edges. Zones are not
// connected to each other; you pick one from town and go. Forcing a graph layout
// onto a set would have produced a picture that implied a topology the game does
// not have. What the realm has instead is an *order*, by difficulty, and that is
// what gets drawn: tier bands, hardest last.
const (
// realmStaleAfter — how old the snapshot may get before the pages stop
// claiming the occupant dots are live. gogobee recomputes the realm every ten
// minutes rather than every two (it is aggregate scans over the whole run
// history, and a first clear does not move), so the window is proportionally
// wider: several missed pushes, not one unlucky one.
realmStaleAfter = 45 * time.Minute
// Payload bounds. A realm has tens of zones, tens of players, and a first per
// zone plus a first per treasure; these only stop a malformed or hostile push
// spooling unbounded rows.
realmMaxZones = 500
realmMaxFirsts = 5000
realmMaxStandings = 1000
)
// realmPush is the payload gogobee POSTs to /api/ingest/realm.
type realmPush struct {
SnapshotAt int64 `json:"snapshot_at"`
storage.Realm
}
// RealmZoneView is one zone as the map draws it: gogobee's facts plus the few
// presentational calls Pete is allowed to make.
type RealmZoneView struct {
storage.RealmZone
Cleared bool // anybody, ever
Unbeaten bool // nobody, ever — the ominous state
FirstWhen string // "Mar 4, 2026", empty when unknown
Levels string // "levels 58", or "level 5" when the band is one wide
Busy bool // somebody is in there right now
}
// RealmTierView is one difficulty band of the map. The band is the unit the page
// draws in, because difficulty order is the only real structure the realm has.
type RealmTierView struct {
Tier int
Label string
Blurb string
Postgame bool
Zones []RealmZoneView
Cleared int // zones in this band somebody has beaten
}
// RealmView is the map page.
type RealmView struct {
Known bool // gogobee has pushed at least one snapshot
Stale bool
Tiers []RealmTierView
ZoneCount int
ClearedZones int
Unbeaten int
OutThere int // adventurers on expedition right now, across the whole realm
SnapshotAt int64
LastSeenAgo string
}
// RealmStandingView is one line of the board.
type RealmStandingView struct {
storage.RealmStanding
Rank int
Deaths int // Pete's own count, from the dispatches he filed — see DeathsBySubject
}
// StandingsView is the board page.
type StandingsView struct {
Known bool
Stale bool
Rows []RealmStandingView
PeteWins int
PeteLosses int
PeteFought bool // he has a record at all; zero-zero renders as "no bouts yet"
SnapshotAt int64
LastSeenAgo string
}
// RealmFirstView is one entry in the hall.
type RealmFirstView struct {
storage.RealmFirst
When string
Kind string // the raw kind, kept for the CSS hook
Label string // "First through" / "First to hold" — reads as a sentence
}
// FirstsView is the hall of firsts page, grouped by year so a long ledger reads
// as a history rather than as a list.
type FirstsView struct {
Known bool
Stale bool
Years []RealmFirstYear
Total int
Zones int
Others int
SnapshotAt int64
LastSeenAgo string
}
// RealmFirstYear is one year's worth of firsts, newest year first.
type RealmFirstYear struct {
Year int
Firsts []RealmFirstView
}
type realmPage struct {
pageData
Realm RealmView
}
type standingsPage struct {
pageData
Standings StandingsView
}
type firstsPage struct {
pageData
Firsts FirstsView
}
// realmTierLabels names the difficulty bands. gogobee's zone tiers are 16 and
// the sixth is the postgame; the labels are the game's own words for them.
var realmTierLabels = map[int]struct{ label, blurb string }{
1: {"Tier I · The Outskirts", "Where everybody starts. Close enough to town to walk back from."},
2: {"Tier II · The Reaches", "Further out, and the road stops being a road."},
3: {"Tier III · The Deep Country", "Long enough that you camp. Bring supplies you don't think you'll need."},
4: {"Tier IV · The Far Places", "Multi-region crossings. People come back from these different."},
5: {"Tier V · The Last Doors", "The end of the map as it was drawn. Very few have seen all of these."},
6: {"Mythic · The Postgame", "Sealed until you're level 18 and have put down both Tier V bosses. It does not get easier past here."},
}
// handleRealmIngest replaces the realm with gogobee's latest snapshot.
func (s *Server) handleRealmIngest(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 realmPush
if err := json.NewDecoder(io.LimitReader(r.Body, 4<<20)).Decode(&push); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
if len(push.Zones) > realmMaxZones {
http.Error(w, "zone list too large", http.StatusBadRequest)
return
}
if len(push.Firsts) > realmMaxFirsts {
http.Error(w, "firsts ledger too large", http.StatusBadRequest)
return
}
if len(push.Standings) > realmMaxStandings {
http.Error(w, "standings too large", http.StatusBadRequest)
return
}
if push.SnapshotAt <= 0 {
push.SnapshotAt = time.Now().Unix()
}
// Never trust the channel with a name. A nameless row renders as a blank line
// on a public page, so it is rejected rather than drawn — the same rule the
// siege muster applies to its defenders.
for i, z := range push.Zones {
if z.ID == "" || z.Display == "" {
http.Error(w, fmt.Sprintf("zone %d: id and display are required", i), http.StatusBadRequest)
return
}
for j, o := range z.Occupants {
if o.Name == "" {
http.Error(w, fmt.Sprintf("zone %d occupant %d: name is required", i, j), http.StatusBadRequest)
return
}
}
}
for i, st := range push.Standings {
if st.Name == "" {
http.Error(w, fmt.Sprintf("standing %d: name is required", i), http.StatusBadRequest)
return
}
}
// A first with no display would render as an empty row in the history book.
// Unlike a name this one Pete can repair himself — gogobee already falls back
// to the raw target for a kind it has no words for, and doing the same here
// means a future first kind can never blank a row.
for i := range push.Firsts {
if push.Firsts[i].Display == "" {
push.Firsts[i].Display = push.Firsts[i].Target
}
if push.Firsts[i].Display == "" {
http.Error(w, fmt.Sprintf("first %d: target is required", i), http.StatusBadRequest)
return
}
}
if err := storage.ReplaceRealm(push.Realm, push.SnapshotAt); err != nil {
slog.Error("realm ingest: replace failed", "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
slog.Info("realm ingest: realm replaced",
"zones", len(push.Zones), "firsts", len(push.Firsts), "standings", len(push.Standings))
w.WriteHeader(http.StatusOK)
}
// loadRealmSnapshot reads the snapshot once and reports staleness. All three
// pages go through it so they can never disagree about how old the realm is.
func (s *Server) loadRealmSnapshot() (storage.Realm, bool, bool) {
snap, known, err := storage.LoadRealm()
if err != nil {
slog.Error("realm: load failed", "err", err)
return storage.Realm{}, false, true
}
stale := !known || snap.SnapshotAt == 0 ||
time.Since(time.Unix(snap.SnapshotAt, 0)) > realmStaleAfter
return snap, known, stale
}
// handleRealmPage serves the world map.
func (s *Server) handleRealmPage(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"
s.render(w, "realm", realmPage{pageData: base, Realm: s.realm()})
}
// realm builds the map view.
func (s *Server) realm() RealmView {
snap, known, stale := s.loadRealmSnapshot()
v := RealmView{Known: known, Stale: stale, SnapshotAt: snap.SnapshotAt}
if snap.SnapshotAt > 0 {
v.LastSeenAgo = shortTimeAgo(time.Unix(snap.SnapshotAt, 0))
}
byTier := map[int][]RealmZoneView{}
for _, z := range snap.Zones {
zv := RealmZoneView{
RealmZone: z,
// Cleared is Clears > 0, NOT "FirstClearBy is set". The two come
// apart exactly when the first clearer opted out: the zone has been
// beaten and the claim stands, it just has no name on it. Keying the
// ominous never-beaten styling off the name would make an
// anonymisation look like a fact about the world.
Cleared: z.Clears > 0,
Busy: len(z.Occupants) > 0,
Levels: levelBand(z.LevelMin, z.LevelMax),
}
zv.Unbeaten = !zv.Cleared
if z.FirstClearAt > 0 {
zv.FirstWhen = time.Unix(z.FirstClearAt, 0).UTC().Format("Jan 2, 2006")
}
byTier[z.Tier] = append(byTier[z.Tier], zv)
v.ZoneCount++
if zv.Cleared {
v.ClearedZones++
} else {
v.Unbeaten++
}
v.OutThere += len(z.Occupants)
}
tiers := make([]int, 0, len(byTier))
for t := range byTier {
tiers = append(tiers, t)
}
sort.Ints(tiers)
for _, t := range tiers {
tv := RealmTierView{Tier: t, Zones: byTier[t], Postgame: t >= 6}
if lbl, ok := realmTierLabels[t]; ok {
tv.Label, tv.Blurb = lbl.label, lbl.blurb
} else {
// A tier the labels don't know about still draws, with an honest
// generic heading rather than an empty one. Same degrade-don't-drop
// rule as an untemplated dispatch.
tv.Label = fmt.Sprintf("Tier %d", t)
}
for _, z := range tv.Zones {
if z.Cleared {
tv.Cleared++
}
}
v.Tiers = append(v.Tiers, tv)
}
return v
}
// handleStandingsPage serves the board.
func (s *Server) handleStandingsPage(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"
s.render(w, "standings", standingsPage{pageData: base, Standings: s.standings()})
}
// standings builds the board view.
//
// The rank is gogobee's push order, not anything computed here: the ordering
// ("deepest tier beaten, then how much of the realm you have beaten") is a
// statement about what the game values, and the game is the thing entitled to
// make it. Pete's job is to draw it and to add the two columns the game box
// cannot answer — the death count and Pete's own record.
func (s *Server) standings() StandingsView {
snap, known, stale := s.loadRealmSnapshot()
v := StandingsView{Known: known, Stale: stale, SnapshotAt: snap.SnapshotAt}
if snap.SnapshotAt > 0 {
v.LastSeenAgo = shortTimeAgo(time.Unix(snap.SnapshotAt, 0))
}
deaths, err := storage.DeathsBySubject()
if err != nil {
// A missing death column is a missing column. It is not worth failing the
// whole board over, and a zero would be a lie, so the template renders a
// dash for anyone not in the map — which is what an absent map produces.
slog.Error("standings: death counts", "err", err)
deaths = nil
}
for i, st := range snap.Standings {
v.Rows = append(v.Rows, RealmStandingView{
RealmStanding: st,
Rank: i + 1,
Deaths: deaths[st.Name],
})
}
if w, l, err := storage.PeteDuelRecord(); err != nil {
slog.Error("standings: pete duel record", "err", err)
} else {
v.PeteWins, v.PeteLosses = w, l
v.PeteFought = w+l > 0
}
return v
}
// handleFirstsPage serves the hall of firsts.
func (s *Server) handleFirstsPage(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"
s.render(w, "firsts", firstsPage{pageData: base, Firsts: s.firsts()})
}
// firsts builds the hall.
//
// gogobee pushes the ledger oldest-first, which is the order it happened in. The
// page reverses it into newest-year-first, because a history book that opens on
// the oldest page is an archive and this is meant to read as "look what has been
// happening" — but within a year it stays chronological, so a year reads forward
// the way a year did.
func (s *Server) firsts() FirstsView {
snap, known, stale := s.loadRealmSnapshot()
v := FirstsView{Known: known, Stale: stale, SnapshotAt: snap.SnapshotAt}
if snap.SnapshotAt > 0 {
v.LastSeenAgo = shortTimeAgo(time.Unix(snap.SnapshotAt, 0))
}
byYear := map[int][]RealmFirstView{}
for _, f := range snap.Firsts {
fv := RealmFirstView{RealmFirst: f, Kind: f.Kind}
switch f.Kind {
case "zone":
fv.Label = "First through"
case "treasure":
fv.Label = "First to hold"
default:
fv.Label = "First"
}
year := 0
if f.AtUnix > 0 {
t := time.Unix(f.AtUnix, 0).UTC()
fv.When = t.Format("Jan 2, 2006")
year = t.Year()
}
byYear[year] = append(byYear[year], fv)
v.Total++
if f.Kind == "zone" {
v.Zones++
} else {
v.Others++
}
}
years := make([]int, 0, len(byYear))
for y := range byYear {
years = append(years, y)
}
// Newest year first. Year 0 is "the ledger has no date for this", which
// sorts last — an undated first is real but it is not news.
sort.Sort(sort.Reverse(sort.IntSlice(years)))
for _, y := range years {
v.Years = append(v.Years, RealmFirstYear{Year: y, Firsts: byYear[y]})
}
return v
}
// levelBand renders a zone's level range as words. A one-wide band ("levels
// 55") reads as a typo, so it collapses to "level 5"; a band with no numbers at
// all renders as nothing rather than as "levels 00".
func levelBand(min, max int) string {
switch {
case min <= 0 && max <= 0:
return ""
case min == max:
return fmt.Sprintf("level %d", min)
case min <= 0:
return fmt.Sprintf("up to level %d", max)
case max <= 0:
return fmt.Sprintf("level %d and up", min)
default:
return fmt.Sprintf("levels %d%d", min, max)
}
}
+362
View File
@@ -0,0 +1,362 @@
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)
}
}
+14 -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", "siege", "run_report"}},
{"layout", []string{"_card", "_realmnav"}, []string{"index", "channel", "weather", "bookmarks", "for-you", "status", "story", "who", "siege", "run_report", "realm", "standings", "firsts"}},
{"games_layout", []string{"_chipbar"}, []string{"games", "games_door", "blackjack", "hangman", "solitaire", "trivia", "uno", "holdem"}},
}
tpls := make(map[string]*template.Template)
@@ -251,6 +251,19 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo
mux.HandleFunc("GET /api/adventure/siege", s.handleSiegeAPI)
mux.HandleFunc("GET /adventure/siege", s.handleSiegePage)
// The realm: the world map, the board, and the hall of firsts. One
// bearer-authed ingest behind all three, and all three public — every number
// on them is already public on the board or in a dispatch.
//
// Same literal-two-segment reasoning as /adventure/siege: "realm",
// "standings" and "firsts" beat /adventure/{guid} on Go's most-specific-match
// rule, and nothing is shadowed because a dispatch guid is
// "<type>:<hash>:<ts>" and can never be a bare word.
mux.HandleFunc("POST /api/ingest/realm", s.handleRealmIngest)
mux.HandleFunc("GET /adventure/realm", s.handleRealmPage)
mux.HandleFunc("GET /adventure/standings", s.handleStandingsPage)
mux.HandleFunc("GET /adventure/firsts", s.handleFirstsPage)
// 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).
+133
View File
@@ -2864,4 +2864,137 @@ html[data-room] .pete-felt {
the document instead of trapping the whole run in a 26rem window that a
reader has to find the edge of before they can move through it. */
.runlog-full { max-height: none; overflow-y: visible; }
/* ── The realm: map, board, hall of firsts ──────────────────────────────
These are hand-written component classes, not generated utilities, and that
matters: a Tailwind class built from a template value gets purged out of the
stylesheet and fails SILENTLY. .standings-tier-{{.DeepestTier}} is exactly
that shape, so all six variants are spelled out below rather than composed.
If a seventh tier ever ships, it needs a line here.
The realm's accent is the adventure purple, unlike the Siege's ember — the
Siege is an emergency, the realm is the standing shape of the world. */
/* Tabs across the four standing pages. */
.realm-tab {
font-weight: 600;
color: color-mix(in srgb, var(--ink) 55%, transparent);
border-bottom: 2px solid transparent;
padding-bottom: 0.15rem;
transition: color 0.15s ease, border-color 0.15s ease;
}
.realm-tab:hover { color: var(--ink); }
.realm-tab-on {
color: #6d4bd8;
border-bottom-color: #6d4bd8;
}
/* The one place in this block a raw purple is set as a foreground rather than
mixed into --ink, so the one place that needs the night override the
existing .text-theme-adventure rule already carries. Everything else here
goes through color-mix and lands readable in all four phases by itself. */
html[data-phase="night"] .realm-tab-on {
color: #baa9eb;
border-bottom-color: #baa9eb;
}
/* A zone card. The default is a place people go: readable, unremarkable. */
.realm-zone {
border-radius: 1.25rem;
padding: 1rem 1.15rem;
background: var(--card);
border: 2px solid color-mix(in srgb, var(--ink) 10%, transparent);
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
transition: border-color 0.15s ease, transform 0.15s ease;
}
.realm-zone:hover { transform: translateY(-1px); }
/* A zone NOBODY has ever cleared. This is the one piece of styling on the page
that has to carry a fact on its own, without being read: a visitor scanning
the map should see where the realm ends before they read a word of it.
Desaturated, dashed, recessed — the opposite of the busy state below. */
.realm-zone-unbeaten {
background: color-mix(in srgb, var(--ink) 5%, var(--card));
border-color: color-mix(in srgb, var(--ink) 18%, transparent);
border-style: dashed;
}
.realm-unbeaten-line {
color: color-mix(in srgb, var(--ink) 52%, transparent);
font-style: italic;
letter-spacing: 0.01em;
}
/* Somebody is in there right now. It wins over the unbeaten styling by source
order, deliberately: a party currently inside a place nobody has ever beaten
is the most interesting card on the page and should read as live, not dead. */
.realm-zone-busy {
border-color: color-mix(in srgb, #6d4bd8 45%, transparent);
border-style: solid;
background: color-mix(in srgb, #6d4bd8 5%, var(--card));
}
/* The postgame band, drawn apart from the rest of the map: it is gated content
and the page should look like it changes character there. */
.realm-band-postgame {
background: color-mix(in srgb, var(--ink) 6%, var(--card));
border: 2px dashed color-mix(in srgb, var(--ink) 20%, transparent);
}
/* The board's deepest-tier chip. Six literal classes on purpose — see the note
at the top of this block. The ramp runs cool-to-hot so a column of them
reads as a gradient of how far people have got. */
.standings-tier {
display: inline-flex; align-items: center;
font-size: 11px; font-weight: 700; line-height: 1;
border-radius: 9999px; padding: 0.25rem 0.5rem;
border: 1px solid transparent;
}
.standings-tier-1 { color: color-mix(in srgb, #6b7280 70%, var(--ink)); background: color-mix(in srgb, #6b7280 12%, var(--card)); border-color: color-mix(in srgb, #6b7280 30%, transparent); }
.standings-tier-2 { color: color-mix(in srgb, #3fa66a 65%, var(--ink)); background: color-mix(in srgb, #3fa66a 12%, var(--card)); border-color: color-mix(in srgb, #3fa66a 30%, transparent); }
.standings-tier-3 { color: color-mix(in srgb, #2f7fd0 65%, var(--ink)); background: color-mix(in srgb, #2f7fd0 12%, var(--card)); border-color: color-mix(in srgb, #2f7fd0 30%, transparent); }
.standings-tier-4 { color: color-mix(in srgb, #8b5cf6 65%, var(--ink)); background: color-mix(in srgb, #8b5cf6 12%, var(--card)); border-color: color-mix(in srgb, #8b5cf6 32%, transparent); }
.standings-tier-5 { color: color-mix(in srgb, #e0562f 65%, var(--ink)); background: color-mix(in srgb, #e0562f 13%, var(--card)); border-color: color-mix(in srgb, #e0562f 34%, transparent); }
.standings-tier-6 { color: color-mix(in srgb, #c9a227 72%, var(--ink)); background: color-mix(in srgb, #c9a227 15%, var(--card)); border-color: color-mix(in srgb, #c9a227 40%, transparent); font-weight: 800; }
/* A realm-first count. Gold, because it is the one number on the board that
can never go up for anybody else once it has been claimed. */
.standings-firsts {
color: color-mix(in srgb, #c9a227 72%, var(--ink));
font-weight: 700;
}
/* The hall of firsts: a ruled ledger with a spine, so a long list reads as a
record rather than as a feed. */
.firsts-ledger {
border-left: 2px solid color-mix(in srgb, var(--ink) 12%, transparent);
padding-left: 1.15rem;
}
.firsts-entry {
position: relative;
padding: 0.7rem 0;
border-bottom: 1px solid color-mix(in srgb, var(--ink) 7%, transparent);
}
.firsts-entry:last-child { border-bottom: 0; }
.firsts-entry::before {
content: "";
position: absolute;
left: -1.45rem; top: 1.15rem;
width: 0.5rem; height: 0.5rem;
border-radius: 9999px;
background: var(--first-dot, color-mix(in srgb, var(--ink) 25%, var(--card)));
}
/* The per-kind dot colour goes through a custom property rather than through
a `.firsts-entry-zone::before` rule, and that is a purge fix, not a style
preference. tailwind.config.js has input.css itself in its content glob, so
a hand-written component class survives the purge only when the extractor
can lift its literal name out of this file — and a class name glued to
`::before` does not extract. It fails SILENTLY, which is how it got noticed
here only because the rule was grepped for afterwards. Any new
`.realm-*`/`.firsts-*` variant wants a plain-selector declaration for the
same reason. */
.firsts-entry-zone { --first-dot: #6d4bd8; }
.firsts-entry-treasure { --first-dot: #c9a227; }
@media (prefers-reduced-motion: reduce) {
.realm-zone:hover { transform: none; }
}
}
File diff suppressed because one or more lines are too long
+21
View File
@@ -0,0 +1,21 @@
{{/* The realm nav, shared by the map, the board, the hall of firsts and the war
room, so the four read as one place rather than four orphans hanging off the
dispatch feed.
It lives in its own partial rather than in realm.html because each page gets
its own parsed template set (see server.go): a {{define}} in one page's file
is invisible to the others, and the only way to share a block is to list it
as a shared file. Passed the whole pageData, so it can mark the current tab
off .Path. */}}
{{define "realmnav"}}
<nav class="mb-5 flex flex-wrap items-center gap-x-4 gap-y-2 text-sm">
<a href="/adventure" class="inline-flex items-center gap-1.5 font-semibold text-[color:var(--ink)]/60 hover:text-[color:var(--ink)] transition">
<span aria-hidden="true"></span> All dispatches
</a>
<span class="text-[color:var(--ink)]/20" aria-hidden="true">·</span>
<a href="/adventure/realm" class="realm-tab{{if eq .Path "/adventure/realm"}} realm-tab-on{{end}}">The map</a>
<a href="/adventure/standings" class="realm-tab{{if eq .Path "/adventure/standings"}} realm-tab-on{{end}}">The board</a>
<a href="/adventure/firsts" class="realm-tab{{if eq .Path "/adventure/firsts"}} realm-tab-on{{end}}">Hall of firsts</a>
<a href="/adventure/siege" class="realm-tab{{if eq .Path "/adventure/siege"}} realm-tab-on{{end}}">The Siege</a>
</nav>
{{end}}
+76
View File
@@ -0,0 +1,76 @@
{{define "title"}}Hall of firsts — {{.SiteTitle}}{{end}}
{{define "main"}}
<article class="mt-2 mb-10 max-w-3xl mx-auto">
{{template "realmnav" .}}
<header class="rounded-3xl bg-theme-adventure text-white p-6 sm:p-10 shadow-pete relative overflow-hidden">
<div class="absolute -top-10 -right-6 text-[12rem] opacity-20 select-none" aria-hidden="true">📜</div>
<div class="relative">
<p class="text-sm uppercase tracking-[0.2em] opacity-80">📜 Hall of firsts</p>
<h1 class="font-display text-3xl sm:text-4xl font-bold mt-2 leading-tight">Everything that has only ever happened once.</h1>
<p class="mt-3 opacity-90 max-w-2xl">
The first time anyone walked out of a place alive. The first time a thing
came out of the ground. Each of these happened exactly once in the history
of the realm and can't happen again.
</p>
{{if .Firsts.Total}}
<div class="mt-6 flex flex-wrap gap-x-8 gap-y-3 text-xs uppercase tracking-wider opacity-85">
<span>Entries · <span class="font-semibold tabular-nums normal-case tracking-normal text-base">{{.Firsts.Total}}</span></span>
<span>Places opened · <span class="font-semibold tabular-nums normal-case tracking-normal text-base">{{.Firsts.Zones}}</span></span>
{{if .Firsts.Others}}<span>Things found · <span class="font-semibold tabular-nums normal-case tracking-normal text-base">{{.Firsts.Others}}</span></span>{{end}}
</div>
{{end}}
{{if and .Firsts.Known .Firsts.Stale}}
<p class="mt-4 text-xs bg-black/25 rounded-xl px-3 py-2">
Ledger as of {{.Firsts.LastSeenAgo}}. Nothing in a history book goes stale exactly, but a new entry might not be here yet.
</p>
{{end}}
</div>
</header>
{{if .Firsts.Years}}
{{range .Firsts.Years}}
<section class="mt-8">
<h2 class="font-display text-2xl font-bold mb-4 tabular-nums">
{{if .Year}}{{.Year}}{{else}}Before the records{{end}}
</h2>
<ol class="firsts-ledger">
{{range .Firsts}}
<li class="firsts-entry firsts-entry-{{.Kind}}">
<div class="flex flex-wrap items-baseline gap-x-2 gap-y-1">
<span class="text-[11px] uppercase tracking-wider text-[color:var(--ink)]/40 font-semibold">{{.Label}}</span>
<span class="font-display font-bold text-base">{{.Display}}</span>
{{if .Tier}}<span class="text-[11px] text-[color:var(--ink)]/35 tabular-nums">T{{.Tier}}</span>{{end}}
</div>
<p class="mt-0.5 text-sm text-[color:var(--ink)]/60">
{{if .Token}}<a href="/adventure/who/{{.Token}}" class="font-semibold text-[color:var(--ink)]/80 hover:text-theme-adventure transition">{{.Holder}}</a>
{{else if .Holder}}<span class="font-semibold text-[color:var(--ink)]/80">{{.Holder}}</span>
{{else}}<span class="italic text-[color:var(--ink)]/40">nobody left who'll admit to it</span>{{end}}
{{if .When}}<span class="text-[color:var(--ink)]/35"> · {{.When}}</span>{{end}}
</p>
</li>
{{end}}
</ol>
</section>
{{end}}
<p class="mt-8 text-xs text-[color:var(--ink)]/40 text-center max-w-xl mx-auto">
An entry with no name is one where the record has outlived the record-holder —
a thing found and long since given away, or somebody who'd rather I didn't say.
The claim still stands.
</p>
{{else}}
<div class="mt-6 rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 p-6 shadow-pete">
<p class="text-sm text-[color:var(--ink)]/60">
{{if .Firsts.Known}}
Nothing's happened for the first time yet. Everything is about to be a first.
{{else}}
Haven't had the ledger through from the field yet.
{{end}}
</p>
</div>
{{end}}
</article>
{{end}}
+113
View File
@@ -0,0 +1,113 @@
{{define "title"}}The realm — {{.SiteTitle}}{{end}}
{{/* One zone. The whole design problem of this page is in this block: a zone
nobody has ever beaten has to LOOK different, not just say so in small
text, because "nobody has ever done this" is the single most interesting
fact the realm has to offer. */}}
{{define "realmzone"}}
<li class="realm-zone{{if .Unbeaten}} realm-zone-unbeaten{{end}}{{if .Busy}} realm-zone-busy{{end}}">
<div class="flex items-baseline justify-between gap-3">
<h3 class="font-display font-bold text-base flex-1 min-w-0 truncate">{{.Display}}</h3>
{{if .Levels}}<span class="text-[11px] uppercase tracking-wider text-[color:var(--ink)]/40 shrink-0">{{.Levels}}</span>{{end}}
</div>
{{if .Atmosphere}}
<p class="mt-1 text-xs text-[color:var(--ink)]/55 leading-relaxed">{{.Atmosphere}}</p>
{{end}}
<div class="mt-2.5 text-xs">
{{if .Unbeaten}}
<p class="realm-unbeaten-line">Nobody has ever come back out of it.</p>
{{else}}
<p class="text-[color:var(--ink)]/60">
{{/* FirstClearBy empty with Clears > 0 is the anonymised case: the zone
has been beaten, the clearer opted out. It must not read as unbeaten. */}}
First through:
{{if .FirstClearToken}}<a href="/adventure/who/{{.FirstClearToken}}" class="font-semibold hover:text-theme-adventure transition">{{.FirstClearBy}}</a>
{{else if .FirstClearBy}}<span class="font-semibold">{{.FirstClearBy}}</span>
{{else}}<span class="italic text-[color:var(--ink)]/45">somebody who'd rather not say</span>{{end}}
{{if .FirstWhen}}<span class="text-[color:var(--ink)]/40"> · {{.FirstWhen}}</span>{{end}}
</p>
<p class="mt-0.5 text-[color:var(--ink)]/45 tabular-nums">
{{.Clears}} clear{{if ne .Clears 1}}s{{end}} by {{.Clearers}} adventurer{{if ne .Clearers 1}}s{{end}}
</p>
{{end}}
</div>
{{if .Occupants}}
<div class="mt-2.5 pt-2.5 border-t border-[color:var(--ink)]/10">
<p class="text-[11px] uppercase tracking-wider text-theme-adventure font-semibold">In there now</p>
<p class="mt-1 text-xs">
{{range $i, $o := .Occupants}}{{if $i}}<span class="text-[color:var(--ink)]/30">, </span>{{end}}<!--
-->{{if $o.Token}}<a href="/adventure/who/{{$o.Token}}" class="font-semibold hover:text-theme-adventure transition">{{$o.Name}}</a>{{else}}<span class="font-semibold">{{$o.Name}}</span>{{end}}<!--
-->{{if $o.Day}}<span class="text-[color:var(--ink)]/40"> (day {{$o.Day}})</span>{{end}}{{end}}
</p>
</div>
{{end}}
</li>
{{end}}
{{define "main"}}
<article class="mt-2 mb-10 max-w-5xl mx-auto">
{{template "realmnav" .}}
<header class="rounded-3xl bg-theme-adventure text-white p-6 sm:p-10 shadow-pete relative overflow-hidden">
<div class="absolute -top-10 -right-6 text-[12rem] opacity-20 select-none" aria-hidden="true">🗺️</div>
<div class="relative">
<p class="text-sm uppercase tracking-[0.2em] opacity-80">🗺️ The realm</p>
<h1 class="font-display text-3xl sm:text-4xl font-bold mt-2 leading-tight">Everywhere you can go, and what came back.</h1>
<p class="mt-3 opacity-90 max-w-2xl">
Every place in the world, in the order it gets harder. Some of these have been
walked a hundred times. Some of them have never been walked at all.
</p>
{{if .Realm.Known}}
<div class="mt-6 flex flex-wrap gap-x-8 gap-y-3 text-xs uppercase tracking-wider opacity-85">
<span>Places · <span class="font-semibold tabular-nums normal-case tracking-normal text-base">{{.Realm.ZoneCount}}</span></span>
<span>Beaten · <span class="font-semibold tabular-nums normal-case tracking-normal text-base">{{.Realm.ClearedZones}}</span></span>
<span>Never beaten · <span class="font-semibold tabular-nums normal-case tracking-normal text-base">{{.Realm.Unbeaten}}</span></span>
<span>Out there now · <span class="font-semibold tabular-nums normal-case tracking-normal text-base">{{.Realm.OutThere}}</span></span>
</div>
{{end}}
{{if and .Realm.Known .Realm.Stale}}
<p class="mt-4 text-xs bg-black/25 rounded-xl px-3 py-2">
The wire's been quiet — this is the realm as of {{.Realm.LastSeenAgo}}. The places
haven't moved, but who's out in them might have.
</p>
{{end}}
</div>
</header>
{{if not .Realm.Known}}
<div class="mt-6 rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 p-6 shadow-pete">
<p class="text-sm text-[color:var(--ink)]/60">
I haven't had the survey through from the field yet. When it lands, the whole
world lives on this page.
</p>
</div>
{{end}}
{{range .Realm.Tiers}}
<section class="mt-8{{if .Postgame}} realm-band-postgame rounded-3xl p-5 sm:p-6{{end}}">
<div class="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1 mb-1">
<h2 class="font-display text-xl sm:text-2xl font-bold">{{.Label}}</h2>
<span class="text-xs text-[color:var(--ink)]/45 tabular-nums shrink-0">
{{.Cleared}} of {{len .Zones}} beaten
</span>
</div>
{{if .Blurb}}<p class="text-sm text-[color:var(--ink)]/55 mb-4 max-w-2xl">{{.Blurb}}</p>{{end}}
<ul class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{{range .Zones}}{{template "realmzone" .}}{{end}}
</ul>
</section>
{{end}}
{{if .Realm.Known}}
<p class="mt-8 text-xs text-[color:var(--ink)]/40 text-center">
Say <code class="rounded bg-[color:var(--ink)]/8 px-1.5 py-0.5 font-mono">!expedition start</code>
to me in Matrix to pick one and go.
</p>
{{end}}
</article>
{{end}}
+3 -5
View File
@@ -22,11 +22,9 @@
{{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>
{{/* The war room joined the realm nav when the realm pages landed: it is one
of the four standing pages about this place, not a one-off. */}}
{{template "realmnav" .}}
{{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}}">
+107
View File
@@ -0,0 +1,107 @@
{{define "title"}}The board — {{.SiteTitle}}{{end}}
{{define "main"}}
<article class="mt-2 mb-10 max-w-4xl mx-auto">
{{template "realmnav" .}}
<header class="rounded-3xl bg-theme-adventure text-white p-6 sm:p-10 shadow-pete relative overflow-hidden">
<div class="absolute -top-10 -right-6 text-[12rem] opacity-20 select-none" aria-hidden="true">🏆</div>
<div class="relative">
<p class="text-sm uppercase tracking-[0.2em] opacity-80">🏆 The board</p>
<h1 class="font-display text-3xl sm:text-4xl font-bold mt-2 leading-tight">Who's actually been getting on with it.</h1>
<p class="mt-3 opacity-90 max-w-2xl">
Ranked by how deep anyone has got, then by how much of the realm they've
put behind them. Lifetime totals — nothing here decays, and nothing here
moves while you're not playing.
</p>
{{if and .Standings.Known .Standings.Stale}}
<p class="mt-4 text-xs bg-black/25 rounded-xl px-3 py-2">
Last count came in {{.Standings.LastSeenAgo}}. Nothing on this board moves fast, but it isn't live either.
</p>
{{end}}
</div>
</header>
{{if .Standings.Rows}}
<div class="mt-6 rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 shadow-pete overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="text-[11px] uppercase tracking-wider text-[color:var(--ink)]/45 border-b-2 border-[color:var(--ink)]/10">
<th class="text-left font-semibold px-4 py-3 w-10">#</th>
<th class="text-left font-semibold px-2 py-3">Adventurer</th>
<th class="text-right font-semibold px-2 py-3" title="The deepest tier they have actually beaten a boss in">Deepest</th>
<th class="text-right font-semibold px-2 py-3" title="Distinct zones cleared">Zones</th>
<th class="text-right font-semibold px-2 py-3" title="Total successful clears, repeats included">Clears</th>
<th class="text-right font-semibold px-2 py-3" title="Things nobody in the realm had ever done before">Firsts</th>
<th class="text-right font-semibold px-2 py-3" title="Total damage dealt to every Siege boss, all time">Siege</th>
<th class="text-right font-semibold px-4 py-3" title="Deaths I've reported">Deaths</th>
</tr>
</thead>
<tbody>
{{range .Standings.Rows}}
<tr class="border-b border-[color:var(--ink)]/5 last:border-0 hover:bg-[color:var(--ink)]/[0.03] transition">
<td class="px-4 py-3 tabular-nums text-[color:var(--ink)]/35 font-semibold">{{.Rank}}</td>
<td class="px-2 py-3 min-w-0">
{{if .Token}}<a href="/adventure/who/{{.Token}}" class="font-semibold hover:text-theme-adventure transition">{{.Name}}</a>
{{else}}<span class="font-semibold">{{.Name}}</span>{{end}}
<span class="block text-xs text-[color:var(--ink)]/40">
lv {{.Level}}{{if .ClassRace}} · {{.ClassRace}}{{end}}
</span>
</td>
<td class="px-2 py-3 text-right tabular-nums">
{{if .DeepestTier}}<span class="standings-tier standings-tier-{{.DeepestTier}}">T{{.DeepestTier}}</span>
{{else}}<span class="text-[color:var(--ink)]/25"></span>{{end}}
</td>
<td class="px-2 py-3 text-right tabular-nums{{if not .Zones}} text-[color:var(--ink)]/25{{end}}">{{if .Zones}}{{.Zones}}{{else}}—{{end}}</td>
<td class="px-2 py-3 text-right tabular-nums{{if not .Clears}} text-[color:var(--ink)]/25{{end}}">{{if .Clears}}{{.Clears}}{{else}}—{{end}}</td>
<td class="px-2 py-3 text-right tabular-nums">
{{if .Firsts}}<span class="standings-firsts">{{.Firsts}}</span>{{else}}<span class="text-[color:var(--ink)]/25"></span>{{end}}
</td>
<td class="px-2 py-3 text-right tabular-nums{{if not .SiegeDamage}} text-[color:var(--ink)]/25{{end}}">
{{if .SiegeDamage}}{{.SiegeDamage}}{{else}}—{{end}}
</td>
<td class="px-4 py-3 text-right tabular-nums{{if not .Deaths}} text-[color:var(--ink)]/25{{end}}">{{if .Deaths}}{{.Deaths}}{{else}}—{{end}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
</div>
<p class="mt-3 text-xs text-[color:var(--ink)]/40 px-1">
Deepest is the hardest tier they've actually put a boss down in, not the hardest
one they've walked into. Deaths is my own count, off the dispatches I've filed.
</p>
{{else}}
<div class="mt-6 rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 p-6 shadow-pete">
<p class="text-sm text-[color:var(--ink)]/60">
{{if .Standings.Known}}
Nobody on the board yet. Make a character and it fills up.
{{else}}
Haven't had the count through from the field yet.
{{end}}
</p>
</div>
{{end}}
{{/* Pete keeps score on himself. He can be hired onto an expedition and he
duels; a paper that ranks everybody else and quietly leaves itself off the
table is doing something slightly dishonest. */}}
<section class="mt-8 rounded-3xl bg-[color:var(--card)] border-2 border-theme-adventure/30 p-6 shadow-pete">
<h2 class="font-display text-xl font-bold">And my own record, since you asked</h2>
{{if .Standings.PeteFought}}
<p class="mt-2 text-sm text-[color:var(--ink)]/70">
<span class="font-semibold text-theme-adventure tabular-nums text-lg">{{.Standings.PeteWins}}</span> won,
<span class="font-semibold tabular-nums text-lg">{{.Standings.PeteLosses}}</span> lost.
I file those myself, which you're welcome to hold against me.
</p>
{{else}}
<p class="mt-2 text-sm text-[color:var(--ink)]/60">
No bouts yet. I'll report them when there are, wins and the other kind.
</p>
{{end}}
</section>
</article>
{{end}}