Files
Pete/internal/storage/orders.go
T
prosolis 6b0aae9f4a 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
2026-07-24 18:55:42 -07:00

245 lines
9.2 KiB
Go

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()
}