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 }