adventure: tell Pete what the realm is, not just what happened in it

Adds the realm snapshot behind Pete's map, board and hall of firsts: every
zone with its clear history and who is standing in it, the ledger of things
that have happened exactly once, and one line per adventurer.

Snapshot semantics like the roster and the Siege — pushed whole, replaces
Pete's copy, dropped rather than retried. It rides the roster ticker but at a
ten-minute stride: these are aggregate scans over the whole run history, and a
first clear does not move.

Two things the real data corrected, neither of which any unit test would have
caught:

`abandoned` does not mean the player gave up. It means the run row was
retired, and abandonZoneRunByID exists precisely to retire a run whose boss is
already dead when the expedition travels on. 30 of prod's 32 boss kills carry
abandoned = 1, so filtering on `abandoned = 0` — as this did, copying the
shipped backfill — drew a realm in which almost nothing had ever been beaten.
boss_defeated = 1 is the clear.

And news_realm_firsts is not a usable history book on its own. It only covers
what happened after the news seam went live, its one-shot seeder carried that
same wrong filter, and its first_at is when the claim was written down, not
when the thing happened — every backfilled row in prod shares one timestamp.
So the zone half of the hall is derived from the run history instead, which is
complete and correctly dated, and the ledger supplies the kinds the run
history knows nothing about. That also makes the hall agree with the board by
construction rather than by coincidence.

Opt-out follows the rule that fits each surface: a first-clearer is anonymised
(deleting the claim would redraw a conquered zone as unbeaten), a player is
dropped from the board outright, and presence is dropped entirely — who is in
a dungeon right now is the live-location fact the liveblog already refuses.

Claude-Session: https://claude.ai/code/session_012bxpQQJDjC1mTtLN3VVtBQ
This commit is contained in:
prosolis
2026-07-24 17:55:05 -07:00
parent 7a5c8341f0
commit d6136d39d9
4 changed files with 1086 additions and 0 deletions
+99
View File
@@ -493,6 +493,105 @@ type RunBeat struct {
Prose string `json:"prose,omitempty"`
}
// RealmZone is one zone as the realm map draws it: what it is, who first got
// through it, how many have since, and who is inside it right now.
//
// FirstClearBy is a character name and FirstClearToken the public board token,
// exactly as the Siege muster pairs them — and the token is EMPTY for a player
// who has opted out, keeping the name off the page too (see buildRealmSnapshot:
// an opted-out first-clearer is anonymised, not deleted, because deleting the
// claim would make the zone read as never-cleared, which is a different and
// false statement about the realm).
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"` // T6 mythic: gated, drawn apart
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"` // boss-defeated runs, all time
Clearers int `json:"clearers"` // distinct adventurers who have managed it
Occupants []RealmOccupant `json:"occupants,omitempty"` // in there right now
}
// RealmOccupant is somebody currently on an expedition in a zone. Same
// name+token pair as everywhere else, and an opted-out player is omitted
// outright rather than anonymised: unlike a first clear, presence is not part of
// a shared tally that stops adding up without them, and "who is in there right
// now" is exactly the live-location fact the liveblog is careful about.
type RealmOccupant struct {
Token string `json:"token,omitempty"`
Name string `json:"name"`
Level int `json:"level,omitempty"`
Day int `json:"day,omitempty"`
}
// RealmFirst is one row of the hall of firsts: a thing that happened in the
// realm exactly once ever, and who it happened to. The ledger
// (news_realm_firsts) records only (kind, target, first_at) — the holder is
// recovered by gogobee at push time from the run history, which is why this is
// pushed rather than derived on Pete.
type RealmFirst struct {
Kind string `json:"kind"` // "zone" | "treasure"
Target string `json:"target"` // the zone id or treasure key
Display string `json:"display"` // the human name for it
Tier int `json:"tier,omitempty"` // zone tier, when kind is "zone"
Holder string `json:"holder,omitempty"` // character name, empty when unrecoverable
Token string `json:"token,omitempty"` // board token; empty when opted out
AtUnix int64 `json:"at_unix"` // when the realm first saw it
}
// RealmStanding is one adventurer's line on the board. Every number here is a
// lifetime total from the game's own run history — nothing is a rate, an
// average, or anything that would move on its own while nobody played.
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"` // deepest zone tier actually cleared
Clears int `json:"clears"`
Zones int `json:"zones"` // distinct zones cleared
Firsts int `json:"firsts"` // realm-firsts held
SiegeDamage int `json:"siege_damage"`
SiegeFights int `json:"siege_fights"`
}
// RealmSnapshot is the whole realm as one photograph: every zone, the hall of
// firsts, and the board. Snapshot semantics, like the roster and the Siege —
// Pete replaces its copy and a failed push is dropped, not retried.
//
// It is pushed on the roster ticker but NOT every tick: none of it moves fast
// enough to be worth the aggregate queries every two minutes, and the page's
// staleness window is generous for exactly that reason. See realmPushInterval.
type RealmSnapshot struct {
SnapshotAt int64 `json:"snapshot_at"`
Zones []RealmZone `json:"zones,omitempty"`
Firsts []RealmFirst `json:"firsts,omitempty"`
Standings []RealmStanding `json:"standings,omitempty"`
}
// PushRealm sends the realm pages' backing data to Pete. Drop-on-failure, same
// as the other two snapshots.
func PushRealm(ctx context.Context, snap RealmSnapshot) error {
if !Enabled() {
return nil
}
payload, err := json.Marshal(snap)
if err != nil {
return err
}
return std.post(ctx, "/api/ingest/realm", payload)
}
// PushRunBeats delivers a batch of beats. Unlike the snapshots this is
// append-only and IS retried — a dropped beat is a hole in a story, not a stale
// number that the next tick corrects. The caller only marks rows sent on success.