Files
prosolis aac6c3e127 adventure: work the five review findings the last pass left open
The extract pre-check is gone. It read a snapshot up to two minutes behind and
still got the last word, so somebody who set out over Matrix during a lagging
roster push was told they weren't on an expedition for a run gogobee would
happily have ended. Same call abandon and leave already made: let it through and
let rejected_not_running be the answer.

The siege_join check stays, because whether a boss is camped outside town is
town-wide and runs on a day-or-longer clock, but it now reads one column through
SiegeIsCamped instead of loading every defender row and the whole history to
look at one flag.

The war-room history insert is OR REPLACE. boss_id is the primary key and it was
never settled whether gogobee means the siege instance or the boss type by it, so
a duplicate pair used to fail the transaction carrying the live boss and the
muster too and freeze the war room on the last good snapshot. A dropped history
row is the smaller failure; the open question is noted in the schema.

offersToUndo's guard didn't cover the case its comment claimed. A gogobee too old
to push seats sends a valid blob with no party key, which decodes to the same
empty slice as a solo run, and a party member got shown the button that throws
away everyone's day. That needs a new field, so whoDetail gains party_known and
the flag gates the empty-list branch alone; the branch that reads the viewer's
own seat is self-evidencing and keeps working against any sender. gogobee's half
is written up in adventure_party_known_flag.md.

And an empty offer list no longer claims "you're already out there", which Pete
can't actually know from a game box too old to push offers at all.
2026-07-24 22:45:26 -07:00

371 lines
15 KiB
Go

package web
import (
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"time"
"pete/internal/storage"
)
// The action queue's web seam — the first verbs the web can play, as opposed to
// the equip queue's dressing-up.
//
// Two audiences, same shape as equip and mischief. A signed-in owner clicks
// "Pull out" on their own adventurer page or "Take your bout" on the war room;
// gogobee hits the bearer-authed pair, polling pending orders and pushing a
// verdict. Pete runs no game rule: it records that somebody asked, and renders
// what gogobee answered. The UI says "asked for" and never claims it landed.
//
// The character is resolved from the SESSION, never from the request. A session
// maps to exactly one localpart and a localpart to exactly one adventurer, so
// there is nothing for the client to name and therefore nothing to forge — the
// equip queue has to take an item id and a slot off the wire and re-resolve them;
// this one has no such surface at all.
// advOrderBurstWindow / advOrderBurstMax blunt a stuck mouse button. The real
// gates are gogobee's — one extraction ends the run, one bout per day — and the
// pending-order guard below stops the common double-click outright.
const (
advOrderBurstWindow = time.Hour
advOrderBurstMax = 30
)
// advOrderReq is the browser's request: the verb, and for the three verbs that
// take arguments, which zone / which loadout / how many days. See the file
// comment on why nothing here identifies the character.
//
// None of these fields is trusted. Each is looked up in the owner's OWN offer
// list — the one gogobee pushed onto their private self-detail row — and the
// order stores what was found there, not what was sent. So a forged zone id
// resolves to nothing and is refused before an order exists.
type advOrderReq struct {
Action string `json:"action"`
Zone string `json:"zone,omitempty"`
Loadout string `json:"loadout,omitempty"`
Days int `json:"days,omitempty"`
}
// handleAdvOrder places a pending action for the signed-in owner. It asserts what
// Pete can honestly know — the viewer is signed in, and gogobee has pushed a
// self-detail row for them, which is gogobee's own proof that this person has an
// adventurer. Everything about whether the action is legal *right now* is
// gogobee's, at verdict time; the pre-checks here only produce a better message
// than a verdict thirty seconds later would.
func (s *Server) handleAdvOrder(w http.ResponseWriter, r *http.Request) {
u := s.requireUser(w, r)
if u == nil {
return
}
owner := buyerLocalpart(u)
if owner == "" {
writeAdvOrderError(w, http.StatusConflict, "please sign in again")
return
}
var req advOrderReq
if !decodeStateBody(w, r, &req) {
return
}
switch req.Action {
case storage.AdvActionExtract, storage.AdvActionSiegeJoin,
storage.AdvActionExpedition, storage.AdvActionResume, storage.AdvActionBabysit,
storage.AdvActionAbandon, storage.AdvActionLeave, storage.AdvActionBabysitCancel:
default:
writeAdvOrderError(w, http.StatusBadRequest, "bad action")
return
}
// Ownership. The self-detail row is gogobee's own owner<->adventurer proof, the
// same join the who page's private panels and the alert sender use. No row means
// this account has no adventurer — or gogobee has stopped pushing, in which case
// an order it can't attribute is not one we should queue.
token, ok := storage.SelfToken(owner)
if !ok {
writeAdvOrderError(w, http.StatusForbidden, "no adventurer on the board for this account")
return
}
// One outstanding order per verb. Two queued extracts would apply in sequence
// and the second would answer "no expedition to leave" — a rejection for
// something that worked, which is the worst thing this strip could say.
if pending, err := storage.HasPendingAdvOrder(u.Sub, req.Action); err != nil {
slog.Error("orders: pending lookup", "err", err)
writeAdvOrderError(w, http.StatusInternalServerError, "internal error")
return
} else if pending {
writeAdvOrderError(w, http.StatusConflict, "already asked — waiting on the game box")
return
}
since := time.Now().Add(-advOrderBurstWindow).Unix()
if n, err := storage.CountAdvOrdersSince(u.Sub, since); err != nil {
slog.Error("orders: burst count", "err", err)
writeAdvOrderError(w, http.StatusInternalServerError, "internal error")
return
} else if n >= advOrderBurstMax {
writeAdvOrderError(w, http.StatusTooManyRequests, "slow down, too many requests in a short while")
return
}
// The roster lookup is for the character name the order carries; the one
// surviving pre-check below is courtesy only. Anything read here is Pete's
// snapshot copy, up to two minutes behind the game box, so it is never
// authoritative and is only allowed the last word where being two minutes late
// cannot make it wrong.
characterName := ""
entry, haveEntry, err := storage.RosterEntryByToken(token)
if err != nil {
slog.Error("orders: roster lookup", "err", err)
writeAdvOrderError(w, http.StatusInternalServerError, "internal error")
return
}
if haveEntry {
characterName = entry.Name
}
// No pre-check on extract, deliberately, and it is the same call abandon and
// leave make in resolveAdvOrderParams: the mark's status is up to two minutes
// stale here and a Matrix departure can outrun the roster push, so "reads idle"
// would refuse a run gogobee would happily have ended. The cost accepted is
// that a genuine mistake comes back as rejected_not_running rather than as an
// instant refusal, which is the honest answer anyway.
if req.Action == storage.AdvActionSiegeJoin {
// This one stays, because it is not a personal status: whether a boss is
// camped outside town is a town-wide fact on a day-or-longer clock, so a
// two-minute-old copy is almost never wrong about it. Note the known/active
// split — no snapshot at all must queue the order (a fresh deploy must not
// have a dead button); only a snapshot that positively says active=0 refuses.
active, known, err := storage.SiegeIsCamped()
if err != nil {
slog.Error("orders: siege lookup", "err", err)
writeAdvOrderError(w, http.StatusInternalServerError, "internal error")
return
}
if known && !active {
writeAdvOrderError(w, http.StatusConflict, "no Siege is camped outside town")
return
}
}
// Resolve the verb's arguments against this owner's own offers. Everything
// this returns came out of gogobee's push, so the stored order can only ever
// name a zone, a loadout and a price the game itself quoted to this player.
params, msg := resolveAdvOrderParams(owner, token, req)
if msg != "" {
writeAdvOrderError(w, http.StatusConflict, msg)
return
}
order, err := storage.InsertAdvOrder(u.Sub, owner, token, characterName, req.Action, params)
if err != nil {
slog.Error("orders: insert order", "err", err)
writeAdvOrderError(w, http.StatusInternalServerError, "internal error")
return
}
slog.Info("orders: action placed", "guid", order.GUID, "owner", owner, "action", req.Action)
w.Header().Set("Cache-Control", "no-store")
writeJSON(w, order)
}
// resolveAdvOrderParams turns the browser's arguments into the stored ones by
// looking each up in the owner's pushed offer list, and returns the reason to
// refuse when it cannot. Five of the eight verbs take no arguments and resolve to
// nil — but babysit_cancel still comes through here, because the offer row is
// the one place Pete can see that there is no sitter to dismiss.
//
// Note what W5a's "Pete has never heard about it" asymmetry does NOT need to
// become here. There is no such state to defer on: the detail row this reads is
// the same row SelfToken already found, so by the time we get here it exists.
// What a gogobee too old to push offers produces is an EMPTY offer list, and
// then the page renders no picker at all — so there is no dead button to protect
// against, only forged arguments to refuse.
func resolveAdvOrderParams(owner, token string, req advOrderReq) (*storage.AdvOrderParams, string) {
switch req.Action {
case storage.AdvActionExtract, storage.AdvActionSiegeJoin:
return nil, ""
case storage.AdvActionAbandon, storage.AdvActionLeave:
// No snapshot pre-check for either, deliberately, and it is the same call
// W5a made for extract: the board is up to two minutes stale, so the only
// thing Pete could test — "the mark reads idle" — would refuse actions the
// game would have allowed. Abandon is worse than extract in that respect,
// because an *extracted* expedition is still abandonable while its owner
// reads as standing in town. gogobee answers rejected_not_running /
// rejected_not_leader / rejected_is_leader, and the strip shows it.
return nil, ""
}
detail, haveDetail, err := storage.PlayerDetailByOwner(owner, token)
if err != nil {
slog.Error("orders: detail lookup", "err", err)
return nil, "couldn't read your adventurer just now"
}
if !haveDetail {
// Only reachable if the row went away between SelfToken and here — the
// roster push replaces the whole table. Refuse rather than guess.
return nil, "couldn't read your adventurer just now"
}
switch req.Action {
case storage.AdvActionExpedition:
if req.Zone == "" {
return nil, "pick somewhere to go first"
}
if len(detail.Zones) == 0 {
// An empty offer list usually means they are already out there, but Pete
// cannot tell that from a game box too old to push offers at all, so say
// only what was actually seen. Unreachable from the page either way — with
// no offers the picker doesn't render — so this is a hand-crafted request.
return nil, "nowhere is on offer for you right now"
}
for _, z := range detail.Zones {
if z.ID != req.Zone {
continue
}
for _, l := range z.Loadouts {
if l.Key == req.Loadout {
return &storage.AdvOrderParams{Zone: z.ID, Loadout: l.Key}, ""
}
}
return nil, "that isn't a loadout for that zone"
}
return nil, "that zone isn't open to you"
case storage.AdvActionResume:
if detail.Resume == nil {
return nil, "there's no expedition waiting for you"
}
for _, l := range detail.Resume.Loadouts {
if l.Key == req.Loadout {
return &storage.AdvOrderParams{Loadout: l.Key}, ""
}
}
return nil, "that isn't a loadout for that zone"
case storage.AdvActionBabysit:
if req.Days != 7 && req.Days != 30 {
return nil, "the sitter works by the week or by the month"
}
if detail.Babysit != nil && detail.Babysit.Active {
return nil, "a sitter is already looking after your camp"
}
return &storage.AdvOrderParams{Days: req.Days}, ""
case storage.AdvActionBabysitCancel:
// The mirror of the check above, and the one W9 verb where the snapshot
// really does contradict the request: a sitter's engagement is a fact about
// the character, not about where they are standing, so it does not go stale
// the way "on an expedition" does. A missing offer is still not a refusal —
// that is a gogobee too old to push one, not a player without a sitter.
if detail.Babysit != nil && !detail.Babysit.Active {
return nil, "there's no sitter to dismiss"
}
return nil, ""
}
return nil, "bad action"
}
// handleAdvOrders returns the signed-in owner's own recent actions for the status
// strip, newest first. Scoped to their OIDC subject.
func (s *Server) handleAdvOrders(w http.ResponseWriter, r *http.Request) {
u := s.requireUser(w, r)
if u == nil {
return
}
orders, err := storage.AdvOrdersByOwner(u.Sub, 10)
if err != nil {
slog.Error("orders: by owner", "err", err)
writeAdvOrderError(w, http.StatusInternalServerError, "internal error")
return
}
if orders == nil {
orders = []storage.AdvOrder{}
}
w.Header().Set("Cache-Control", "no-store")
writeJSON(w, orders)
}
// ---- the gogobee wire: bearer-authed, idempotent -------------------------------
// advOrderPollLimit caps one poll, matching the equip and mischief seams.
const advOrderPollLimit = 50
// handleAdvOrdersPending is gogobee's poll: every action still waiting. Like the
// seams beside it there is no stale-reoffer window — a gogobee that dies mid-apply
// leaves the order pending to be offered again, and its guid ledger makes the
// replay a no-op.
func (s *Server) handleAdvOrdersPending(w http.ResponseWriter, r *http.Request) {
if !s.bearerOK(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
orders, err := storage.PendingAdvOrders(advOrderPollLimit)
if err != nil {
slog.Error("orders: pending", "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if orders == nil {
orders = []storage.AdvOrder{}
}
writeJSON(w, orders)
}
// advOrderVerdict is gogobee's answer on an order: the terminal status and a
// human note to render.
type advOrderVerdict struct {
GUID string `json:"guid"`
Status string `json:"status"`
Detail string `json:"detail,omitempty"`
}
// handleAdvOrderVerdict files gogobee's verdict against a pending order.
// Idempotent: gogobee's poll loop retries, so the same verdict can arrive more
// than once and only the first moves the order. An unknown guid is a 400 — under
// this seam's contract that parks the row for a human rather than retrying
// forever against a row that will never exist.
func (s *Server) handleAdvOrderVerdict(w http.ResponseWriter, r *http.Request) {
if !s.bearerOK(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
var v advOrderVerdict
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<14)).Decode(&v); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
if v.GUID == "" {
http.Error(w, "guid is required", http.StatusBadRequest)
return
}
order, err := storage.ResolveAdvOrder(v.GUID, v.Status, v.Detail)
if errors.Is(err, storage.ErrNoSuchAdvOrder) {
slog.Error("orders: verdict for an order we've never heard of", "guid", v.GUID, "status", v.Status)
http.Error(w, "no such order", http.StatusBadRequest)
return
}
if errors.Is(err, storage.ErrBadAdvVerdict) {
slog.Error("orders: verdict outside the terminal set", "guid", v.GUID, "status", v.Status)
http.Error(w, "bad verdict", http.StatusBadRequest)
return
}
if err != nil {
// A storage failure, not a bad request. 400 here would park a perfectly
// resolvable order forever on a transient database error; 500 gets it
// retried on gogobee's next poll.
slog.Error("orders: resolve", "guid", v.GUID, "status", v.Status, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
slog.Info("orders: action resolved", "guid", order.GUID, "action", order.Action, "status", order.Status)
writeJSON(w, order)
}
func writeAdvOrderError(w http.ResponseWriter, code int, msg string) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(map[string]string{"error": msg})
}