Files
Pete/internal/web/roster.go
prosolis 16711e13e6 adventure: a click-through page for every adventurer on the board
Each roster name becomes /adventure/who/{token}. Anyone sees the public sheet —
stats and equipped gear, decoded from the detail_json gogobee now hangs on each
board entry — with a live JSON re-poll so an open tab tracks HP and room as they
move. The signed-in owner sees the same page enriched with their private
inventory, vault, house, and pets, unlocked by an ownership join in the new
player_self_detail table (localpart owns token) — Pete never reverses the
anonymous token to decide it. buyerLocalpart is extracted so the storefront and
the ownership check lowercase the session name the same way.
2026-07-14 22:22:52 -07:00

257 lines
9.0 KiB
Go

package web
import (
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"time"
"pete/internal/storage"
)
// The live adventurer board.
//
// Everything else Pete publishes about the realm is an *accomplishment* — a
// death, a clear, a milestone. Those are clippings: they read as archive the
// instant they land, however fast we deliver them, and no refresh interval fixes
// that. The board is the opposite kind of thing. It is state that is currently
// true, so it is worth looking at *now*, and it goes stale on its own if we stop
// hearing from gogobee.
//
// Direction of travel is gogobee → Pete, like every other adventure payload:
// Pete has no route back into the game box's network and we are not opening one.
// gogobee pushes the whole board; we replace ours with it.
const (
// rosterStaleAfter — how old a snapshot can get before the board stops
// claiming to be live. gogobee pushes every couple of minutes, so this is
// several missed pushes, not one unlucky one.
//
// This matters more than it looks: if gogobee dies mid-expedition, the last
// snapshot says "Josie is in holymachina" and would say so forever. A board
// that lies confidently is worse than one that admits it lost the wire —
// especially once players can act on it (see the target-list note below).
rosterStaleAfter = 12 * time.Minute
// rosterMaxEntries — hard cap on a snapshot. A bounded realm; this only
// exists so a malformed or hostile push can't spool unbounded rows.
rosterMaxEntries = 500
)
// rosterPush is the payload gogobee POSTs to /api/ingest/roster.
//
// Balances ride the same tick as the board but live in a separate keyspace: they
// are keyed by localpart (a buyer's own sign-in name), not by the anonymous
// roster token, and Pete only ever reads one back for the authenticated user
// asking about themselves. So the board stays anonymous while the storefront can
// still grey out tiers a buyer plainly can't afford.
type rosterPush struct {
SnapshotAt int64 `json:"snapshot_at"`
Adventurers []storage.RosterEntry `json:"adventurers"`
Balances []storage.MischiefBalance `json:"balances,omitempty"`
Tiers []storage.MischiefTier `json:"tiers,omitempty"`
}
// RosterView is one row as the page renders it.
//
// Token is the mark's anonymous roster token, exposed so the storefront can name
// a target without ever knowing their real handle. It is safe on a public page by
// design — non-reversible, stable, and already how gogobee keys the board.
type RosterView struct {
Token string
Name string
Level int
ClassRace string
OnRun bool
Zone string
Region string
Where string // "holymachina, day 3" | "in town"
Idle string // "quiet for 2 days" — only when idle
}
// handleRosterIngest replaces the board with gogobee's latest snapshot.
func (s *Server) handleRosterIngest(w http.ResponseWriter, r *http.Request) {
if !s.adv.Enabled {
http.NotFound(w, r)
return
}
if !s.bearerOK(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
var push rosterPush
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&push); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
if len(push.Adventurers) > rosterMaxEntries {
http.Error(w, "roster too large", http.StatusBadRequest)
return
}
// A snapshot with no timestamp can't be aged, so it could never go stale —
// it would sit on the page claiming to be live forever. Treat it as now.
if push.SnapshotAt <= 0 {
push.SnapshotAt = time.Now().Unix()
}
// Never trust the channel with a name. Same rule as factGuard: gogobee
// pre-sanitizes to character names, and Pete checks anyway, because this is
// the last thing standing between the payload and a public page.
for i, e := range push.Adventurers {
if e.Token == "" || e.Name == "" {
http.Error(w, "each adventurer needs token and name", http.StatusBadRequest)
return
}
if e.Status != "expedition" && e.Status != "idle" {
http.Error(w, fmt.Sprintf("adventurer %d: unknown status %q", i, e.Status), http.StatusBadRequest)
return
}
}
if err := storage.ReplaceRoster(push.Adventurers, push.SnapshotAt); err != nil {
slog.Error("roster ingest: replace failed", "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
// Advisory balances are best-effort: a board that landed is worth keeping
// even if the balances behind it didn't, so a failure here is logged, not
// fatal. Stale affordability only ever bounces an order, never miscounts money.
if err := storage.ReplaceUserEuro(push.Balances, push.SnapshotAt); err != nil {
slog.Error("roster ingest: balance replace failed", "err", err)
}
// The tier catalog is likewise best-effort and only refreshed when the push
// actually carried one, so a gogobee build that predates the storefront (no
// tiers field) can't wipe a catalog a newer one already established.
if len(push.Tiers) > 0 {
if err := storage.ReplaceMischiefTiers(push.Tiers); err != nil {
slog.Error("roster ingest: tier replace failed", "err", err)
}
}
slog.Info("roster ingest: board replaced", "adventurers", len(push.Adventurers), "balances", len(push.Balances))
w.WriteHeader(http.StatusOK)
}
// detailPush is the payload gogobee POSTs to /api/ingest/detail: the private,
// owner-only expansion for every player, in its own keyspace (keyed by localpart)
// and on its own endpoint so a fat inventory can't blow the roster push's budget.
type detailPush struct {
SnapshotAt int64 `json:"snapshot_at"`
Players []storage.PlayerDetail `json:"players"`
}
// handleDetailIngest replaces the private self-detail set with gogobee's latest
// push. Bearer-authed like the roster; the data is owner-private and only ever
// served back to the one authenticated user it belongs to (see PlayerDetailByOwner).
func (s *Server) handleDetailIngest(w http.ResponseWriter, r *http.Request) {
if !s.adv.Enabled {
http.NotFound(w, r)
return
}
if !s.bearerOK(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
// A generous cap: this carries every player's inventory + vault, heavier than
// the board, but still a bounded realm — the ceiling only stops a runaway push.
var push detailPush
if err := json.NewDecoder(io.LimitReader(r.Body, 8<<20)).Decode(&push); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
if len(push.Players) > rosterMaxEntries {
http.Error(w, "detail set too large", http.StatusBadRequest)
return
}
if push.SnapshotAt <= 0 {
push.SnapshotAt = time.Now().Unix()
}
if err := storage.ReplacePlayerDetail(push.Players, push.SnapshotAt); err != nil {
slog.Error("detail ingest: replace failed", "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
slog.Info("detail ingest: self-view set replaced", "players", len(push.Players))
w.WriteHeader(http.StatusOK)
}
// handleRosterAPI serves the board as JSON for the page's own re-poll, so an
// open tab goes live without a reload. Public — same exposure as the rendered
// page, no more.
func (s *Server) handleRosterAPI(w http.ResponseWriter, r *http.Request) {
if !s.adv.Enabled {
http.NotFound(w, r)
return
}
views, stale, _ := s.roster()
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(map[string]any{
"stale": stale,
"adventurers": views,
})
}
// roster loads the board and decides whether it is still live. A stale board is
// still returned — the page shows it dimmed and says so, rather than blanking,
// because "here is who was out when we lost contact" beats an empty page.
func (s *Server) roster() (views []RosterView, stale bool, snapshotAt int64) {
entries, snapshotAt, err := storage.LoadRoster()
if err != nil {
slog.Error("roster: load failed", "err", err)
return nil, true, 0
}
stale = snapshotAt == 0 || time.Since(time.Unix(snapshotAt, 0)) > rosterStaleAfter
for _, e := range entries {
views = append(views, toRosterView(e))
}
return views, stale, snapshotAt
}
func toRosterView(e storage.RosterEntry) RosterView {
v := RosterView{
Token: e.Token,
Name: e.Name,
Level: e.Level,
ClassRace: e.ClassRace,
OnRun: e.Status == "expedition",
Zone: e.Zone,
Region: e.Region,
}
if v.OnRun {
where := e.Zone
if e.Region != "" {
where = fmt.Sprintf("%s, %s", e.Zone, e.Region)
}
if e.Day > 0 {
where = fmt.Sprintf("%s — day %d", where, e.Day)
}
v.Where = where
return v
}
v.Where = "in town"
v.Idle = humanIdle(e.IdleHours)
return v
}
// humanIdle renders the idle clock the way a person would say it. Deliberately
// coarse: this is colour on a board, not the boredom ticker's actual threshold.
func humanIdle(hours int) string {
switch {
case hours <= 0:
return ""
case hours < 2:
return "just got back"
case hours < 24:
return fmt.Sprintf("quiet for %dh", hours)
case hours < 48:
return "quiet for a day"
default:
return fmt.Sprintf("quiet for %d days", hours/24)
}
}