Files
Pete/internal/web/orders.go
T
prosolis 6b0aae9f4a adventure: let a player act from the web, not just read about it
The equip queue proved the reverse pipe works. This gives it verbs that
play the game: pull out of a run from the adventurer page, take today's
bout from the war room.

Its own table and its own poll, not more actions on equip_orders. Every
column of that table is equip vocabulary (item, slot, tier) and these
verbs act on the character rather than on something it is carrying.

Nothing in a request names an adventurer. The session maps to one
localpart and a localpart to one adventurer, so Pete resolves the
character itself and there is no id on the wire to forge.

The panel's copy is kept honest by the verdict: an applied action hides
the offer it has just spent, a refusal puts the button back. Watching it
run is what put that there, along with the strip's layout — gogobee
answers a bout with a whole sentence of damage, which the equip strip's
two-column row squeezed into a column and wrapped the verb.

Claude-Session: https://claude.ai/code/session_012bxpQQJDjC1mTtLN3VVtBQ
2026-07-24 18:55:42 -07:00

241 lines
8.5 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. Just the verb: see the file comment on
// why nothing identifies the character.
type advOrderReq struct {
Action string `json:"action"`
}
// 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:
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
}
// Per-verb pre-checks, all courtesy only. Both read Pete's snapshot copy, which
// is up to two minutes behind the game box, so neither is authoritative and
// neither is allowed to be the last word — a run that ended in that window comes
// back from gogobee as rejected_not_running, which is the honest answer.
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
}
switch req.Action {
case storage.AdvActionExtract:
if haveEntry && entry.Status != "expedition" {
writeAdvOrderError(w, http.StatusConflict, "you're not on an expedition")
return
}
case storage.AdvActionSiegeJoin:
snap, known, err := storage.LoadSiege()
if err != nil {
slog.Error("orders: siege lookup", "err", err)
writeAdvOrderError(w, http.StatusInternalServerError, "internal error")
return
}
if known && !snap.Active {
writeAdvOrderError(w, http.StatusConflict, "no Siege is camped outside town")
return
}
}
order, err := storage.InsertAdvOrder(u.Sub, owner, token, characterName, req.Action)
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)
}
// 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 err != nil {
slog.Error("orders: resolve", "guid", v.GUID, "status", v.Status, "err", err)
http.Error(w, "bad verdict", http.StatusBadRequest)
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})
}