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 }