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.
371 lines
15 KiB
Go
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})
|
|
}
|