The web half of Mischief Makers M3. A signed-in buyer picks a mark off the anonymous roster board and pays for a monster to find them; gogobee does the real work and hands back a verdict Pete files against the order. - mischief_orders: intent in, verdict out, idempotent on a guid that is the end-to-end key gogobee passes to DebitIdem and stamps on the contract - user_euro + mischief_tiers: advisory balance and the live price list, pushed on the roster tick so the storefront never hardcodes a number that can drift - OIDC-gated buy API (target + tier + signed), bearer-authed poll/claim wire - roster board grows a 'send trouble' button, a tier picker, and a status panel Pete never touches money and never runs a game rule. It records what a buyer wants and what gogobee said happened.
130 lines
4.4 KiB
Go
130 lines
4.4 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
|
|
}
|
|
|
|
// RosterEntryByToken looks up one adventurer by their roster token. The bool is
|
|
// false when no such token is on the current board — which is exactly the check
|
|
// the storefront needs: a buyer may only order a hit on a mark the live board is
|
|
// actually showing, never a stale or guessed token.
|
|
func RosterEntryByToken(token string) (RosterEntry, bool, error) {
|
|
var e RosterEntry
|
|
err := Get().QueryRow(`
|
|
SELECT token, name, level, COALESCE(class_race, ''), status,
|
|
COALESCE(zone, ''), COALESCE(region, ''), day, idle_hours, snapshot_at
|
|
FROM adventure_roster WHERE token = ?`, token).Scan(
|
|
&e.Token, &e.Name, &e.Level, &e.ClassRace, &e.Status,
|
|
&e.Zone, &e.Region, &e.Day, &e.IdleHours, &e.SnapshotAt)
|
|
if err == sql.ErrNoRows {
|
|
return RosterEntry{}, false, nil
|
|
}
|
|
if err != nil {
|
|
return RosterEntry{}, false, err
|
|
}
|
|
return e, true, 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
|
|
}
|