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.
212 lines
7.3 KiB
Go
212 lines
7.3 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)
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|