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
241 lines
8.5 KiB
Go
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})
|
|
}
|