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
463 lines
15 KiB
Go
463 lines
15 KiB
Go
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)
|
||
}
|
||
}
|