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