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
This commit is contained in:
prosolis
2026-07-24 18:55:42 -07:00
parent b19ab5eff0
commit 6b0aae9f4a
10 changed files with 1178 additions and 9 deletions
+244
View File
@@ -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()
}
+113
View File
@@ -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)
}
}
+37
View File
@@ -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_pending ON equip_orders(status, created_at);
CREATE INDEX IF NOT EXISTS idx_equip_orders_owner ON equip_orders(owner_sub, created_at DESC); 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 — -- A player's private, owner-only expansion — inventory, vault, house, pets —
-- pushed whole by gogobee on the roster tick. Keyed by localpart (== session -- pushed whole by gogobee on the roster tick. Keyed by localpart (== session
-- Username), a *separate keyspace* from the anonymous roster tokens on purpose: -- Username), a *separate keyspace* from the anonymous roster tokens on purpose:
+240
View File
@@ -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})
}
+247
View File
@@ -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
}
+13
View File
@@ -295,6 +295,13 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo
mux.HandleFunc("GET /api/equip/pending", s.handleEquipPending) mux.HandleFunc("GET /api/equip/pending", s.handleEquipPending)
mux.HandleFunc("POST /api/equip/verdict", s.handleEquipVerdict) 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 // 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 // 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. // 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. // storefront, since without a board there is no detail page to equip from.
mux.HandleFunc("POST /api/equip/order", s.handleEquipOrder) mux.HandleFunc("POST /api/equip/order", s.handleEquipOrder)
mux.HandleFunc("GET /api/equip/orders", s.handleEquipOrders) 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 { if s.cfg.Push.Enabled {
mux.HandleFunc("POST /api/push/subscribe", s.handlePushSubscribe) mux.HandleFunc("POST /api/push/subscribe", s.handlePushSubscribe)
+26 -1
View File
@@ -82,6 +82,18 @@ type SiegePastView struct {
type siegePage struct { type siegePage struct {
pageData pageData
Siege SiegeView 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. // 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 := s.base(r)
base.Active = "adventure" 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, // 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 // 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 // board. There is nothing here that ties a page to a person more than
// /adventure already does. // /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 // handleSiegeAPI serves the war room as JSON for the page's own re-poll. This
+178
View File
@@ -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();
})();
+38 -4
View File
@@ -58,14 +58,46 @@
</div> </div>
</header> </header>
<!-- How to actually join in. The bout is a Matrix command today; saying so <!-- How to actually join in. A signed-in adventurer can do it from here; the
plainly beats a page that shows a fight nobody can tell how to enter. --> Matrix command stays on the page for everyone else, because it is still
<div class="mt-6 rounded-3xl bg-[color:var(--card)] border-2 border-theme-adventure/30 p-5 shadow-pete"> the only door for a visitor who isn't signed in — and the blow-by-blow of
the fight arrives there whichever door you came through. -->
<div id="adv-actions" class="adv-actions mt-6 rounded-3xl bg-[color:var(--card)] border-2 border-theme-adventure/30 p-5 shadow-pete">
{{if .YouOnBoard}}
{{if .YouFought}}
<p class="text-sm text-[color:var(--ink)]/75">
<span class="font-semibold text-theme-adventure">You've taken your bout today.</span>
Come back tomorrow. One fight each, per day, and everyone in the right-hand column below still has theirs.
</p>
{{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. */}}
<div data-offer>
<p class="text-sm text-[color:var(--ink)]/75 mb-3">
<span class="font-semibold text-theme-adventure">Your bout is unspent.</span>
Damage counts whether you win the fight or not. Turning up is the mechanic, and the blow-by-blow lands in Matrix.
</p>
<button type="button"
class="adv-action-btn rounded-full bg-theme-adventure text-white px-4 py-1.5 text-sm font-semibold hover:opacity-90 transition"
data-action="siege_join"
data-label="Take your bout"
data-confirm-label="Yes, take my bout"
data-confirm="Take your bout against this boss now? It's the only one you get today, and it costs real HP, though you can't die from it. The damage comes off the pool whether you win or lose.">Take your bout</button>
</div>
{{end}}
{{else}}
<p class="text-sm text-[color:var(--ink)]/75"> <p class="text-sm text-[color:var(--ink)]/75">
<span class="font-semibold text-theme-adventure">Taking your bout:</span> <span class="font-semibold text-theme-adventure">Taking your bout:</span>
say <code class="rounded bg-[color:var(--ink)]/8 px-1.5 py-0.5 font-mono text-xs">!adventure siege fight</code> say <code class="rounded bg-[color:var(--ink)]/8 px-1.5 py-0.5 font-mono text-xs">!adventure siege fight</code>
to me in Matrix. One a day, each. Damage counts whether you win the fight or not turning up is the mechanic. to me in Matrix. One a day, each. Damage counts whether you win the fight or not; turning up is the mechanic.
</p> </p>
{{end}}
<div id="adv-action-orders-box" class="mt-4 hidden">
<ul id="adv-action-orders" class="space-y-1.5 text-xs"></ul>
</div>
</div> </div>
<!-- The muster. Two columns, and the right-hand one is the point: a bout not <!-- The muster. Two columns, and the right-hand one is the point: a bout not
@@ -206,3 +238,5 @@
})(); })();
</script> </script>
{{end}} {{end}}
{{define "scripts"}}<script src="/static/js/adventure-actions.js" defer></script>{{end}}
+38
View File
@@ -195,6 +195,42 @@
</a> </a>
</section> </section>
{{if .HasSelf}}
<!-- Your call. The panels above are a spectator view of a run going well or
badly; this is the one thing the watcher can do about it. Owner-only, and
shown whether or not the mark is currently on a run — the board is up to
two minutes stale, so hiding the button on a snapshot that says "in town"
would be the page refusing an action the game would have allowed. gogobee
answers rejected_not_running if it really has ended, and that answer shows
up in the strip below. -->
<section id="adv-actions" class="adv-actions mt-6 rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 p-6 shadow-pete">
<h2 class="font-display text-xl font-bold mb-1">Your call</h2>
<p class="text-sm text-[color:var(--ink)]/60 mb-4">
Asked for here, done on the game box. It picks these up within a few seconds.
</p>
{{/* 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. */}}
<div data-offer>
<button type="button"
class="adv-action-btn rounded-full border border-theme-adventure/40 text-theme-adventure hover:bg-theme-adventure/10 px-4 py-1.5 text-sm font-semibold transition-colors"
data-action="extract"
data-label="Pull out of the run"
data-confirm-label="Yes, pull out"
data-confirm="Pull out of the dungeon now? You keep the loot, XP and coins you're carrying, and the run waits where you left it: you have seven days to go back in. If you're leading a party, it ends the day for all of you.">Pull out of the run</button>
</div>
<!-- The queue is honest: an action lands on the game box's next poll, so a
fresh one reads "asked for", never "done". JS fills this from
/api/adventure/orders. -->
<div id="adv-action-orders-box" class="mt-5 hidden">
<h3 class="font-display text-base font-bold mb-2">What you've asked for</h3>
<ul id="adv-action-orders" class="space-y-1.5 text-xs"></ul>
</div>
</section>
{{end}}
{{if .HasHistory}} {{if .HasHistory}}
<!-- The record. Public, like the dispatches it's counted from — this is the <!-- The record. Public, like the dispatches it's counted from — this is the
same information the /adventure feed already printed, only as numbers same information the /adventure feed already printed, only as numbers
@@ -714,3 +750,5 @@
})(); })();
</script> </script>
{{end}} {{end}}
{{define "scripts"}}<script src="/static/js/adventure-actions.js" defer></script>{{end}}