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:
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user