news: the adventure page gets something that's actually happening

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.
This commit is contained in:
prosolis
2026-07-13 18:05:38 -07:00
parent 8cb5b38599
commit 99574db3e9
8 changed files with 615 additions and 0 deletions

108
internal/storage/roster.go Normal file
View File

@@ -0,0 +1,108 @@
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
}

View File

@@ -21,6 +21,37 @@ CREATE TABLE IF NOT EXISTS stories (
published_at INTEGER
);
-- adventure_roster is a *snapshot*, not a log: gogobee POSTs the whole live
-- board and it replaces this table wholesale. Rows are state that is currently
-- true ("Josie is in holymachina"), which is the one thing the story feed can
-- never be — every dispatch there is an accomplishment, and an accomplishment is
-- a clipping the moment it lands.
--
-- token is gogobee's per-player roster token, not a Matrix handle and not a
-- story GUID. Players who ran "!news optout" are omitted from the snapshot
-- upstream and so never appear here at all.
CREATE TABLE IF NOT EXISTS adventure_roster (
token TEXT PRIMARY KEY,
name TEXT NOT NULL,
level INTEGER NOT NULL DEFAULT 0,
class_race TEXT,
status TEXT NOT NULL, -- "expedition" | "idle"
zone TEXT,
region TEXT,
day INTEGER NOT NULL DEFAULT 0, -- expedition day, 0 if idle
idle_hours INTEGER NOT NULL DEFAULT 0, -- hours since last player action
snapshot_at INTEGER NOT NULL -- when gogobee took the snapshot
);
-- The snapshot time lives outside the rows because an *empty* board is
-- ambiguous: either nobody is playing, or gogobee has stopped talking to us. A
-- MAX(snapshot_at) over zero rows can't tell those apart, and the page must —
-- one is "quiet realm", the other is "the wire is down, trust nothing here".
CREATE TABLE IF NOT EXISTS adventure_roster_meta (
id INTEGER PRIMARY KEY CHECK (id = 1),
snapshot_at INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS post_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
guid TEXT NOT NULL,