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:
@@ -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 5–8", 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 1–6 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
|
||||
// 5–5") reads as a typo, so it collapses to "level 5"; a band with no numbers at
|
||||
// all renders as nothing rather than as "levels 0–0".
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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 5–5" reads as a typo and "levels 0–0" 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 5–8"},
|
||||
{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
@@ -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).
|
||||
|
||||
@@ -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
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}">
|
||||
|
||||
@@ -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}}
|
||||
Reference in New Issue
Block a user