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.
257 lines
9.0 KiB
Go
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)
|
|
}
|
|
}
|