Every dispatch Pete publishes is an accomplishment — a death, a clear, a milestone — and an accomplishment is a newspaper clipping the moment it lands. No refresh interval fixes that. So the page never felt alive, and it never was going to. The board is the other kind of thing: state that is currently true. gogobee pushes the whole roster, we replace ours with it, and it renders above the clippings. An open tab re-polls so it keeps telling the truth. Replace, never merge: anyone gogobee omits (opted out, no character) drops off the public page. That omission IS the opt-out — a standing row showing class, level and zone names the player anyway, so "an adventurer" would have been a fig leaf. The snapshot time lives in its own row, because an empty board is ambiguous: nobody playing, or gogobee stopped talking to us. The page has to tell those apart — one is a quiet realm, the other is a board that lies confidently, which is worse than one that admits it lost the wire. Also teaches Pete "departure", so a bored adventurer letting itself out is news.
109 lines
3.6 KiB
Go
109 lines
3.6 KiB
Go
package storage
|
|
|
|
import (
|
|
"database/sql"
|
|
"log/slog"
|
|
)
|
|
|
|
// RosterEntry is one adventurer's currently-true state, as of the last snapshot
|
|
// gogobee pushed. Not an event — nothing here is a thing that *happened*.
|
|
type RosterEntry struct {
|
|
Token string `json:"token"`
|
|
Name string `json:"name"`
|
|
Level int `json:"level"`
|
|
ClassRace string `json:"class_race"`
|
|
Status string `json:"status"` // "expedition" | "idle"
|
|
Zone string `json:"zone,omitempty"`
|
|
Region string `json:"region,omitempty"`
|
|
Day int `json:"day,omitempty"`
|
|
IdleHours int `json:"idle_hours,omitempty"`
|
|
SnapshotAt int64 `json:"snapshot_at"`
|
|
}
|
|
|
|
// ReplaceRoster swaps the whole board for a new snapshot, in one transaction.
|
|
//
|
|
// Replace — never merge. A player who dropped out of gogobee's snapshot (deleted
|
|
// character, or a fresh `!news optout`) must vanish from the board, and an
|
|
// upsert would leave them standing there forever. The transaction means a reader
|
|
// mid-swap sees the old board or the new one, never a half-empty realm.
|
|
func ReplaceRoster(entries []RosterEntry, snapshotAt int64) error {
|
|
tx, err := Get().Begin()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() { _ = tx.Rollback() }()
|
|
|
|
if _, err := tx.Exec(`DELETE FROM adventure_roster`); err != nil {
|
|
return err
|
|
}
|
|
stmt, err := tx.Prepare(`
|
|
INSERT INTO adventure_roster
|
|
(token, name, level, class_race, status, zone, region, day, idle_hours, snapshot_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer stmt.Close()
|
|
|
|
for _, e := range entries {
|
|
if _, err := stmt.Exec(e.Token, e.Name, e.Level, e.ClassRace, e.Status,
|
|
e.Zone, e.Region, e.Day, e.IdleHours, snapshotAt); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
// Stamp the snapshot even when it carried zero adventurers — that is a
|
|
// legitimate state (quiet realm) and must not read as "gogobee went away".
|
|
if _, err := tx.Exec(`
|
|
INSERT INTO adventure_roster_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()
|
|
}
|
|
|
|
// LoadRoster returns the current board: everyone on an expedition first (most
|
|
// recently departed at the top of that group), then the idle, longest-idle last.
|
|
// Also returns the snapshot time so the caller can decide whether the wire has
|
|
// gone quiet — see rosterStale.
|
|
func LoadRoster() ([]RosterEntry, int64, error) {
|
|
rows, err := Get().Query(`
|
|
SELECT token, name, level, COALESCE(class_race, ''), status,
|
|
COALESCE(zone, ''), COALESCE(region, ''), day, idle_hours, snapshot_at
|
|
FROM adventure_roster
|
|
ORDER BY status = 'expedition' DESC, day DESC, idle_hours ASC, level DESC, name ASC`)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var out []RosterEntry
|
|
for rows.Next() {
|
|
var e RosterEntry
|
|
if err := rows.Scan(&e.Token, &e.Name, &e.Level, &e.ClassRace, &e.Status,
|
|
&e.Zone, &e.Region, &e.Day, &e.IdleHours, &e.SnapshotAt); err != nil {
|
|
return nil, 0, err
|
|
}
|
|
out = append(out, e)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, 0, err
|
|
}
|
|
return out, RosterSnapshotAt(), nil
|
|
}
|
|
|
|
// RosterSnapshotAt reports when the board was last refreshed, 0 if gogobee has
|
|
// never pushed one. Read from the meta row, not the entries, so a snapshot that
|
|
// legitimately carried nobody still counts as a snapshot.
|
|
func RosterSnapshotAt() int64 {
|
|
var at sql.NullInt64
|
|
err := Get().QueryRow(`SELECT snapshot_at FROM adventure_roster_meta WHERE id = 1`).Scan(&at)
|
|
if err == sql.ErrNoRows {
|
|
return 0
|
|
}
|
|
if err != nil {
|
|
slog.Error("RosterSnapshotAt query failed", "err", err)
|
|
return 0
|
|
}
|
|
return at.Int64
|
|
}
|