The one adventure ask that carries intent back to the game box, built the mischief way: no new network route, Pete records the equip/unequip and gogobee polls it and files a verdict. The who page grows Equip / Take off buttons on the owner's own worn and backpack panels, and a pending-changes strip that shows 'queued' until the verdict lands, never claiming a change it can't see. The item handle is the inventory row id, sent only on wearable magic items, so a non-zero id is also what gates the button. gogobee resolves the owner by localpart, never a name.
217 lines
8.6 KiB
Go
217 lines
8.6 KiB
Go
package storage
|
|
|
|
import (
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
)
|
|
|
|
// The equip queue: the one adventure feature that carries intent *back* to the
|
|
// game box, and it does it the same way mischief does — no new network route.
|
|
//
|
|
// A signed-in owner, on their own detail page, asks to wear an item they own or
|
|
// take one off. Pete records only the *intent*; it never touches the game's
|
|
// equipment tables. gogobee's poll loop drains the pending orders, runs the real
|
|
// equip through its own rules (slot eviction, the 3-bond attunement cap,
|
|
// reconcile), and hands back a verdict Pete files against the order. The guid is
|
|
// the idempotency key end to end.
|
|
//
|
|
// Unlike mischief the underlying game action is NOT naturally idempotent —
|
|
// equipping consumes an inventory row and regenerates it on unequip, so replaying
|
|
// the flow would double-move items. gogobee therefore short-circuits on the order
|
|
// guid before it mutates anything (see its poller). On Pete's side the mechanic
|
|
// is mischief's exactly: a verdict only moves a still-pending order, so a retried
|
|
// verdict is a no-op.
|
|
|
|
// EquipOrder is one wear/remove request and its current standing.
|
|
type EquipOrder 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 — the character to dress
|
|
CharacterName string `json:"character_name,omitempty"` // display copy, frozen at order time; gogobee ignores it
|
|
ItemID int64 `json:"item_id,omitempty"` // adventure_inventory row id, for an equip; unused for unequip
|
|
ItemName string `json:"item_name"` // display copy
|
|
Slot string `json:"slot"` // the magic-item slot to fill or clear
|
|
Action string `json:"action"` // equip / unequip
|
|
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.
|
|
const (
|
|
EquipActionEquip = "equip"
|
|
EquipActionUnequip = "unequip"
|
|
)
|
|
|
|
// Order states. Terminal states are enumerated, not free-text, so the page can
|
|
// say something specific; detail carries the prose. The rejection set is honest
|
|
// to what gogobee's equip path can actually return: it auto-evicts a slot's
|
|
// current occupant (so there is no "slot taken") and equips over the bond cap as
|
|
// inert rather than refusing (so there is no "requirements" bounce). What is left
|
|
// is the item having moved out from under the order, or not being wearable.
|
|
const (
|
|
EquipPending = "pending" // placed; gogobee hasn't acted yet
|
|
EquipApplied = "applied" // worn/removed; detail says how (bonded, inert, ...)
|
|
EquipRejectedNotOwned = "rejected_not_owned" // the item is no longer in the pack (stale page)
|
|
EquipRejectedNotWorn = "rejected_not_worn" // unequip of a slot that's already empty
|
|
EquipRejectedNotEquipp = "rejected_not_equippable" // the item has no slot to fill
|
|
)
|
|
|
|
// validEquipVerdict is the set of terminal states gogobee may hand back.
|
|
func validEquipVerdict(status string) bool {
|
|
switch status {
|
|
case EquipApplied, EquipRejectedNotOwned, EquipRejectedNotWorn, EquipRejectedNotEquipp:
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func validEquipAction(action string) bool {
|
|
return action == EquipActionEquip || action == EquipActionUnequip
|
|
}
|
|
|
|
var ErrNoSuchEquipOrder = errors.New("equip: no such order")
|
|
|
|
// InsertEquipOrder records a fresh, pending order and returns it with a new guid.
|
|
// The guid is minted here so the owner sees a stable reference the instant they
|
|
// click, before gogobee has heard of it. The caller has already checked the owner
|
|
// is signed in and owns the page; eligibility (still-owned, wearable, bond cap) is
|
|
// gogobee's, at verdict time.
|
|
func InsertEquipOrder(ownerSub, ownerLocalpart, characterName string, itemID int64, itemName, slot, action string) (EquipOrder, error) {
|
|
if !validEquipAction(action) {
|
|
return EquipOrder{}, fmt.Errorf("equip: bad action %q", action)
|
|
}
|
|
guid, err := newGUID()
|
|
if err != nil {
|
|
return EquipOrder{}, err
|
|
}
|
|
now := nowUnix()
|
|
if _, err := Get().Exec(
|
|
`INSERT INTO equip_orders
|
|
(guid, owner_sub, owner_localpart, character_name, item_id, item_name, slot, action, status, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
guid, ownerSub, ownerLocalpart, characterName, itemID, itemName, slot, action, EquipPending, now, now,
|
|
); err != nil {
|
|
return EquipOrder{}, fmt.Errorf("equip: insert order: %w", err)
|
|
}
|
|
return EquipOrder{
|
|
GUID: guid, OwnerSub: ownerSub, OwnerLocalpart: ownerLocalpart,
|
|
CharacterName: characterName, ItemID: itemID, ItemName: itemName,
|
|
Slot: slot, Action: action, Status: EquipPending, CreatedAt: now, UpdatedAt: now,
|
|
}, nil
|
|
}
|
|
|
|
// PendingEquipOrders is gogobee's poll: every order still waiting. Like mischief
|
|
// there is no claimed-but-stale window — a gogobee that dies mid-apply leaves the
|
|
// order pending to be offered again, and gogobee's own guid guard makes the replay
|
|
// a no-op.
|
|
func PendingEquipOrders(limit int) ([]EquipOrder, error) {
|
|
if limit <= 0 {
|
|
limit = 100
|
|
}
|
|
rows, err := Get().Query(
|
|
`SELECT guid, owner_sub, owner_localpart, character_name, item_id, item_name, slot, action, status, COALESCE(detail, ''), created_at, updated_at
|
|
FROM equip_orders
|
|
WHERE status = ?
|
|
ORDER BY created_at
|
|
LIMIT ?`,
|
|
EquipPending, limit,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("equip: pending orders: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
return scanEquipOrders(rows)
|
|
}
|
|
|
|
// ResolveEquipOrder files gogobee's verdict against a pending order. Idempotent by
|
|
// exactly mischief'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 ResolveEquipOrder(guid, status, detail string) (EquipOrder, error) {
|
|
if !validEquipVerdict(status) {
|
|
return EquipOrder{}, fmt.Errorf("equip: bad verdict %q", status)
|
|
}
|
|
now := nowUnix()
|
|
if _, err := Get().Exec(
|
|
`UPDATE equip_orders SET status = ?, detail = ?, updated_at = ?
|
|
WHERE guid = ? AND status = ?`,
|
|
status, detail, now, guid, EquipPending,
|
|
); err != nil {
|
|
return EquipOrder{}, fmt.Errorf("equip: resolve order: %w", err)
|
|
}
|
|
return EquipOrderByGUID(guid)
|
|
}
|
|
|
|
// EquipOrderByGUID reads one order.
|
|
func EquipOrderByGUID(guid string) (EquipOrder, error) {
|
|
rows, err := Get().Query(
|
|
`SELECT guid, owner_sub, owner_localpart, character_name, item_id, item_name, slot, action, status, COALESCE(detail, ''), created_at, updated_at
|
|
FROM equip_orders WHERE guid = ?`, guid,
|
|
)
|
|
if err != nil {
|
|
return EquipOrder{}, fmt.Errorf("equip: read order: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
out, err := scanEquipOrders(rows)
|
|
if err != nil {
|
|
return EquipOrder{}, err
|
|
}
|
|
if len(out) == 0 {
|
|
return EquipOrder{}, ErrNoSuchEquipOrder
|
|
}
|
|
return out[0], nil
|
|
}
|
|
|
|
// EquipOrdersByOwner returns an owner's own recent orders, newest first, for the
|
|
// status strip on the detail page. Keyed on the OIDC subject so a username change
|
|
// doesn't strand history.
|
|
func EquipOrdersByOwner(ownerSub string, limit int) ([]EquipOrder, error) {
|
|
if limit <= 0 {
|
|
limit = 20
|
|
}
|
|
rows, err := Get().Query(
|
|
`SELECT guid, owner_sub, owner_localpart, character_name, item_id, item_name, slot, action, status, COALESCE(detail, ''), created_at, updated_at
|
|
FROM equip_orders
|
|
WHERE owner_sub = ?
|
|
ORDER BY created_at DESC
|
|
LIMIT ?`,
|
|
ownerSub, limit,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("equip: orders by owner: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
return scanEquipOrders(rows)
|
|
}
|
|
|
|
// CountEquipOrdersSince backs the web anti-spam guard — the real eligibility check
|
|
// is gogobee's at verdict time; this only blunts a stuck mouse button.
|
|
func CountEquipOrdersSince(ownerSub string, since int64) (int, error) {
|
|
var n int
|
|
err := Get().QueryRow(
|
|
`SELECT COUNT(*) FROM equip_orders WHERE owner_sub = ? AND created_at >= ?`,
|
|
ownerSub, since,
|
|
).Scan(&n)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("equip: count recent orders: %w", err)
|
|
}
|
|
return n, nil
|
|
}
|
|
|
|
func scanEquipOrders(rows *sql.Rows) ([]EquipOrder, error) {
|
|
var out []EquipOrder
|
|
for rows.Next() {
|
|
var o EquipOrder
|
|
if err := rows.Scan(&o.GUID, &o.OwnerSub, &o.OwnerLocalpart, &o.CharacterName,
|
|
&o.ItemID, &o.ItemName, &o.Slot, &o.Action, &o.Status, &o.Detail,
|
|
&o.CreatedAt, &o.UpdatedAt); err != nil {
|
|
return nil, fmt.Errorf("equip: scan order: %w", err)
|
|
}
|
|
out = append(out, o)
|
|
}
|
|
return out, rows.Err()
|
|
}
|