Two things a code review turned up in the W9 seams. The verdict handler answered 400 for everything ResolveAdvOrder could fail with, not just a bad verdict. gogobee's contract says a 400 means "park this row for a human", so a SQLite busy or a disk hiccup permanently stranded an extract or a bout that was perfectly resolvable. Split the two apart with ErrBadAdvVerdict: a verdict outside the terminal set is still 400, because gogobee will never send it successfully, and a genuine storage failure is now 500 and comes back on the next poll. The push URL builders concatenated the guid and the run id raw, while every other builder beside them path-escapes because these values arrive over a wire. A guid carrying a slash sent the notification tap to a different page.
337 lines
14 KiB
Go
337 lines
14 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`.
|
|
//
|
|
// W9 adds the three that undo the ones above. Each was already named inside a
|
|
// refusal or a confirm this page shows — "`!expedition abandon` first",
|
|
// "`!expedition leave` to walk out alone", "no refund if you cancel early" — so
|
|
// until now the web told people to go and type a command it could have offered.
|
|
// None takes an argument and none spends a euro:
|
|
//
|
|
// expedition_abandon end the expedition outright, for the whole party. Leader
|
|
// only, which gogobee enforces. Also the way to close an
|
|
// extracted run without paying to walk back into it first.
|
|
// expedition_leave walk out of somebody else's party alone, supplies left in
|
|
// the pool. Member only — the leader's row IS the expedition.
|
|
// babysit_cancel dismiss the sitter early. No refund, by the game's design.
|
|
const (
|
|
AdvActionExtract = "extract"
|
|
AdvActionSiegeJoin = "siege_join"
|
|
AdvActionExpedition = "expedition_start"
|
|
AdvActionResume = "expedition_resume"
|
|
AdvActionBabysit = "babysit"
|
|
|
|
AdvActionAbandon = "expedition_abandon"
|
|
AdvActionLeave = "expedition_leave"
|
|
AdvActionBabysitCancel = "babysit_cancel"
|
|
)
|
|
|
|
// 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
|
|
|
|
// W9's two. rejected_is_leader is deliberately not rejected_not_leader read
|
|
// backwards: they are opposite facts about the same person, and collapsing
|
|
// them would answer a leader who tried to walk out by telling them they are
|
|
// not the leader.
|
|
AdvRejectedIsLeader = "rejected_is_leader" // expedition_leave: the leader's row is the expedition
|
|
AdvRejectedNothingToCancel = "rejected_nothing_to_cancel" // babysit_cancel: no sitter is engaged
|
|
)
|
|
|
|
func validAdvAction(action string) bool {
|
|
switch action {
|
|
case AdvActionExtract, AdvActionSiegeJoin,
|
|
AdvActionExpedition, AdvActionResume, AdvActionBabysit,
|
|
AdvActionAbandon, AdvActionLeave, AdvActionBabysitCancel:
|
|
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, AdvRejectedIsLeader, AdvRejectedNothingToCancel:
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
var ErrNoSuchAdvOrder = errors.New("orders: no such order")
|
|
|
|
// ErrBadAdvVerdict is a verdict outside the terminal set. It is kept distinct
|
|
// from a storage failure so the web seam can answer 400 (gogobee sent something
|
|
// it will never be able to send successfully) rather than parking a perfectly
|
|
// resolvable order on a transient database error.
|
|
var ErrBadAdvVerdict = errors.New("orders: bad verdict")
|
|
|
|
// 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("%w %q", ErrBadAdvVerdict, 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, ¶ms,
|
|
&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()
|
|
}
|