adventure: let an owner equip and unequip from their own page

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.
This commit is contained in:
prosolis
2026-07-17 08:44:28 -07:00
parent e90deda498
commit 1589c36e96
10 changed files with 999 additions and 7 deletions

View File

@@ -32,6 +32,11 @@ type PlayerDetail struct {
// items can be Attuned — equipping moves the row out of gogobee's inventory
// table entirely, so a backpack item's bond state isn't false, it's undefined.
type ItemView struct {
// ID is the adventure_inventory row id, sent only for a backpack item that can
// be worn through the magic-item path — so a non-zero ID doubles as "this item
// has an Equip button." Worn items carry none: unequip keys on Slot. The id is
// the handle an equip order round-trips back to gogobee to name the item.
ID int64 `json:"id,omitempty"`
Name string `json:"name"`
Type string `json:"type"`
Tier int `json:"tier"`

216
internal/storage/equip.go Normal file
View File

@@ -0,0 +1,216 @@
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()
}

View File

@@ -0,0 +1,135 @@
package storage
import (
"errors"
"testing"
"time"
)
func TestEquipOrderLifecycle(t *testing.T) {
setupTestDB(t)
o, err := InsertEquipOrder("sub-1", "josie", "Josie", 42, "Cloak of Elvenkind", "cloak", EquipActionEquip)
if err != nil {
t.Fatal(err)
}
if o.Status != EquipPending {
t.Fatalf("fresh order status = %q, want pending", o.Status)
}
if o.ItemID != 42 || o.Slot != "cloak" || o.Action != EquipActionEquip {
t.Fatalf("order fields lost through insert: %+v", o)
}
pending, err := PendingEquipOrders(10)
if err != nil {
t.Fatal(err)
}
if len(pending) != 1 || pending[0].GUID != o.GUID {
t.Fatalf("pending = %+v, want the one order we just placed", pending)
}
got, err := ResolveEquipOrder(o.GUID, EquipApplied, "worn and bonded")
if err != nil {
t.Fatal(err)
}
if got.Status != EquipApplied || got.Detail != "worn and bonded" {
t.Fatalf("resolved order = %+v, want applied with detail", got)
}
if pending, _ := PendingEquipOrders(10); len(pending) != 0 {
t.Fatalf("applied order still pending: %+v", pending)
}
}
// TestEquipResolveIsIdempotent: gogobee's poll loop retries, so a verdict can
// arrive twice — the second must not overwrite the first. This is the whole
// reason Pete can copy mischief's mechanic even though the game action underneath
// is not itself idempotent (gogobee guards that separately, on the order guid).
func TestEquipResolveIsIdempotent(t *testing.T) {
setupTestDB(t)
o, err := InsertEquipOrder("sub-1", "josie", "Josie", 7, "Ring of Protection", "ring_1", EquipActionEquip)
if err != nil {
t.Fatal(err)
}
if _, err := ResolveEquipOrder(o.GUID, EquipApplied, "first"); err != nil {
t.Fatal(err)
}
got, err := ResolveEquipOrder(o.GUID, EquipRejectedNotOwned, "second")
if err != nil {
t.Fatalf("re-resolve errored: %v", err)
}
if got.Status != EquipApplied || got.Detail != "first" {
t.Fatalf("idempotency broken: order became %+v", got)
}
}
func TestEquipResolveUnknownAndBadVerdict(t *testing.T) {
setupTestDB(t)
if _, err := ResolveEquipOrder("nope", EquipApplied, ""); !errors.Is(err, ErrNoSuchEquipOrder) {
t.Fatalf("unknown guid err = %v, want ErrNoSuchEquipOrder", err)
}
o, _ := InsertEquipOrder("sub-1", "josie", "Josie", 1, "Boots", "feet", EquipActionEquip)
if _, err := ResolveEquipOrder(o.GUID, "exploded", ""); err == nil {
t.Error("a bogus verdict status was accepted")
}
if got, _ := EquipOrderByGUID(o.GUID); got.Status != EquipPending {
t.Fatalf("order moved off pending on a bad verdict: %q", got.Status)
}
}
// TestEquipInsertRejectsBadAction: the action is part of the contract, so a value
// that isn't equip/unequip must not reach the table.
func TestEquipInsertRejectsBadAction(t *testing.T) {
setupTestDB(t)
if _, err := InsertEquipOrder("sub-1", "josie", "Josie", 1, "Thing", "cloak", "wield"); err == nil {
t.Fatal("a bogus action was accepted")
}
}
// TestEquipUnequipCarriesSlotNotItem: an unequip has no live inventory row to name,
// so it rides on the slot alone — item_id 0 is expected, not a bug.
func TestEquipUnequipCarriesSlotNotItem(t *testing.T) {
setupTestDB(t)
o, err := InsertEquipOrder("sub-1", "josie", "Josie", 0, "Cloak of Elvenkind", "cloak", EquipActionUnequip)
if err != nil {
t.Fatal(err)
}
got, _ := EquipOrderByGUID(o.GUID)
if got.Action != EquipActionUnequip || got.Slot != "cloak" || got.ItemID != 0 {
t.Fatalf("unequip order = %+v, want slot-keyed with no item id", got)
}
}
func TestEquipOrdersByOwnerAndCount(t *testing.T) {
setupTestDB(t)
for i := 0; i < 3; i++ {
if _, err := InsertEquipOrder("sub-A", "alice", "Alice", int64(i+1), "Item", "cloak", EquipActionEquip); err != nil {
t.Fatal(err)
}
}
if _, err := InsertEquipOrder("sub-B", "bob", "Bob", 9, "Item", "cloak", EquipActionEquip); err != nil {
t.Fatal(err)
}
mine, err := EquipOrdersByOwner("sub-A", 20)
if err != nil {
t.Fatal(err)
}
if len(mine) != 3 {
t.Fatalf("alice sees %d orders, want 3 (and none of bob's)", len(mine))
}
n, err := CountEquipOrdersSince("sub-A", time.Now().Add(-time.Hour).Unix())
if err != nil {
t.Fatal(err)
}
if n != 3 {
t.Fatalf("count since an hour ago = %d, want 3", n)
}
if n, _ := CountEquipOrdersSince("sub-A", time.Now().Add(time.Hour).Unix()); n != 0 {
t.Fatalf("count since the future = %d, want 0", n)
}
}

View File

@@ -151,6 +151,43 @@ CREATE TABLE IF NOT EXISTS mischief_tiers (
ordinal INTEGER NOT NULL DEFAULT 0
);
-- An equip/unequip an owner asked for from their own detail page, on its way to
-- gogobee. This is the one adventure feature that carries intent *back* to the
-- game box; it does it the mischief way, with no new network route — Pete records
-- the intent, gogobee polls and applies it against its own equipment tables, and
-- pushes back a verdict. The status ladder:
--
-- pending -> applied (worn or removed; detail says how)
-- -> rejected_not_owned (the item left the pack before gogobee got here)
-- -> rejected_not_worn (unequip of an already-empty slot)
-- -> rejected_not_equippable (the item has no slot to fill)
--
-- guid is the idempotency key end to end. Note the game action is NOT naturally
-- idempotent (equipping consumes an inventory row), so gogobee short-circuits on
-- this guid before it mutates — unlike mischief, whose action converges on its
-- own. owner_sub is the OIDC subject (stable across renames) and keys "my orders";
-- owner_localpart is the Matrix localpart gogobee turns into the MXID of the
-- character to dress. item_id is the adventure_inventory row id for an equip (the
-- table is AUTOINCREMENT, so a stale id misses cleanly rather than hitting the
-- wrong item); an unequip keys on slot alone. character_name and item_name are
-- frozen display copy gogobee ignores.
CREATE TABLE IF NOT EXISTS equip_orders (
guid TEXT PRIMARY KEY,
owner_sub TEXT NOT NULL,
owner_localpart TEXT NOT NULL,
character_name TEXT NOT NULL DEFAULT '',
item_id INTEGER NOT NULL DEFAULT 0,
item_name TEXT NOT NULL DEFAULT '',
slot TEXT NOT NULL DEFAULT '',
action TEXT NOT NULL, -- equip / unequip
status TEXT NOT NULL, -- see the ladder above
detail TEXT, -- gogobee's human note on the verdict
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_equip_orders_pending ON equip_orders(status, created_at);
CREATE INDEX IF NOT EXISTS idx_equip_orders_owner ON equip_orders(owner_sub, created_at DESC);
-- A player's private, owner-only expansion — inventory, vault, house, pets —
-- pushed whole by gogobee on the roster tick. Keyed by localpart (== session
-- Username), a *separate keyspace* from the anonymous roster tokens on purpose: