Files
Pete/internal/storage/orders.go
T
prosolis 868a29e992 adventure: let a player leave town from the web, not only read about it
W5a gave the web two verbs that cost nothing. These are the three that take
arguments and spend coins: set out for a zone with a supply loadout, walk back
into the run you extracted from, hire the pet sitter for a week or a month.
Between them they cover the most common thing anybody does in the game, which
until now could only be typed into Matrix.

Arguments are the new surface, so they are the thing to be careful with. Nothing
in a request is trusted: every zone, loadout and duration is looked up in the
offer list gogobee pushed onto that owner's own private row, and the order stores
what was found there rather than what was sent. A forged zone resolves to nothing
and never becomes an order. gogobee then re-resolves all of it anyway, because an
offer is a quote and a quote is not a permission.

The money confirm is the equip panel's, lifted: cost, balance, and the balance it
leaves. When it does not cover, it says so instead of printing a negative.

Verified in a browser rather than only in tests, which is where both real defects
came from: the confirm box was appending to the whole panel and so appeared at
the bottom of the section instead of under the button that raised it, and button
prices printed as EUR45000 above a dialog reading EUR45,000.

Claude-Session: https://claude.ai/code/session_012bxpQQJDjC1mTtLN3VVtBQ
2026-07-24 19:47:26 -07:00

306 lines
12 KiB
Go

package storage
import (
"database/sql"
"encoding/json"
"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"`
Status string `json:"status"`
Detail string `json:"detail,omitempty"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at,omitempty"`
// Params is the verb's arguments, and only the three verbs that take any carry
// it. It never names an adventurer — that still comes from the session — and
// nothing in it is trusted: Pete resolves every field against the owner's own
// pushed offer list before storing it, and gogobee resolves it again against
// the game's tables before it means anything.
Params *AdvOrderParams `json:"params,omitempty"`
}
// AdvOrderParams is the union of every verb's arguments, flat rather than
// per-verb because there are three of them and each reads one or two fields.
// A field a verb does not read is ignored rather than rejected.
type AdvOrderParams struct {
Zone string `json:"zone,omitempty"` // zone id, for expedition_start
Loadout string `json:"loadout,omitempty"` // lean|balanced|heavy
Days int `json:"days,omitempty"` // 7 or 30, for babysit
}
// 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.
// W5b adds the three that take arguments and spend coins:
//
// expedition_start leave town for a zone with a supply loadout — `!expedition
// start <zone> <loadout>`. The most common action in the game
// and, until now, Matrix-only.
// expedition_resume walk back into the run you extracted from, re-outfitted —
// `!resume`. The other half of W5a's extract: that verb's own
// verdict tells people to type !resume, and this is the door.
// babysit engage the pet sitter for a week or a month — `!adventure
// babysit week|month`.
const (
AdvActionExtract = "extract"
AdvActionSiegeJoin = "siege_join"
AdvActionExpedition = "expedition_start"
AdvActionResume = "expedition_resume"
AdvActionBabysit = "babysit"
)
// 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 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, dead, or an argument the game does not sell
// W5b's three verbs.
AdvRejectedBusy = "rejected_busy" // already out, already seated, or already has a sitter
AdvRejectedInsufficientFunds = "rejected_insufficient_funds" // could not cover the cost
AdvRejectedZoneLocked = "rejected_zone_locked" // that zone is not open at this level
AdvRejectedNothingToResume = "rejected_nothing_to_resume" // nothing extracted, or its window closed
)
func validAdvAction(action string) bool {
switch action {
case AdvActionExtract, AdvActionSiegeJoin,
AdvActionExpedition, AdvActionResume, AdvActionBabysit:
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,
AdvRejectedBusy, AdvRejectedInsufficientFunds, AdvRejectedZoneLocked,
AdvRejectedNothingToResume:
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, params *AdvOrderParams) (AdvOrder, error) {
if !validAdvAction(action) {
return AdvOrder{}, fmt.Errorf("orders: bad action %q", action)
}
// Store the canonical re-serialised form, never the client's bytes: the caller
// has already resolved every field against the owner's own offer list, so what
// goes in the row is Pete's understanding of the request rather than the
// request itself.
paramsJSON := ""
if params != nil {
b, err := json.Marshal(params)
if err != nil {
return AdvOrder{}, fmt.Errorf("orders: marshal params: %w", err)
}
paramsJSON = string(b)
}
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, params, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
guid, ownerSub, ownerLocalpart, token, characterName, action, AdvOrderPending, paramsJSON, 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, Params: params, 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, ''), COALESCE(params, ''), 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, ''), COALESCE(params, ''), 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, ''), COALESCE(params, ''), 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
var params string
if err := rows.Scan(&o.GUID, &o.OwnerSub, &o.OwnerLocalpart, &o.Token,
&o.CharacterName, &o.Action, &o.Status, &o.Detail, &params,
&o.CreatedAt, &o.UpdatedAt); err != nil {
return nil, fmt.Errorf("orders: scan order: %w", err)
}
// Unparseable params are dropped rather than failing the read. The row is
// still a real order somebody placed, and a verb whose arguments went
// missing is refused honestly by gogobee ("that order didn't say where
// to") — which beats the whole poll erroring on one bad row.
if params != "" {
var pp AdvOrderParams
if err := json.Unmarshal([]byte(params), &pp); err == nil {
o.Params = &pp
}
}
out = append(out, o)
}
return out, rows.Err()
}