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) } }