diff --git a/internal/storage/orders.go b/internal/storage/orders.go new file mode 100644 index 0000000..5f8421d --- /dev/null +++ b/internal/storage/orders.go @@ -0,0 +1,244 @@ +package storage + +import ( + "database/sql" + "errors" + "fmt" +) + +// The action queue: the second channel that carries intent back to the game box, +// and the first one that acts on the adventurer rather than on their kit. +// +// Same shape as the equip queue, deliberately — a signed-in owner asks for +// something on a page they own, Pete records only the intent, gogobee polls, +// runs the real rule against its own tables, and files a verdict Pete renders. +// Pete never ends an expedition and never swings at a boss; it records that +// somebody asked to. +// +// The reason this is its own table rather than more actions on equip_orders is +// vocabulary: an equip order is about an item in a slot at a tier, and none of +// those columns mean anything to "leave the dungeon". See the schema comment. +// +// Neither verb is naturally idempotent — an extract ends a run, a bout spends +// the day's only swing — so gogobee guards on the order guid before it mutates, +// exactly as the equip poller does. On Pete's side the mechanic is the equip +// queue's: a verdict only moves a still-pending row, so a retried verdict is a +// no-op. + +// AdvOrder is one requested action and its current standing. +type AdvOrder struct { + GUID string `json:"guid"` + OwnerSub string `json:"-"` // OIDC subject; keys "my orders", never sent to gogobee + OwnerLocalpart string `json:"owner_localpart"` // Matrix localpart gogobee turns into an MXID — whose adventurer acts + Token string `json:"token,omitempty"` // the roster token ownership was proven against; display/audit only + CharacterName string `json:"character_name,omitempty"` // display copy, frozen at order time; gogobee ignores it + Action string `json:"action"` // extract / siege_join + Status string `json:"status"` + Detail string `json:"detail,omitempty"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at,omitempty"` +} + +// Actions. These cross the wire to gogobee, so they are part of the contract. +// +// extract pull out of a running expedition, keeping loot/XP, resumable for a +// week — the game's `!extract`. Leader-only, which gogobee enforces. +// siege_join take today's one bout against the world boss — `!adventure +// worldboss fight`. The narration still lands in Matrix; the web gets +// the damage line as the verdict. +const ( + AdvActionExtract = "extract" + AdvActionSiegeJoin = "siege_join" +) + +// Order states. Terminal states are enumerated rather than free-text so the page +// can say something specific about each; detail carries gogobee's prose. The +// rejection set is honest to what the two game paths can actually answer. +const ( + AdvOrderPending = "pending" // placed; gogobee hasn't acted yet + AdvOrderApplied = "applied" // it happened; detail says what + + AdvRejectedNotRunning = "rejected_not_running" // extract: no active expedition + AdvRejectedNotLeader = "rejected_not_leader" // extract: a party member can't call the extraction + AdvRejectedNoSiege = "rejected_no_siege" // siege_join: nothing camped outside town + AdvRejectedAlreadyFought = "rejected_already_fought" // siege_join: today's bout is spent + AdvRejectedUnavailable = "rejected_unavailable" // no character, or dead +) + +func validAdvAction(action string) bool { + switch action { + case AdvActionExtract, AdvActionSiegeJoin: + return true + } + return false +} + +// validAdvVerdict is the set of terminal states gogobee may hand back. +func validAdvVerdict(status string) bool { + switch status { + case AdvOrderApplied, AdvRejectedNotRunning, AdvRejectedNotLeader, + AdvRejectedNoSiege, AdvRejectedAlreadyFought, AdvRejectedUnavailable: + return true + } + return false +} + +var ErrNoSuchAdvOrder = errors.New("orders: no such order") + +// InsertAdvOrder records a fresh, pending order and returns it with a new guid. +// The guid is minted here so the owner has a stable reference the instant they +// click, before gogobee has heard of it. The caller has already proved the signed- +// in viewer owns this adventurer; whether the action is *legal right now* is +// gogobee's answer, at verdict time. +func InsertAdvOrder(ownerSub, ownerLocalpart, token, characterName, action string) (AdvOrder, error) { + if !validAdvAction(action) { + return AdvOrder{}, fmt.Errorf("orders: bad action %q", action) + } + guid, err := newGUID() + if err != nil { + return AdvOrder{}, err + } + now := nowUnix() + if _, err := Get().Exec( + `INSERT INTO adventure_orders + (guid, owner_sub, owner_localpart, token, character_name, action, status, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + guid, ownerSub, ownerLocalpart, token, characterName, action, AdvOrderPending, now, now, + ); err != nil { + return AdvOrder{}, fmt.Errorf("orders: insert order: %w", err) + } + return AdvOrder{ + GUID: guid, OwnerSub: ownerSub, OwnerLocalpart: ownerLocalpart, + Token: token, CharacterName: characterName, Action: action, + Status: AdvOrderPending, CreatedAt: now, UpdatedAt: now, + }, nil +} + +// PendingAdvOrders is gogobee's poll: every order still waiting. Like the equip +// queue there is no claimed-but-stale window — a gogobee that dies mid-apply +// leaves the order pending to be offered again, and its own guid ledger makes the +// replay a no-op. +func PendingAdvOrders(limit int) ([]AdvOrder, error) { + if limit <= 0 { + limit = 100 + } + rows, err := Get().Query( + `SELECT guid, owner_sub, owner_localpart, token, character_name, action, status, COALESCE(detail, ''), created_at, updated_at + FROM adventure_orders + WHERE status = ? + ORDER BY created_at + LIMIT ?`, + AdvOrderPending, limit, + ) + if err != nil { + return nil, fmt.Errorf("orders: pending orders: %w", err) + } + defer rows.Close() + return scanAdvOrders(rows) +} + +// ResolveAdvOrder files gogobee's verdict against a pending order. Idempotent by +// the equip queue's mechanic: the UPDATE only moves a still-pending row, and the +// row is read back unconditionally so a first verdict, a retried verdict, and a +// missing row all take one path. +func ResolveAdvOrder(guid, status, detail string) (AdvOrder, error) { + if !validAdvVerdict(status) { + return AdvOrder{}, fmt.Errorf("orders: bad verdict %q", status) + } + now := nowUnix() + if _, err := Get().Exec( + `UPDATE adventure_orders SET status = ?, detail = ?, updated_at = ? + WHERE guid = ? AND status = ?`, + status, detail, now, guid, AdvOrderPending, + ); err != nil { + return AdvOrder{}, fmt.Errorf("orders: resolve order: %w", err) + } + return AdvOrderByGUID(guid) +} + +// AdvOrderByGUID reads one order. +func AdvOrderByGUID(guid string) (AdvOrder, error) { + rows, err := Get().Query( + `SELECT guid, owner_sub, owner_localpart, token, character_name, action, status, COALESCE(detail, ''), created_at, updated_at + FROM adventure_orders WHERE guid = ?`, guid, + ) + if err != nil { + return AdvOrder{}, fmt.Errorf("orders: read order: %w", err) + } + defer rows.Close() + out, err := scanAdvOrders(rows) + if err != nil { + return AdvOrder{}, err + } + if len(out) == 0 { + return AdvOrder{}, ErrNoSuchAdvOrder + } + return out[0], nil +} + +// AdvOrdersByOwner returns an owner's own recent orders, newest first, for the +// status strip. Keyed on the OIDC subject so a rename doesn't strand history. +func AdvOrdersByOwner(ownerSub string, limit int) ([]AdvOrder, error) { + if limit <= 0 { + limit = 20 + } + rows, err := Get().Query( + `SELECT guid, owner_sub, owner_localpart, token, character_name, action, status, COALESCE(detail, ''), created_at, updated_at + FROM adventure_orders + WHERE owner_sub = ? + ORDER BY created_at DESC + LIMIT ?`, + ownerSub, limit, + ) + if err != nil { + return nil, fmt.Errorf("orders: orders by owner: %w", err) + } + defer rows.Close() + return scanAdvOrders(rows) +} + +// HasPendingAdvOrder reports whether this owner already has an unanswered order +// of this action outstanding. Unlike the equip queue's burst counter this is a +// correctness guard, not anti-spam: two queued extracts would apply in sequence +// and the second would come back "no expedition to leave", which reads as a +// failure for something that in fact worked. +func HasPendingAdvOrder(ownerSub, action string) (bool, error) { + var n int + err := Get().QueryRow( + `SELECT COUNT(*) FROM adventure_orders WHERE owner_sub = ? AND action = ? AND status = ?`, + ownerSub, action, AdvOrderPending, + ).Scan(&n) + if err != nil { + return false, fmt.Errorf("orders: pending lookup: %w", err) + } + return n > 0, nil +} + +// CountAdvOrdersSince backs the web anti-spam guard, same role as the equip +// queue's: the real eligibility is gogobee's at verdict time, this only blunts a +// stuck mouse button. +func CountAdvOrdersSince(ownerSub string, since int64) (int, error) { + var n int + err := Get().QueryRow( + `SELECT COUNT(*) FROM adventure_orders WHERE owner_sub = ? AND created_at >= ?`, + ownerSub, since, + ).Scan(&n) + if err != nil { + return 0, fmt.Errorf("orders: count recent orders: %w", err) + } + return n, nil +} + +func scanAdvOrders(rows *sql.Rows) ([]AdvOrder, error) { + var out []AdvOrder + for rows.Next() { + var o AdvOrder + if err := rows.Scan(&o.GUID, &o.OwnerSub, &o.OwnerLocalpart, &o.Token, + &o.CharacterName, &o.Action, &o.Status, &o.Detail, + &o.CreatedAt, &o.UpdatedAt); err != nil { + return nil, fmt.Errorf("orders: scan order: %w", err) + } + out = append(out, o) + } + return out, rows.Err() +} diff --git a/internal/storage/orders_test.go b/internal/storage/orders_test.go new file mode 100644 index 0000000..56f7047 --- /dev/null +++ b/internal/storage/orders_test.go @@ -0,0 +1,113 @@ +package storage + +import ( + "errors" + "testing" +) + +func TestAdvOrderRoundTrip(t *testing.T) { + setupTestDB(t) + + o, err := InsertAdvOrder("sub-1", "josie", "tok-josie", "Josie", AdvActionExtract) + if err != nil { + t.Fatalf("insert: %v", err) + } + if o.GUID == "" || o.Status != AdvOrderPending { + t.Fatalf("order = %+v, want a guid and pending", o) + } + + pending, err := PendingAdvOrders(10) + if err != nil { + t.Fatalf("pending: %v", err) + } + if len(pending) != 1 || pending[0].GUID != o.GUID || pending[0].Action != AdvActionExtract { + t.Fatalf("pending = %+v", pending) + } + + got, err := ResolveAdvOrder(o.GUID, AdvOrderApplied, "out on day 3") + if err != nil { + t.Fatalf("resolve: %v", err) + } + if got.Status != AdvOrderApplied || got.Detail != "out on day 3" { + t.Fatalf("resolved = %+v", got) + } + if left, _ := PendingAdvOrders(10); len(left) != 0 { + t.Fatalf("a resolved order is still pending: %+v", left) + } +} + +// TestAdvOrderVerdictOnlyMovesAPendingRow is the idempotency mechanic: gogobee +// retries its verdict push, so the second one must be a read, not a write. +func TestAdvOrderVerdictOnlyMovesAPendingRow(t *testing.T) { + setupTestDB(t) + o, _ := InsertAdvOrder("sub-1", "josie", "tok-josie", "Josie", AdvActionSiegeJoin) + if _, err := ResolveAdvOrder(o.GUID, AdvOrderApplied, "first"); err != nil { + t.Fatalf("first verdict: %v", err) + } + got, err := ResolveAdvOrder(o.GUID, AdvRejectedNoSiege, "second") + if err != nil { + t.Fatalf("second verdict: %v", err) + } + if got.Status != AdvOrderApplied || got.Detail != "first" { + t.Fatalf("order = %q/%q, want the first verdict to stand", got.Status, got.Detail) + } +} + +func TestAdvOrderRejectsBadInput(t *testing.T) { + setupTestDB(t) + + if _, err := InsertAdvOrder("sub-1", "josie", "tok", "Josie", "sell_house"); err == nil { + t.Fatal("an unknown action was accepted") + } + o, _ := InsertAdvOrder("sub-1", "josie", "tok", "Josie", AdvActionExtract) + if _, err := ResolveAdvOrder(o.GUID, "exploded", ""); err == nil { + t.Fatal("an unknown verdict was accepted") + } + if _, err := AdvOrderByGUID("nope"); !errors.Is(err, ErrNoSuchAdvOrder) { + t.Fatalf("unknown guid err = %v, want ErrNoSuchAdvOrder", err) + } +} + +// TestHasPendingAdvOrderIsPerVerb: the guard stops a double-click on one button, +// not the other button. +func TestHasPendingAdvOrderIsPerVerb(t *testing.T) { + setupTestDB(t) + o, _ := InsertAdvOrder("sub-1", "josie", "tok", "Josie", AdvActionExtract) + + if got, _ := HasPendingAdvOrder("sub-1", AdvActionExtract); !got { + t.Fatal("a pending extract wasn't seen") + } + if got, _ := HasPendingAdvOrder("sub-1", AdvActionSiegeJoin); got { + t.Fatal("a pending extract blocked a bout") + } + if got, _ := HasPendingAdvOrder("sub-2", AdvActionExtract); got { + t.Fatal("one owner's pending order was seen for another") + } + // A resolved order stops holding the verb. + if _, err := ResolveAdvOrder(o.GUID, AdvOrderApplied, ""); err != nil { + t.Fatalf("resolve: %v", err) + } + if got, _ := HasPendingAdvOrder("sub-1", AdvActionExtract); got { + t.Fatal("a resolved order still holds its verb") + } +} + +func TestAdvOrdersByOwnerScopes(t *testing.T) { + setupTestDB(t) + if _, err := InsertAdvOrder("sub-A", "alice", "tok-a", "Alice", AdvActionExtract); err != nil { + t.Fatalf("insert: %v", err) + } + if _, err := InsertAdvOrder("sub-B", "bob", "tok-b", "Bob", AdvActionExtract); err != nil { + t.Fatalf("insert: %v", err) + } + got, err := AdvOrdersByOwner("sub-A", 10) + if err != nil { + t.Fatalf("by owner: %v", err) + } + if len(got) != 1 || got[0].OwnerLocalpart != "alice" { + t.Fatalf("orders = %+v, want only alice's", got) + } + if n, _ := CountAdvOrdersSince("sub-A", 0); n != 1 { + t.Fatalf("count = %d, want 1", n) + } +} diff --git a/internal/storage/schema.go b/internal/storage/schema.go index abd0b06..e6e0d8e 100644 --- a/internal/storage/schema.go +++ b/internal/storage/schema.go @@ -393,6 +393,43 @@ CREATE TABLE IF NOT EXISTS equip_orders ( CREATE INDEX IF NOT EXISTS idx_equip_orders_pending ON equip_orders(status, created_at); CREATE INDEX IF NOT EXISTS idx_equip_orders_owner ON equip_orders(owner_sub, created_at DESC); +-- An action an owner asked for from the web — pull out of a run, take today's +-- swing at the Siege — on its way to gogobee. Same reverse-pipe shape as +-- equip_orders and the same guid-as-idempotency-key contract, but a SEPARATE +-- table on purpose: every column of equip_orders is equip vocabulary (item, slot, +-- tier), and these verbs act on the character rather than on something it is +-- carrying. Sharing the table would have meant rows where most columns are +-- meaningless and an action set nobody could read. +-- +-- The status ladder: +-- +-- pending -> applied (it happened; detail says what) +-- -> rejected_not_running (extract: no expedition to leave) +-- -> rejected_not_leader (extract: a party member can't call it) +-- -> rejected_no_siege (siege_join: nothing camped outside town) +-- -> rejected_already_fought (siege_join: today's bout is already spent) +-- -> rejected_unavailable (no character, or dead) +-- +-- Like the equip queue, the underlying game action is NOT idempotent — an extract +-- ends an expedition and a bout spends a day — so gogobee short-circuits on the +-- guid before it mutates anything. token is the roster token the order was placed +-- from; gogobee ignores it (the localpart names the character) but it is what +-- Pete proved ownership against, and it keeps the row self-describing. +CREATE TABLE IF NOT EXISTS adventure_orders ( + guid TEXT PRIMARY KEY, + owner_sub TEXT NOT NULL, + owner_localpart TEXT NOT NULL, + token TEXT NOT NULL DEFAULT '', + character_name TEXT NOT NULL DEFAULT '', + action TEXT NOT NULL, -- extract / siege_join + status TEXT NOT NULL, -- see the ladder above + detail TEXT, -- gogobee's human note on the verdict + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_adventure_orders_pending ON adventure_orders(status, created_at); +CREATE INDEX IF NOT EXISTS idx_adventure_orders_owner ON adventure_orders(owner_sub, created_at DESC); + -- A player's private, owner-only expansion — inventory, vault, house, pets — -- pushed whole by gogobee on the roster tick. Keyed by localpart (== session -- Username), a *separate keyspace* from the anonymous roster tokens on purpose: diff --git a/internal/web/orders.go b/internal/web/orders.go new file mode 100644 index 0000000..3c37cae --- /dev/null +++ b/internal/web/orders.go @@ -0,0 +1,240 @@ +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}) +} diff --git a/internal/web/orders_test.go b/internal/web/orders_test.go new file mode 100644 index 0000000..6e01ba0 --- /dev/null +++ b/internal/web/orders_test.go @@ -0,0 +1,247 @@ +package web + +import ( + "bytes" + "encoding/json" + "net/http/httptest" + "strings" + "testing" + "time" + + "pete/internal/storage" +) + +// W5: the action queue's web seam. Two contracts, same shape as the equip queue's +// tests — the owner half must be unable to act for anybody but itself, and the +// gogobee half is a bearer-authed, idempotent pending/verdict pair. + +// seedActions stands up a board and a private detail row owned by `owner`, which +// together are gogobee's proof that this account has an adventurer. `status` is +// the roster status the mark carries ("expedition" or "idle"), because the +// extract pre-check reads it. +func seedActions(t *testing.T, owner, status string) *Server { + t.Helper() + s, _ := newAdvServer(t, "tok") + s.auth = &Authenticator{secret: []byte("test-secret-key-at-least-16")} + now := time.Now().Unix() + + e := entry("tok-josie", "Josie", status, "holymachina") + if w := postRoster(t, s, "tok", rosterPush{SnapshotAt: now, Adventurers: []storage.RosterEntry{e}}); w.Code != 200 { + t.Fatalf("seed roster = %d", w.Code) + } + if w := postDetail(t, s, "tok", detailPush{SnapshotAt: now, Players: []storage.PlayerDetail{{ + Localpart: owner, Token: "tok-josie", + }}}); w.Code != 200 { + t.Fatalf("seed detail = %d", w.Code) + } + return s +} + +func placeAction(t *testing.T, s *Server, username, action string) *httptest.ResponseRecorder { + t.Helper() + r := as(t, s, username, "POST", "/api/adventure/order", advOrderReq{Action: action}) + w := httptest.NewRecorder() + s.handleAdvOrder(w, r) + return w +} + +// TestActionOrderNamesNoCharacter is the reason this seam has a smaller attack +// surface than the equip queue's: nothing in the request identifies an +// adventurer, so there is no id to forge. The order that lands must be attributed +// to the session's own localpart and its own token, whatever the body said. +func TestActionOrderNamesNoCharacter(t *testing.T) { + s := seedActions(t, "holymachina", "expedition") + + // A body carrying extra fields — a token, a localpart — must change nothing: + // the handler reads only Action off it. + r := as(t, s, "holymachina", "POST", "/api/adventure/order", map[string]any{ + "action": "extract", "token": "tok-somebody-else", "owner_localpart": "someone", + }) + w := httptest.NewRecorder() + s.handleAdvOrder(w, r) + if w.Code != 200 { + t.Fatalf("order = %d (%s)", w.Code, w.Body.String()) + } + var got storage.AdvOrder + if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v", err) + } + if got.OwnerLocalpart != "holymachina" { + t.Fatalf("owner = %q, want the session's localpart", got.OwnerLocalpart) + } + if got.Token != "tok-josie" { + t.Fatalf("token = %q, want the token resolved from the session, not the body", got.Token) + } + if got.Status != storage.AdvOrderPending { + t.Fatalf("status = %q, want pending — Pete never claims an action landed", got.Status) + } +} + +// TestActionOrderNeedsAnAdventurer: a signed-in visitor with no self-detail row +// has no adventurer for gogobee to act on. Queuing the order anyway would file +// something gogobee can only answer with a rejection. +func TestActionOrderNeedsAnAdventurer(t *testing.T) { + s, _ := newAdvServer(t, "tok") + s.auth = &Authenticator{secret: []byte("test-secret-key-at-least-16")} + if w := placeAction(t, s, "stranger", "extract"); w.Code != 403 { + t.Fatalf("order without an adventurer = %d, want 403", w.Code) + } +} + +// TestOnlyOneOutstandingOrderPerVerb. Two queued extracts apply in sequence and +// the second answers "you weren't on an expedition" — a rejection for something +// that worked, which is the worst thing the strip could say. The guard is per +// verb, so a pending extract must not block a Siege bout. +func TestOnlyOneOutstandingOrderPerVerb(t *testing.T) { + s := seedActions(t, "holymachina", "expedition") + postSiege(t, s, "tok", liveSiege(time.Now().Unix(), 800)) + + if w := placeAction(t, s, "holymachina", "extract"); w.Code != 200 { + t.Fatalf("first extract = %d (%s)", w.Code, w.Body.String()) + } + w := placeAction(t, s, "holymachina", "extract") + if w.Code != 409 { + t.Fatalf("second extract = %d, want 409", w.Code) + } + if w := placeAction(t, s, "holymachina", "siege_join"); w.Code != 200 { + t.Fatalf("bout blocked by a pending extract = %d (%s); the guard is per verb", w.Code, w.Body.String()) + } +} + +// TestActionPreChecksAreCourtesyOnly pins both halves of a deliberate asymmetry. +// Pete refuses what its own snapshot says is impossible — but the snapshot is up +// to two minutes old, so the refusal must be cheap and local (a 409 the button +// shows immediately), never a queued order gogobee has to answer. +func TestActionPreChecksAreCourtesyOnly(t *testing.T) { + // Idle mark: extract refused up front. + s := seedActions(t, "holymachina", "idle") + if w := placeAction(t, s, "holymachina", "extract"); w.Code != 409 { + t.Fatalf("extract while idle = %d, want 409", w.Code) + } + + // No Siege pushed at all: unknown, not "inactive". Pete has never heard from + // gogobee about a boss, and refusing on that would make the button dead on a + // fresh deploy. It must go through and let gogobee answer. + if w := placeAction(t, s, "holymachina", "siege_join"); w.Code != 200 { + t.Fatalf("bout with no siege snapshot at all = %d, want it queued", w.Code) + } + + // A snapshot that positively says no boss is camped: refuse. + s2 := seedActions(t, "holymachina", "idle") + now := time.Now().Unix() + postSiege(t, s2, "tok", siegePush{SnapshotAt: now, Siege: storage.Siege{Active: false}}) + if w := placeAction(t, s2, "holymachina", "siege_join"); w.Code != 409 { + t.Fatalf("bout with no boss camped = %d, want 409", w.Code) + } +} + +func TestActionOrderRejectsAnUnknownVerb(t *testing.T) { + s := seedActions(t, "holymachina", "expedition") + if w := placeAction(t, s, "holymachina", "sell_house"); w.Code != 400 { + t.Fatalf("unknown action = %d, want 400", w.Code) + } +} + +// TestActionOrdersAreScopedToTheirOwner: the strip is read back by OIDC subject. +// `as` signs every session as sub-1, so this drives the storage layer directly to +// prove the scoping rather than pretending two sessions exist. +func TestActionOrdersAreScopedToTheirOwner(t *testing.T) { + s := seedActions(t, "holymachina", "expedition") + if w := placeAction(t, s, "holymachina", "extract"); w.Code != 200 { + t.Fatalf("place = %d", w.Code) + } + if _, err := storage.InsertAdvOrder("sub-2", "someone", "tok-other", "Other", storage.AdvActionExtract); err != nil { + t.Fatalf("insert other: %v", err) + } + + r := as(t, s, "holymachina", "GET", "/api/adventure/orders", nil) + w := httptest.NewRecorder() + s.handleAdvOrders(w, r) + var got []storage.AdvOrder + if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v", err) + } + if len(got) != 1 || got[0].OwnerLocalpart != "holymachina" { + t.Fatalf("orders = %+v, want only the signed-in owner's", got) + } +} + +// TestActionVerdictIsIdempotent: gogobee's poll loop retries, so the same verdict +// arrives more than once and only the first may move the order. A second verdict +// overwriting the first would let a re-offer's "no expedition to leave" replace +// the "done" that was true. +func TestActionVerdictIsIdempotent(t *testing.T) { + s := seedActions(t, "holymachina", "expedition") + w := placeAction(t, s, "holymachina", "extract") + var order storage.AdvOrder + _ = json.Unmarshal(w.Body.Bytes(), &order) + + first := postVerdict(t, s, "tok", advOrderVerdict{ + GUID: order.GUID, Status: storage.AdvOrderApplied, Detail: "Out on day 3.", + }) + if first.Code != 200 { + t.Fatalf("verdict = %d (%s)", first.Code, first.Body.String()) + } + second := postVerdict(t, s, "tok", advOrderVerdict{ + GUID: order.GUID, Status: storage.AdvRejectedNotRunning, Detail: "no run", + }) + if second.Code != 200 { + t.Fatalf("retried verdict = %d, want a quiet 200", second.Code) + } + got, err := storage.AdvOrderByGUID(order.GUID) + if err != nil { + t.Fatalf("read back: %v", err) + } + if got.Status != storage.AdvOrderApplied || !strings.Contains(got.Detail, "day 3") { + t.Fatalf("order = %q/%q, want the first verdict to stand", got.Status, got.Detail) + } +} + +// TestActionWireNeedsTheBearerToken: the poll and the verdict are gogobee's, and +// the pending list names every player who has asked for something. +func TestActionWireNeedsTheBearerToken(t *testing.T) { + s := seedActions(t, "holymachina", "expedition") + placeAction(t, s, "holymachina", "extract") + + req := httptest.NewRequest("GET", "/api/adventure/orders/pending", nil) + w := httptest.NewRecorder() + s.handleAdvOrdersPending(w, req) + if w.Code != 401 { + t.Fatalf("unauthed poll = %d, want 401", w.Code) + } + + req = httptest.NewRequest("GET", "/api/adventure/orders/pending", nil) + req.Header.Set("Authorization", "Bearer tok") + w = httptest.NewRecorder() + s.handleAdvOrdersPending(w, req) + if w.Code != 200 { + t.Fatalf("authed poll = %d", w.Code) + } + var pending []storage.AdvOrder + if err := json.Unmarshal(w.Body.Bytes(), &pending); err != nil { + t.Fatalf("decode: %v", err) + } + if len(pending) != 1 || pending[0].Action != storage.AdvActionExtract { + t.Fatalf("pending = %+v, want the one queued extract", pending) + } +} + +// TestVerdictForAnUnknownOrderIs400: under this seam's contract that parks the +// row for a human rather than retrying forever against a row that can never +// exist. +func TestVerdictForAnUnknownOrderIs400(t *testing.T) { + s := seedActions(t, "holymachina", "expedition") + if w := postVerdict(t, s, "tok", advOrderVerdict{GUID: "nope", Status: storage.AdvOrderApplied}); w.Code != 400 { + t.Fatalf("verdict for an unknown guid = %d, want 400", w.Code) + } +} + +func postVerdict(t *testing.T, s *Server, token string, v advOrderVerdict) *httptest.ResponseRecorder { + t.Helper() + body, _ := json.Marshal(v) + req := httptest.NewRequest("POST", "/api/adventure/orders/verdict", bytes.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+token) + w := httptest.NewRecorder() + s.handleAdvOrderVerdict(w, req) + return w +} diff --git a/internal/web/server.go b/internal/web/server.go index 544250d..ba13241 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -295,6 +295,13 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo mux.HandleFunc("GET /api/equip/pending", s.handleEquipPending) mux.HandleFunc("POST /api/equip/verdict", s.handleEquipVerdict) + // The action queue's game-box wire: gogobee polls the verbs an owner asked for + // from the web (pull out of a run, take today's bout) and pushes a verdict. + // Bearer-authed for the same reason as every seam above it. Its own poll and + // its own table, not more actions on the equip queue — see storage/orders.go. + mux.HandleFunc("GET /api/adventure/orders/pending", s.handleAdvOrdersPending) + mux.HandleFunc("POST /api/adventure/orders/verdict", s.handleAdvOrderVerdict) + // The casino. Signed-in only — there is money in it — so these hang off the // auth block, and gamesReady() also insists on a Matrix server name: without // one, no player can be named to gogobee's ledger and the tables stay shut. @@ -328,6 +335,12 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo // storefront, since without a board there is no detail page to equip from. mux.HandleFunc("POST /api/equip/order", s.handleEquipOrder) mux.HandleFunc("GET /api/equip/orders", s.handleEquipOrders) + + // The action queue, owner side. Signed-in only — the session IS the + // character, there is nothing in the request to identify one — and gated + // on the adventure seam like everything else here. + mux.HandleFunc("POST /api/adventure/order", s.handleAdvOrder) + mux.HandleFunc("GET /api/adventure/orders", s.handleAdvOrders) } if s.cfg.Push.Enabled { mux.HandleFunc("POST /api/push/subscribe", s.handlePushSubscribe) diff --git a/internal/web/siege.go b/internal/web/siege.go index 574eafe..3d4056a 100644 --- a/internal/web/siege.go +++ b/internal/web/siege.go @@ -82,6 +82,18 @@ type SiegePastView struct { type siegePage struct { pageData Siege SiegeView + // The viewer's own standing in the muster, when they are signed in and have + // an adventurer. This is the only personal thing on an otherwise wholly + // public page, and it exists to hang one button off: the war room is where + // somebody realises the town needs them, so it is where they should be able + // to answer. + // + // YouFought reads a snapshot up to two minutes old, so it decides what the + // page OFFERS and never what the game allows — a bout taken in Matrix inside + // that window comes back from gogobee as rejected_already_fought, which is + // the honest answer and the one the strip shows. + YouOnBoard bool + YouFought bool } // handleSiegeIngest replaces the war room with gogobee's latest snapshot. @@ -153,11 +165,24 @@ func (s *Server) handleSiegePage(w http.ResponseWriter, r *http.Request) { base := s.base(r) base.Active = "adventure" + view := s.siege() + page := siegePage{pageData: base, Siege: view} + if base.User != nil { + if token, ok := storage.SelfToken(buyerLocalpart(base.User)); ok { + page.YouOnBoard = true + for _, d := range view.Fought { + if d.Token == token { + page.YouFought = true + break + } + } + } + } // Unlike the who page this one is NOT noindex: it names a boss and a town, // and the defender list is character names that are already public on the // board. There is nothing here that ties a page to a person more than // /adventure already does. - s.render(w, "siege", siegePage{pageData: base, Siege: s.siege()}) + s.render(w, "siege", page) } // handleSiegeAPI serves the war room as JSON for the page's own re-poll. This diff --git a/internal/web/static/js/adventure-actions.js b/internal/web/static/js/adventure-actions.js new file mode 100644 index 0000000..aebc2c5 --- /dev/null +++ b/internal/web/static/js/adventure-actions.js @@ -0,0 +1,178 @@ +// The action queue, owner side — the first buttons on this site that play the +// game rather than read it. +// +// Same honesty rule as the equip queue: clicking records an intent, and the game +// box acts on its next poll. So nothing here ever says "done" on its own. It says +// "asked for", then shows whatever verdict gogobee filed, including a refusal. +// +// Shared by the adventurer page (pull out of a run) and the war room (take +// today's bout), which is why it lives in a file rather than inline in either. +(function () { + var panels = Array.prototype.slice.call(document.querySelectorAll('.adv-actions')); + if (!panels.length) return; // not an owner, or not a page with actions + + var list = document.getElementById('adv-action-orders'); + var box = document.getElementById('adv-action-orders-box'); + + // How each terminal status reads. gogobee's own detail line is preferred when + // it sent one — it names the zone, the day, the damage — and these are the + // fallback for a verdict that arrived without prose. + var STATUS = { + pending: 'asked for…', + applied: 'done', + rejected_not_running: "couldn't, you weren't on an expedition", + rejected_not_leader: "couldn't, only the party leader can call it", + rejected_no_siege: "couldn't, no Siege is camped outside town", + rejected_already_fought: "couldn't, today's bout is already spent", + rejected_unavailable: "couldn't right now" + }; + + // syncOffers keeps the panel's own copy from outliving the truth. Watching it + // run for real is what put this here: after a bout landed, the page went on + // saying "your bout is unspent" above a dead button, under a verdict that said + // the fight was over. + // + // Applied hides the offer, because the thing on offer has happened. A REFUSAL + // puts the button back, and that asymmetry is the point: a refusal is often + // about a stale page, and taking away the retry would leave them nothing to do + // about it. + function syncOffers(orders) { + var newest = {}; + orders.forEach(function (o) { if (!(o.action in newest)) newest[o.action] = o; }); + Object.keys(newest).forEach(function (action) { + var o = newest[action]; + if (o.status === 'pending') return; // still out; leave the button disabled + var btn = document.querySelector('.adv-action-btn[data-action="' + action + '"]'); + if (!btn) return; + var offer = btn.closest('[data-offer]') || btn; + if (o.status === 'applied') { + offer.classList.add('hidden'); + return; + } + // Both halves of the restore matter, and the second is easy to forget: + // re-enabling a button inside a wrapper this function hid on an earlier + // pass gives back a control nobody can see. + offer.classList.remove('hidden'); + btn.disabled = false; + btn.classList.remove('opacity-50'); + btn.textContent = btn.getAttribute('data-label') || btn.textContent; + }); + } + + var VERB = { extract: 'Pull out', siege_join: 'Join the defence' }; + + var pollTimer = null; + + function render(orders) { + if (!list || !box) return; + list.innerHTML = ''; + if (!orders || !orders.length) { box.classList.add('hidden'); return; } + box.classList.remove('hidden'); + var anyPending = false; + orders.forEach(function (o) { + if (o.status === 'pending') anyPending = true; + // Stacked, not the equip strip's justify-between row. That layout is right + // for a two-word verdict and wrong here: gogobee answers a bout with a + // whole sentence of damage numbers, which squeezed into a right-hand column + // and pushed the verb itself onto two lines. + var li = document.createElement('li'); + var verb = document.createElement('div'); + verb.className = 'font-semibold text-[color:var(--ink)]/70'; + verb.textContent = VERB[o.action] || o.action; + var said = document.createElement('div'); + said.className = 'mt-0.5 leading-snug ' + (o.status === 'pending' + ? 'text-[color:var(--ink)]/45' + : (o.status === 'applied' ? 'text-theme-adventure font-semibold' : 'text-[color:var(--warn)]')); + said.textContent = o.detail || STATUS[o.status] || o.status; + li.appendChild(verb); li.appendChild(said); + list.appendChild(li); + }); + syncOffers(orders); + // Keep refreshing while anything is unanswered so the verdict lands without a + // reload; stop once everything is terminal. A Siege bout runs a whole combat + // on the game box, so this can legitimately sit on "asked for" for a while. + if (anyPending && !pollTimer) { + pollTimer = setInterval(loadOrders, 10000); + } else if (!anyPending && pollTimer) { + clearInterval(pollTimer); pollTimer = null; + } + } + + function loadOrders() { + fetch('/api/adventure/orders', { headers: { 'Accept': 'application/json' } }) + .then(function (r) { return r.ok ? r.json() : null; }) + .then(function (o) { if (o) render(o); }) + .catch(function () { /* transient — a later tick will do */ }); + } + + function placeOrder(btn) { + btn.disabled = true; + btn.classList.add('opacity-50'); + var was = btn.textContent; + btn.textContent = 'asking…'; + fetch('/api/adventure/order', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: btn.getAttribute('data-action') }) + }) + .then(function (r) { return r.json().then(function (j) { return { ok: r.ok, body: j }; }); }) + .then(function (res) { + if (!res.ok) { + btn.disabled = false; + btn.classList.remove('opacity-50'); + btn.textContent = (res.body && res.body.error) || 'try again'; + setTimeout(function () { btn.textContent = was; }, 4000); + return; + } + btn.textContent = 'asked for'; + loadOrders(); + }) + .catch(function () { + btn.disabled = false; + btn.classList.remove('opacity-50'); + btn.textContent = 'try again'; + setTimeout(function () { btn.textContent = was; }, 4000); + }); + } + + // Both verbs are one-way — an extraction ends the run for the whole party and a + // bout is the only one you get today — so both confirm. Built in the DOM rather + // than with confirm(), which would block the event loop and, on this site's own + // evidence, wedge an automated browser. + function askConfirm(btn) { + var panel = btn.closest('.adv-actions') || btn.parentElement; + var existing = panel.querySelector('.adv-action-confirm'); + if (existing) existing.remove(); + + var boxEl = document.createElement('div'); + boxEl.className = 'adv-action-confirm mt-3 rounded-xl bg-[color:var(--ink)]/5 p-3 text-sm'; + var p = document.createElement('p'); + p.className = 'text-[color:var(--ink)]/70'; + p.textContent = btn.getAttribute('data-confirm') || 'Are you sure?'; + var row = document.createElement('div'); + row.className = 'mt-2 flex gap-1.5'; + var yes = document.createElement('button'); + yes.type = 'button'; + yes.className = 'rounded-full bg-theme-adventure text-white px-3 py-1 font-semibold'; + yes.textContent = btn.getAttribute('data-confirm-label') || 'Yes, do it'; + yes.addEventListener('click', function () { boxEl.remove(); placeOrder(btn); }); + var no = document.createElement('button'); + no.type = 'button'; + no.className = 'rounded-full border border-[color:var(--ink)]/20 text-[color:var(--ink)]/60 px-3 py-1'; + no.textContent = 'Not yet'; + no.addEventListener('click', function () { boxEl.remove(); }); + row.appendChild(yes); row.appendChild(no); + boxEl.appendChild(p); boxEl.appendChild(row); + panel.appendChild(boxEl); + } + + panels.forEach(function (panel) { + panel.addEventListener('click', function (e) { + var btn = e.target.closest('.adv-action-btn'); + if (!btn || btn.disabled) return; + askConfirm(btn); + }); + }); + + loadOrders(); +})(); diff --git a/internal/web/templates/siege.html b/internal/web/templates/siege.html index 2b2e5c4..eaffe67 100644 --- a/internal/web/templates/siege.html +++ b/internal/web/templates/siege.html @@ -58,14 +58,46 @@ - -
-

- Taking your bout: - say !adventure siege fight - to me in Matrix. One a day, each. Damage counts whether you win the fight or not — turning up is the mechanic. -

+ +
+ {{if .YouOnBoard}} + {{if .YouFought}} +

+ You've taken your bout today. + Come back tomorrow. One fight each, per day, and everyone in the right-hand column below still has theirs. +

+ {{else}} + {{/* data-offer marks the half of this panel that stops being true the + moment the bout lands. The script hides it on an applied verdict, so + the page can't go on saying "unspent" over a fight that just + happened. */}} +
+

+ Your bout is unspent. + Damage counts whether you win the fight or not. Turning up is the mechanic, and the blow-by-blow lands in Matrix. +

+ +
+ {{end}} + {{else}} +

+ Taking your bout: + say !adventure siege fight + to me in Matrix. One a day, each. Damage counts whether you win the fight or not; turning up is the mechanic. +

+ {{end}} + +
+
+

Your call

+

+ Asked for here, done on the game box. It picks these up within a few seconds. +

+ {{/* data-offer marks what stops being true once the extraction lands: there + is no run left to pull out of. Hidden by the script on an applied + verdict, restored on a refusal, which is the one case where the reader + still needs the retry. */}} +
+ +
+ + + +
+ {{end}} + {{if .HasHistory}}