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
245 lines
9.2 KiB
Go
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()
|
|
}
|