adventure: give the realm a map, a board and a history

Everything Pete published so far was either the present moment (the roster,
the Siege bar) or one thing that happened (a dispatch, a run report). None of
it said what this place IS — how many zones there are, which are harder, or
whether anybody has ever actually beaten them.

Three pages off one snapshot, because they are one question and every number
on all three comes off the same scan of the same run history upstream:

  /adventure/realm      the world, in difficulty order, with who is inside it
  /adventure/standings  the board, plus Pete's own duel record
  /adventure/firsts     the hall of firsts, as a dated history

The map is deliberately not who_map.go's layout engine. That lays out a graph,
and the realm has no edges — zones aren't connected, you pick one and go.
Forcing a graph onto a set would imply a topology the game doesn't have. What
it has is an order, so tier bands are what get drawn.

A zone nobody has ever cleared looks different rather than saying so in small
text, and that styling keys off the clear count, never off whether a name is
attached — otherwise an opt-out would silently redraw a conquered place as one
nobody has come out of.

The per-kind first dot goes through a custom property instead of a
.firsts-entry-zone::before rule: input.css is in Tailwind's content glob, so a
hand-written class survives the purge only if its literal name can be lifted
out of the file, and a name glued to ::before cannot. It failed silently.

Claude-Session: https://claude.ai/code/session_012bxpQQJDjC1mTtLN3VVtBQ
This commit is contained in:
prosolis
2026-07-24 17:54:49 -07:00
parent b4a276da36
commit 1dfd3ac9fb
12 changed files with 1745 additions and 7 deletions
+369
View File
@@ -0,0 +1,369 @@
package storage
import (
"database/sql"
)
// The realm, as gogobee pushes it.
//
// Three pages ride one snapshot: the world map, the board, and the hall of
// firsts. They are one push rather than three because they are one *question* —
// "what is this place, and what has happened here" — and because every number in
// all three comes off the same scan of the same run history. Splitting them would
// mean three ways for the same fact to be a different number depending on which
// page you were looking at.
//
// Nothing here is an event. The events (a zone_first dispatch, a death) come down
// the dispatch queue like any other fact. This is the standing state of the world,
// which is the only kind of thing a map can honestly draw.
// RealmOccupant is somebody on an expedition in a zone right now. Token is the
// public board token and is EMPTY only in the sense that this row never exists
// for an opted-out player — unlike a siege contributor, presence is dropped
// outright upstream rather than anonymised, so every row here has a name and a
// link.
type RealmOccupant struct {
Token string `json:"token,omitempty"`
Name string `json:"name"`
Level int `json:"level,omitempty"`
Day int `json:"day,omitempty"`
}
// RealmZone is one place in the world: what it is, who first got through it, how
// many have since, and who is inside it.
//
// FirstBy with an empty FirstToken is the anonymised case — the zone HAS been
// cleared and the claim stands, but the clearer opted out and gets no name and no
// link. Clears > 0 with no FirstBy at all is the same state seen from the other
// side, and both render as "cleared, by somebody" rather than as never-cleared,
// which would be a false statement about the realm rather than a withheld one.
type RealmZone struct {
ID string `json:"id"`
Display string `json:"display"`
Tier int `json:"tier"`
LevelMin int `json:"level_min"`
LevelMax int `json:"level_max"`
Faction string `json:"faction,omitempty"`
Atmosphere string `json:"atmosphere,omitempty"`
Postgame bool `json:"postgame,omitempty"`
FirstClearBy string `json:"first_clear_by,omitempty"`
FirstClearToken string `json:"first_clear_token,omitempty"`
FirstClearAt int64 `json:"first_clear_at,omitempty"`
Clears int `json:"clears"`
Clearers int `json:"clearers"`
Occupants []RealmOccupant `json:"occupants,omitempty"`
}
// RealmFirst is one entry in the hall of firsts.
type RealmFirst struct {
Kind string `json:"kind"`
Target string `json:"target"`
Display string `json:"display"`
Tier int `json:"tier,omitempty"`
Holder string `json:"holder,omitempty"`
Token string `json:"token,omitempty"`
AtUnix int64 `json:"at_unix"`
}
// RealmStanding is one line on the board.
type RealmStanding struct {
Token string `json:"token,omitempty"`
Name string `json:"name"`
Level int `json:"level"`
ClassRace string `json:"class_race,omitempty"`
DeepestTier int `json:"deepest_tier"`
Clears int `json:"clears"`
Zones int `json:"zones"`
Firsts int `json:"firsts"`
SiegeDamage int `json:"siege_damage"`
SiegeFights int `json:"siege_fights"`
}
// Realm is the whole snapshot.
type Realm struct {
Zones []RealmZone `json:"zones,omitempty"`
Firsts []RealmFirst `json:"firsts,omitempty"`
Standings []RealmStanding `json:"standings,omitempty"`
SnapshotAt int64 `json:"snapshot_at"`
}
// ReplaceRealm swaps the whole realm for a new snapshot, in one transaction.
//
// Replace, never merge, for the reason the siege does it: a zone whose clear
// count was corrected upstream, a player who opted out, an occupant who came
// home — all of those are *removals*, and a merge has no way to express one. The
// transaction means a reader mid-swap sees the old realm or the new one, never a
// zone list with the previous board under it.
func ReplaceRealm(r Realm, snapshotAt int64) error {
tx, err := Get().Begin()
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
for _, t := range []string{
"adventure_realm_zone",
"adventure_realm_occupant",
"adventure_realm_first",
"adventure_realm_standing",
} {
if _, err := tx.Exec(`DELETE FROM ` + t); err != nil {
return err
}
}
zstmt, err := tx.Prepare(`
INSERT INTO adventure_realm_zone
(pos, zone_id, display, tier, level_min, level_max, faction, atmosphere,
postgame, first_by, first_token, first_at, clears, clearers)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
if err != nil {
return err
}
defer zstmt.Close()
ostmt, err := tx.Prepare(`
INSERT INTO adventure_realm_occupant (pos, zone_id, token, name, level, day)
VALUES (?, ?, ?, ?, ?, ?)`)
if err != nil {
return err
}
defer ostmt.Close()
opos := 0
for i, z := range r.Zones {
if _, err := zstmt.Exec(i, z.ID, z.Display, z.Tier, z.LevelMin, z.LevelMax,
z.Faction, z.Atmosphere, z.Postgame, z.FirstClearBy, z.FirstClearToken,
z.FirstClearAt, z.Clears, z.Clearers); err != nil {
return err
}
for _, o := range z.Occupants {
if _, err := ostmt.Exec(opos, z.ID, o.Token, o.Name, o.Level, o.Day); err != nil {
return err
}
opos++
}
}
fstmt, err := tx.Prepare(`
INSERT INTO adventure_realm_first (pos, kind, target, display, tier, holder, token, at_unix)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
if err != nil {
return err
}
defer fstmt.Close()
for i, f := range r.Firsts {
if _, err := fstmt.Exec(i, f.Kind, f.Target, f.Display, f.Tier, f.Holder, f.Token, f.AtUnix); err != nil {
return err
}
}
sstmt, err := tx.Prepare(`
INSERT INTO adventure_realm_standing
(pos, token, name, level, class_race, deepest_tier, clears, zones, firsts,
siege_damage, siege_fights)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
if err != nil {
return err
}
defer sstmt.Close()
for i, s := range r.Standings {
if _, err := sstmt.Exec(i, s.Token, s.Name, s.Level, s.ClassRace, s.DeepestTier,
s.Clears, s.Zones, s.Firsts, s.SiegeDamage, s.SiegeFights); err != nil {
return err
}
}
if _, err := tx.Exec(`
INSERT INTO adventure_realm_meta (id, snapshot_at) VALUES (1, ?)
ON CONFLICT(id) DO UPDATE SET snapshot_at = excluded.snapshot_at`, snapshotAt); err != nil {
return err
}
return tx.Commit()
}
// LoadRealm returns the realm as last pushed. ok is false when gogobee has never
// pushed one — distinct from a pushed snapshot that happens to be empty, which is
// a real answer (a realm with no living adventurers on the board is a thing that
// can be true) and which the pages render differently.
func LoadRealm() (Realm, bool, error) {
var r Realm
err := Get().QueryRow(`SELECT snapshot_at FROM adventure_realm_meta WHERE id = 1`).
Scan(&r.SnapshotAt)
if err == sql.ErrNoRows {
return Realm{}, false, nil
}
if err != nil {
return Realm{}, false, err
}
// Each cursor is drained fully before the next query opens. The pool is one
// connection wide, and a nested read is the deadlock the run-beat batch
// shipped with and then had to have cut out of it.
zones, err := loadRealmZones()
if err != nil {
return r, true, err
}
occ, err := loadRealmOccupants()
if err != nil {
return r, true, err
}
for i := range zones {
zones[i].Occupants = occ[zones[i].ID]
}
r.Zones = zones
if r.Firsts, err = loadRealmFirsts(); err != nil {
return r, true, err
}
if r.Standings, err = loadRealmStandings(); err != nil {
return r, true, err
}
return r, true, nil
}
func loadRealmZones() ([]RealmZone, error) {
rows, err := Get().Query(`
SELECT zone_id, display, tier, level_min, level_max, faction, atmosphere,
postgame, first_by, first_token, first_at, clears, clearers
FROM adventure_realm_zone ORDER BY pos ASC`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []RealmZone
for rows.Next() {
var z RealmZone
if err := rows.Scan(&z.ID, &z.Display, &z.Tier, &z.LevelMin, &z.LevelMax,
&z.Faction, &z.Atmosphere, &z.Postgame, &z.FirstClearBy, &z.FirstClearToken,
&z.FirstClearAt, &z.Clears, &z.Clearers); err != nil {
return nil, err
}
out = append(out, z)
}
return out, rows.Err()
}
func loadRealmOccupants() (map[string][]RealmOccupant, error) {
rows, err := Get().Query(`
SELECT zone_id, token, name, level, day
FROM adventure_realm_occupant ORDER BY pos ASC`)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[string][]RealmOccupant{}
for rows.Next() {
var zoneID string
var o RealmOccupant
if err := rows.Scan(&zoneID, &o.Token, &o.Name, &o.Level, &o.Day); err != nil {
return nil, err
}
out[zoneID] = append(out[zoneID], o)
}
return out, rows.Err()
}
func loadRealmFirsts() ([]RealmFirst, error) {
rows, err := Get().Query(`
SELECT kind, target, display, tier, holder, token, at_unix
FROM adventure_realm_first ORDER BY pos ASC`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []RealmFirst
for rows.Next() {
var f RealmFirst
if err := rows.Scan(&f.Kind, &f.Target, &f.Display, &f.Tier, &f.Holder, &f.Token, &f.AtUnix); err != nil {
return nil, err
}
out = append(out, f)
}
return out, rows.Err()
}
func loadRealmStandings() ([]RealmStanding, error) {
rows, err := Get().Query(`
SELECT token, name, level, class_race, deepest_tier, clears, zones, firsts,
siege_damage, siege_fights
FROM adventure_realm_standing ORDER BY pos ASC`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []RealmStanding
for rows.Next() {
var s RealmStanding
if err := rows.Scan(&s.Token, &s.Name, &s.Level, &s.ClassRace, &s.DeepestTier,
&s.Clears, &s.Zones, &s.Firsts, &s.SiegeDamage, &s.SiegeFights); err != nil {
return nil, err
}
out = append(out, s)
}
return out, rows.Err()
}
// DeathsBySubject counts the deaths Pete has reported for each named adventurer.
//
// This is the one standings number that does NOT come off the gogobee push, and
// the reason is that the game has nowhere to read it from: a character carries
// its most recent death (source, place, date) and no running total, so there is
// no lifetime count on the game box to send. Pete does have one — his own back
// catalogue of death dispatches, seeded at news launch by the backfill and
// complete since — so he counts them himself, which is a thing a newspaper is
// entitled to do about its own reporting.
//
// Keyed on the character name because that is the only join the fact table
// offers: a dispatch carries a name, never a token. Names are unique per realm in
// practice; a collision would merge two adventurers' death counts, which is why
// this is the only column derived this way and not, say, clears.
func DeathsBySubject() (map[string]int, error) {
rows, err := Get().Query(`
SELECT subject, COUNT(*) FROM adventure_events
WHERE event_type = 'death' AND subject IS NOT NULL AND subject <> ''
GROUP BY subject`)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[string]int{}
for rows.Next() {
var name string
var n int
if err := rows.Scan(&name, &n); err != nil {
return nil, err
}
out[name] = n
}
return out, rows.Err()
}
// PeteDuelRecord is Pete's own won/lost tally, from the dispatches he filed about
// himself. He is a companion who can be hired onto an expedition and he has a
// record; keeping score on himself is in voice, and it is the one line on the
// board that is not about a player.
//
// Both types have had templates in the renderer since before anything emitted
// them, so this reads zero until gogobee starts filing them — and zero-zero is
// rendered as "no bouts yet" rather than as a 0% win rate.
func PeteDuelRecord() (wins, losses int, err error) {
err = Get().QueryRow(`
SELECT
COALESCE(SUM(CASE WHEN event_type = 'pete_duel_win' THEN 1 ELSE 0 END), 0),
COALESCE(SUM(CASE WHEN event_type = 'pete_duel_loss' THEN 1 ELSE 0 END), 0)
FROM adventure_events
WHERE event_type IN ('pete_duel_win', 'pete_duel_loss')`).Scan(&wins, &losses)
if err == sql.ErrNoRows {
return 0, 0, nil
}
return wins, losses, err
}
+84
View File
@@ -212,6 +212,90 @@ CREATE TABLE IF NOT EXISTS adventure_run_beat (
PRIMARY KEY (run_id, seq)
);
-- The realm: the world map, the hall of firsts, and the board. Four tables from
-- one gogobee push, all replaced whole — the same contract as the roster and the
-- Siege, and for the same reason. Every row here is a *derived* answer (how many
-- clears, who was first, who is inside right now) recomputed on the game box from
-- its own run history. Pete keeping a stale one and merging into it would let a
-- correction upstream leave a wrong number here permanently.
--
-- Unlike the Siege there is no live/history lifetime split, because none of this
-- has a lifetime: a zone does not end. What varies is only how often it changes,
-- and that is handled on the gogobee side by pushing every ten minutes instead of
-- every two.
--
-- pos is the push order throughout, kept as the key for the same reason the siege
-- muster does: an opted-out player carries NO token, so several rows can
-- legitimately be tokenless and must not collide on one.
CREATE TABLE IF NOT EXISTS adventure_realm_zone (
pos INTEGER PRIMARY KEY, -- gogobee's design-doc zone order
zone_id TEXT NOT NULL,
display TEXT NOT NULL,
tier INTEGER NOT NULL DEFAULT 0,
level_min INTEGER NOT NULL DEFAULT 0,
level_max INTEGER NOT NULL DEFAULT 0,
faction TEXT NOT NULL DEFAULT '',
atmosphere TEXT NOT NULL DEFAULT '',
postgame INTEGER NOT NULL DEFAULT 0,
first_by TEXT NOT NULL DEFAULT '',
first_token TEXT NOT NULL DEFAULT '',
first_at INTEGER NOT NULL DEFAULT 0,
clears INTEGER NOT NULL DEFAULT 0,
clearers INTEGER NOT NULL DEFAULT 0
);
-- Who is standing in a zone right now. Its own table rather than a JSON blob on
-- the zone row so the map can be drawn with one join and "there are people in
-- there" is a count, not a parse.
CREATE TABLE IF NOT EXISTS adventure_realm_occupant (
pos INTEGER PRIMARY KEY,
zone_id TEXT NOT NULL,
token TEXT NOT NULL DEFAULT '',
name TEXT NOT NULL,
level INTEGER NOT NULL DEFAULT 0,
day INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_adv_realm_occ_zone ON adventure_realm_occupant(zone_id);
-- The hall of firsts: every thing that has happened in the realm exactly once.
-- holder is empty when the game can no longer say who did it (a treasure found
-- and later discarded leaves no owner anywhere) — an unattributed first is still
-- a first and is rendered as one.
CREATE TABLE IF NOT EXISTS adventure_realm_first (
pos INTEGER PRIMARY KEY, -- gogobee's order: oldest first
kind TEXT NOT NULL, -- "zone" | "treasure" | whatever comes next
target TEXT NOT NULL,
display TEXT NOT NULL,
tier INTEGER NOT NULL DEFAULT 0,
holder TEXT NOT NULL DEFAULT '',
token TEXT NOT NULL DEFAULT '',
at_unix INTEGER NOT NULL DEFAULT 0
);
-- The board. pos IS the rank, and the ranking is gogobee's — the ordering is a
-- statement about what the game values, and the game gets to make it.
CREATE TABLE IF NOT EXISTS adventure_realm_standing (
pos INTEGER PRIMARY KEY,
token TEXT NOT NULL DEFAULT '',
name TEXT NOT NULL,
level INTEGER NOT NULL DEFAULT 0,
class_race TEXT NOT NULL DEFAULT '',
deepest_tier INTEGER NOT NULL DEFAULT 0,
clears INTEGER NOT NULL DEFAULT 0,
zones INTEGER NOT NULL DEFAULT 0,
firsts INTEGER NOT NULL DEFAULT 0,
siege_damage INTEGER NOT NULL DEFAULT 0,
siege_fights INTEGER NOT NULL DEFAULT 0
);
-- One row, like adventure_siege: when the realm last arrived. Its own table
-- because "gogobee has never pushed a realm" and "gogobee pushed a realm that is
-- empty" are different states, and the page says different things about them.
CREATE TABLE IF NOT EXISTS adventure_realm_meta (
id INTEGER PRIMARY KEY CHECK (id = 1),
snapshot_at INTEGER NOT NULL DEFAULT 0
);
-- A signed-in buyer's own euro balance, as of the last snapshot gogobee pushed.
-- Keyed by localpart (== Authentik preferred_username == the session's Username),
-- a *separate keyspace* from the anonymous roster tokens on purpose: it is only