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_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: