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) } // 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) } }