Files
Pete/internal/storage/realm.go
T
prosolis 1dfd3ac9fb 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
2026-07-24 17:54:49 -07:00

370 lines
12 KiB
Go

package storage
import (
"database/sql"
)
// The realm, as gogobee pushes it.
//
// Three pages ride one snapshot: the world map, the board, and the hall of
// firsts. They are one push rather than three because they are one *question* —
// "what is this place, and what has happened here" — and because every number in
// all three comes off the same scan of the same run history. Splitting them would
// mean three ways for the same fact to be a different number depending on which
// page you were looking at.
//
// Nothing here is an event. The events (a zone_first dispatch, a death) come down
// the dispatch queue like any other fact. This is the standing state of the world,
// which is the only kind of thing a map can honestly draw.
// RealmOccupant is somebody on an expedition in a zone right now. Token is the
// public board token and is EMPTY only in the sense that this row never exists
// for an opted-out player — unlike a siege contributor, presence is dropped
// outright upstream rather than anonymised, so every row here has a name and a
// link.
type RealmOccupant struct {
Token string `json:"token,omitempty"`
Name string `json:"name"`
Level int `json:"level,omitempty"`
Day int `json:"day,omitempty"`
}
// RealmZone is one place in the world: what it is, who first got through it, how
// many have since, and who is inside it.
//
// FirstBy with an empty FirstToken is the anonymised case — the zone HAS been
// cleared and the claim stands, but the clearer opted out and gets no name and no
// link. Clears > 0 with no FirstBy at all is the same state seen from the other
// side, and both render as "cleared, by somebody" rather than as never-cleared,
// which would be a false statement about the realm rather than a withheld one.
type RealmZone struct {
ID string `json:"id"`
Display string `json:"display"`
Tier int `json:"tier"`
LevelMin int `json:"level_min"`
LevelMax int `json:"level_max"`
Faction string `json:"faction,omitempty"`
Atmosphere string `json:"atmosphere,omitempty"`
Postgame bool `json:"postgame,omitempty"`
FirstClearBy string `json:"first_clear_by,omitempty"`
FirstClearToken string `json:"first_clear_token,omitempty"`
FirstClearAt int64 `json:"first_clear_at,omitempty"`
Clears int `json:"clears"`
Clearers int `json:"clearers"`
Occupants []RealmOccupant `json:"occupants,omitempty"`
}
// RealmFirst is one entry in the hall of firsts.
type RealmFirst struct {
Kind string `json:"kind"`
Target string `json:"target"`
Display string `json:"display"`
Tier int `json:"tier,omitempty"`
Holder string `json:"holder,omitempty"`
Token string `json:"token,omitempty"`
AtUnix int64 `json:"at_unix"`
}
// RealmStanding is one line on the board.
type RealmStanding struct {
Token string `json:"token,omitempty"`
Name string `json:"name"`
Level int `json:"level"`
ClassRace string `json:"class_race,omitempty"`
DeepestTier int `json:"deepest_tier"`
Clears int `json:"clears"`
Zones int `json:"zones"`
Firsts int `json:"firsts"`
SiegeDamage int `json:"siege_damage"`
SiegeFights int `json:"siege_fights"`
}
// Realm is the whole snapshot.
type Realm struct {
Zones []RealmZone `json:"zones,omitempty"`
Firsts []RealmFirst `json:"firsts,omitempty"`
Standings []RealmStanding `json:"standings,omitempty"`
SnapshotAt int64 `json:"snapshot_at"`
}
// ReplaceRealm swaps the whole realm for a new snapshot, in one transaction.
//
// Replace, never merge, for the reason the siege does it: a zone whose clear
// count was corrected upstream, a player who opted out, an occupant who came
// home — all of those are *removals*, and a merge has no way to express one. The
// transaction means a reader mid-swap sees the old realm or the new one, never a
// zone list with the previous board under it.
func ReplaceRealm(r Realm, snapshotAt int64) error {
tx, err := Get().Begin()
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
for _, t := range []string{
"adventure_realm_zone",
"adventure_realm_occupant",
"adventure_realm_first",
"adventure_realm_standing",
} {
if _, err := tx.Exec(`DELETE FROM ` + t); err != nil {
return err
}
}
zstmt, err := tx.Prepare(`
INSERT INTO adventure_realm_zone
(pos, zone_id, display, tier, level_min, level_max, faction, atmosphere,
postgame, first_by, first_token, first_at, clears, clearers)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
if err != nil {
return err
}
defer zstmt.Close()
ostmt, err := tx.Prepare(`
INSERT INTO adventure_realm_occupant (pos, zone_id, token, name, level, day)
VALUES (?, ?, ?, ?, ?, ?)`)
if err != nil {
return err
}
defer ostmt.Close()
opos := 0
for i, z := range r.Zones {
if _, err := zstmt.Exec(i, z.ID, z.Display, z.Tier, z.LevelMin, z.LevelMax,
z.Faction, z.Atmosphere, z.Postgame, z.FirstClearBy, z.FirstClearToken,
z.FirstClearAt, z.Clears, z.Clearers); err != nil {
return err
}
for _, o := range z.Occupants {
if _, err := ostmt.Exec(opos, z.ID, o.Token, o.Name, o.Level, o.Day); err != nil {
return err
}
opos++
}
}
fstmt, err := tx.Prepare(`
INSERT INTO adventure_realm_first (pos, kind, target, display, tier, holder, token, at_unix)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
if err != nil {
return err
}
defer fstmt.Close()
for i, f := range r.Firsts {
if _, err := fstmt.Exec(i, f.Kind, f.Target, f.Display, f.Tier, f.Holder, f.Token, f.AtUnix); err != nil {
return err
}
}
sstmt, err := tx.Prepare(`
INSERT INTO adventure_realm_standing
(pos, token, name, level, class_race, deepest_tier, clears, zones, firsts,
siege_damage, siege_fights)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
if err != nil {
return err
}
defer sstmt.Close()
for i, s := range r.Standings {
if _, err := sstmt.Exec(i, s.Token, s.Name, s.Level, s.ClassRace, s.DeepestTier,
s.Clears, s.Zones, s.Firsts, s.SiegeDamage, s.SiegeFights); err != nil {
return err
}
}
if _, err := tx.Exec(`
INSERT INTO adventure_realm_meta (id, snapshot_at) VALUES (1, ?)
ON CONFLICT(id) DO UPDATE SET snapshot_at = excluded.snapshot_at`, snapshotAt); err != nil {
return err
}
return tx.Commit()
}
// LoadRealm returns the realm as last pushed. ok is false when gogobee has never
// pushed one — distinct from a pushed snapshot that happens to be empty, which is
// a real answer (a realm with no living adventurers on the board is a thing that
// can be true) and which the pages render differently.
func LoadRealm() (Realm, bool, error) {
var r Realm
err := Get().QueryRow(`SELECT snapshot_at FROM adventure_realm_meta WHERE id = 1`).
Scan(&r.SnapshotAt)
if err == sql.ErrNoRows {
return Realm{}, false, nil
}
if err != nil {
return Realm{}, false, err
}
// Each cursor is drained fully before the next query opens. The pool is one
// connection wide, and a nested read is the deadlock the run-beat batch
// shipped with and then had to have cut out of it.
zones, err := loadRealmZones()
if err != nil {
return r, true, err
}
occ, err := loadRealmOccupants()
if err != nil {
return r, true, err
}
for i := range zones {
zones[i].Occupants = occ[zones[i].ID]
}
r.Zones = zones
if r.Firsts, err = loadRealmFirsts(); err != nil {
return r, true, err
}
if r.Standings, err = loadRealmStandings(); err != nil {
return r, true, err
}
return r, true, nil
}
func loadRealmZones() ([]RealmZone, error) {
rows, err := Get().Query(`
SELECT zone_id, display, tier, level_min, level_max, faction, atmosphere,
postgame, first_by, first_token, first_at, clears, clearers
FROM adventure_realm_zone ORDER BY pos ASC`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []RealmZone
for rows.Next() {
var z RealmZone
if err := rows.Scan(&z.ID, &z.Display, &z.Tier, &z.LevelMin, &z.LevelMax,
&z.Faction, &z.Atmosphere, &z.Postgame, &z.FirstClearBy, &z.FirstClearToken,
&z.FirstClearAt, &z.Clears, &z.Clearers); err != nil {
return nil, err
}
out = append(out, z)
}
return out, rows.Err()
}
func loadRealmOccupants() (map[string][]RealmOccupant, error) {
rows, err := Get().Query(`
SELECT zone_id, token, name, level, day
FROM adventure_realm_occupant ORDER BY pos ASC`)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[string][]RealmOccupant{}
for rows.Next() {
var zoneID string
var o RealmOccupant
if err := rows.Scan(&zoneID, &o.Token, &o.Name, &o.Level, &o.Day); err != nil {
return nil, err
}
out[zoneID] = append(out[zoneID], o)
}
return out, rows.Err()
}
func loadRealmFirsts() ([]RealmFirst, error) {
rows, err := Get().Query(`
SELECT kind, target, display, tier, holder, token, at_unix
FROM adventure_realm_first ORDER BY pos ASC`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []RealmFirst
for rows.Next() {
var f RealmFirst
if err := rows.Scan(&f.Kind, &f.Target, &f.Display, &f.Tier, &f.Holder, &f.Token, &f.AtUnix); err != nil {
return nil, err
}
out = append(out, f)
}
return out, rows.Err()
}
func loadRealmStandings() ([]RealmStanding, error) {
rows, err := Get().Query(`
SELECT token, name, level, class_race, deepest_tier, clears, zones, firsts,
siege_damage, siege_fights
FROM adventure_realm_standing ORDER BY pos ASC`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []RealmStanding
for rows.Next() {
var s RealmStanding
if err := rows.Scan(&s.Token, &s.Name, &s.Level, &s.ClassRace, &s.DeepestTier,
&s.Clears, &s.Zones, &s.Firsts, &s.SiegeDamage, &s.SiegeFights); err != nil {
return nil, err
}
out = append(out, s)
}
return out, rows.Err()
}
// DeathsBySubject counts the deaths Pete has reported for each named adventurer.
//
// This is the one standings number that does NOT come off the gogobee push, and
// the reason is that the game has nowhere to read it from: a character carries
// its most recent death (source, place, date) and no running total, so there is
// no lifetime count on the game box to send. Pete does have one — his own back
// catalogue of death dispatches, seeded at news launch by the backfill and
// complete since — so he counts them himself, which is a thing a newspaper is
// entitled to do about its own reporting.
//
// Keyed on the character name because that is the only join the fact table
// offers: a dispatch carries a name, never a token. Names are unique per realm in
// practice; a collision would merge two adventurers' death counts, which is why
// this is the only column derived this way and not, say, clears.
func DeathsBySubject() (map[string]int, error) {
rows, err := Get().Query(`
SELECT subject, COUNT(*) FROM adventure_events
WHERE event_type = 'death' AND subject IS NOT NULL AND subject <> ''
GROUP BY subject`)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[string]int{}
for rows.Next() {
var name string
var n int
if err := rows.Scan(&name, &n); err != nil {
return nil, err
}
out[name] = n
}
return out, rows.Err()
}
// PeteDuelRecord is Pete's own won/lost tally, from the dispatches he filed about
// himself. He is a companion who can be hired onto an expedition and he has a
// record; keeping score on himself is in voice, and it is the one line on the
// board that is not about a player.
//
// Both types have had templates in the renderer since before anything emitted
// them, so this reads zero until gogobee starts filing them — and zero-zero is
// rendered as "no bouts yet" rather than as a 0% win rate.
func PeteDuelRecord() (wins, losses int, err error) {
err = Get().QueryRow(`
SELECT
COALESCE(SUM(CASE WHEN event_type = 'pete_duel_win' THEN 1 ELSE 0 END), 0),
COALESCE(SUM(CASE WHEN event_type = 'pete_duel_loss' THEN 1 ELSE 0 END), 0)
FROM adventure_events
WHERE event_type IN ('pete_duel_win', 'pete_duel_loss')`).Scan(&wins, &losses)
if err == sql.ErrNoRows {
return 0, 0, nil
}
return wins, losses, err
}