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:
@@ -32,6 +32,11 @@ type PlayerDetail struct {
|
|||||||
// items can be Attuned — equipping moves the row out of gogobee's inventory
|
// 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.
|
// table entirely, so a backpack item's bond state isn't false, it's undefined.
|
||||||
type ItemView struct {
|
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"`
|
Name string `json:"name"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Tier int `json:"tier"`
|
Tier int `json:"tier"`
|
||||||
|
|||||||
216
internal/storage/equip.go
Normal file
216
internal/storage/equip.go
Normal 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()
|
||||||
|
}
|
||||||
135
internal/storage/equip_test.go
Normal file
135
internal/storage/equip_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -151,6 +151,43 @@ CREATE TABLE IF NOT EXISTS mischief_tiers (
|
|||||||
ordinal INTEGER NOT NULL DEFAULT 0
|
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 —
|
-- A player's private, owner-only expansion — inventory, vault, house, pets —
|
||||||
-- pushed whole by gogobee on the roster tick. Keyed by localpart (== session
|
-- pushed whole by gogobee on the roster tick. Keyed by localpart (== session
|
||||||
-- Username), a *separate keyspace* from the anonymous roster tokens on purpose:
|
-- Username), a *separate keyspace* from the anonymous roster tokens on purpose:
|
||||||
|
|||||||
256
internal/web/equip.go
Normal file
256
internal/web/equip.go
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"pete/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The equip queue's web seam.
|
||||||
|
//
|
||||||
|
// Two audiences, same shape as mischief. A signed-in owner, on their own detail
|
||||||
|
// page, clicks Equip or Unequip; the OIDC-gated buy half records the intent after
|
||||||
|
// proving they own the page and the item. gogobee hits the bearer-authed pair: it
|
||||||
|
// polls pending orders and pushes a verdict. Pete never runs an equip rule — it
|
||||||
|
// records intent and files the verdict; the item actually moves on the game box,
|
||||||
|
// on gogobee's next poll tick. The UI says "queued" and never claims it landed.
|
||||||
|
|
||||||
|
// equipBurstWindow / equipBurstMax are Pete's own anti-spam guard, nothing more.
|
||||||
|
// The real eligibility — still-owned, wearable, the 3-bond cap — is gogobee's, at
|
||||||
|
// verdict time. This only stops a stuck mouse button from spooling the table.
|
||||||
|
const (
|
||||||
|
equipBurstWindow = time.Hour
|
||||||
|
equipBurstMax = 40
|
||||||
|
)
|
||||||
|
|
||||||
|
// equipOrderReq is the browser's request. The owner names the page they're on
|
||||||
|
// (proving ownership), what they're doing, and the item — by its inventory row id
|
||||||
|
// for an equip, or by slot for an unequip. Pete resolves the display facts itself
|
||||||
|
// from the owner's own detail, never trusting the client for name or slot.
|
||||||
|
type equipOrderReq struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
Action string `json:"action"`
|
||||||
|
ItemID int64 `json:"item_id"`
|
||||||
|
Slot string `json:"slot"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleEquipOrder places a pending equip/unequip for the signed-in owner. It
|
||||||
|
// asserts what Pete can honestly know: the viewer is signed in, owns this exact
|
||||||
|
// page (proven by a row gogobee pushed, never by the token alone), and the item
|
||||||
|
// is actually in the panel they claim. Bond caps and the rest of the rulebook are
|
||||||
|
// gogobee's, checked when it drains the order.
|
||||||
|
func (s *Server) handleEquipOrder(w http.ResponseWriter, r *http.Request) {
|
||||||
|
u := s.requireUser(w, r)
|
||||||
|
if u == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
owner := buyerLocalpart(u)
|
||||||
|
if owner == "" {
|
||||||
|
writeEquipError(w, http.StatusConflict, "please sign in again")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req equipOrderReq
|
||||||
|
if !decodeStateBody(w, r, &req) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Token == "" {
|
||||||
|
writeEquipError(w, http.StatusBadRequest, "no character")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Action != storage.EquipActionEquip && req.Action != storage.EquipActionUnequip {
|
||||||
|
writeEquipError(w, http.StatusBadRequest, "bad action")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ownership: only the localpart that owns this exact page token may dress it.
|
||||||
|
// The detail row is gogobee's own proof of owner<->page; a token alone proves
|
||||||
|
// nothing.
|
||||||
|
pd, ok, err := storage.PlayerDetailByOwner(owner, req.Token)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("equip: owner lookup", "err", err)
|
||||||
|
writeEquipError(w, http.StatusInternalServerError, "internal error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
writeEquipError(w, http.StatusForbidden, "that's not your adventurer")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the item from the owner's own panels — never from the client — so a
|
||||||
|
// forged name or slot can't ride into the order. An equip names a backpack item
|
||||||
|
// by its row id (present only on wearable magic items); an unequip names a worn
|
||||||
|
// slot.
|
||||||
|
var itemName, slot string
|
||||||
|
if req.Action == storage.EquipActionEquip {
|
||||||
|
it, found := findBackpackItem(pd.Inventory, req.ItemID)
|
||||||
|
if !found {
|
||||||
|
writeEquipError(w, http.StatusBadRequest, "that item isn't in your pack")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
itemName, slot = it.Name, it.Slot
|
||||||
|
} else {
|
||||||
|
it, found := findWornSlot(pd.Equipped, req.Slot)
|
||||||
|
if !found {
|
||||||
|
writeEquipError(w, http.StatusBadRequest, "nothing's in that slot")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
itemName, slot = it.Name, it.Slot
|
||||||
|
}
|
||||||
|
|
||||||
|
since := time.Now().Add(-equipBurstWindow).Unix()
|
||||||
|
if n, err := storage.CountEquipOrdersSince(u.Sub, since); err != nil {
|
||||||
|
slog.Error("equip: burst count", "err", err)
|
||||||
|
writeEquipError(w, http.StatusInternalServerError, "internal error")
|
||||||
|
return
|
||||||
|
} else if n >= equipBurstMax {
|
||||||
|
writeEquipError(w, http.StatusTooManyRequests, "slow down — too many changes in a short while")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
characterName := ""
|
||||||
|
if entry, ok, err := storage.RosterEntryByToken(req.Token); err == nil && ok {
|
||||||
|
characterName = entry.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
itemID := req.ItemID
|
||||||
|
if req.Action == storage.EquipActionUnequip {
|
||||||
|
itemID = 0 // a worn item's inventory row is gone; the slot is the handle
|
||||||
|
}
|
||||||
|
order, err := storage.InsertEquipOrder(u.Sub, owner, characterName, itemID, itemName, slot, req.Action)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("equip: insert order", "err", err)
|
||||||
|
writeEquipError(w, http.StatusInternalServerError, "internal error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slog.Info("equip: order placed", "guid", order.GUID, "owner", owner, "action", req.Action, "slot", slot)
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
writeJSON(w, order)
|
||||||
|
}
|
||||||
|
|
||||||
|
// findBackpackItem finds a wearable backpack item by its row id. Only magic items
|
||||||
|
// carry a non-zero ID, so a zero id can never match — the id both names the item
|
||||||
|
// and gates the action to the magic-item equip path.
|
||||||
|
func findBackpackItem(items []storage.ItemView, id int64) (storage.ItemView, bool) {
|
||||||
|
if id == 0 {
|
||||||
|
return storage.ItemView{}, false
|
||||||
|
}
|
||||||
|
for _, it := range items {
|
||||||
|
if it.ID == id {
|
||||||
|
return it, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return storage.ItemView{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// findWornSlot finds a worn item by the slot it fills.
|
||||||
|
func findWornSlot(items []storage.ItemView, slot string) (storage.ItemView, bool) {
|
||||||
|
if slot == "" {
|
||||||
|
return storage.ItemView{}, false
|
||||||
|
}
|
||||||
|
for _, it := range items {
|
||||||
|
if it.Slot == slot {
|
||||||
|
return it, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return storage.ItemView{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleEquipOrders returns the signed-in owner's own recent equip orders for the
|
||||||
|
// status strip, newest first. Scoped to their OIDC subject.
|
||||||
|
func (s *Server) handleEquipOrders(w http.ResponseWriter, r *http.Request) {
|
||||||
|
u := s.requireUser(w, r)
|
||||||
|
if u == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
orders, err := storage.EquipOrdersByOwner(u.Sub, 20)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("equip: orders by owner", "err", err)
|
||||||
|
writeEquipError(w, http.StatusInternalServerError, "internal error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if orders == nil {
|
||||||
|
orders = []storage.EquipOrder{}
|
||||||
|
}
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
writeJSON(w, orders)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the gogobee wire: bearer-authed, idempotent -------------------------------
|
||||||
|
|
||||||
|
// equipPollLimit caps one poll, matching the mischief seam.
|
||||||
|
const equipPollLimit = 50
|
||||||
|
|
||||||
|
// handleEquipPending is gogobee's poll: every order still waiting. Like mischief
|
||||||
|
// there is no stale-reoffer window — a gogobee that dies mid-apply leaves the order
|
||||||
|
// pending to be offered again, and gogobee's guid guard makes the replay a no-op.
|
||||||
|
func (s *Server) handleEquipPending(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !s.bearerOK(r) {
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
orders, err := storage.PendingEquipOrders(equipPollLimit)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("equip: pending", "err", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if orders == nil {
|
||||||
|
orders = []storage.EquipOrder{}
|
||||||
|
}
|
||||||
|
writeJSON(w, orders)
|
||||||
|
}
|
||||||
|
|
||||||
|
// equipVerdict is gogobee's answer on an order: the terminal status and a human
|
||||||
|
// note to render.
|
||||||
|
type equipVerdict struct {
|
||||||
|
GUID string `json:"guid"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Detail string `json:"detail,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleEquipVerdict files gogobee's verdict against a pending order. Idempotent:
|
||||||
|
// gogobee's poll loop retries, so the same verdict can arrive more than once and
|
||||||
|
// only the first moves the order. An unknown guid is a 400 — under the seam's
|
||||||
|
// contract that parks the row for a human rather than retrying forever against a
|
||||||
|
// row that will never exist.
|
||||||
|
func (s *Server) handleEquipVerdict(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !s.bearerOK(r) {
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var v equipVerdict
|
||||||
|
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<14)).Decode(&v); err != nil {
|
||||||
|
http.Error(w, "bad json", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if v.GUID == "" {
|
||||||
|
http.Error(w, "guid is required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
order, err := storage.ResolveEquipOrder(v.GUID, v.Status, v.Detail)
|
||||||
|
if errors.Is(err, storage.ErrNoSuchEquipOrder) {
|
||||||
|
slog.Error("equip: verdict for an order we've never heard of", "guid", v.GUID, "status", v.Status)
|
||||||
|
http.Error(w, "no such order", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("equip: resolve", "guid", v.GUID, "status", v.Status, "err", err)
|
||||||
|
http.Error(w, "bad verdict", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slog.Info("equip: order resolved", "guid", order.GUID, "status", order.Status)
|
||||||
|
writeJSON(w, order)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeEquipError(w http.ResponseWriter, code int, msg string) {
|
||||||
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
|
w.WriteHeader(code)
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]string{"error": msg})
|
||||||
|
}
|
||||||
199
internal/web/equip_test.go
Normal file
199
internal/web/equip_test.go
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"pete/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The equip queue's web seam. Two contracts: the owner half proves ownership and
|
||||||
|
// resolves the item from Pete's own record (never the client), and the gogobee
|
||||||
|
// half is a bearer-authed, idempotent pending/verdict pair.
|
||||||
|
|
||||||
|
// seedEquip stands up a board + a private detail set owned by `owner`, with a
|
||||||
|
// wearable backpack magic item (ID != 0, the equip handle) and a worn item in a
|
||||||
|
// slot. Mirrors seedWho but pins the fields the equip path keys on.
|
||||||
|
func seedEquip(t *testing.T, owner string) *Server {
|
||||||
|
t.Helper()
|
||||||
|
s, _ := newAdvServer(t, "tok")
|
||||||
|
s.auth = &Authenticator{secret: []byte("test-secret-key-at-least-16")}
|
||||||
|
now := time.Now().Unix()
|
||||||
|
|
||||||
|
e := entry("tok-josie", "Josie", "expedition", "holymachina")
|
||||||
|
if w := postRoster(t, s, "tok", rosterPush{SnapshotAt: now, Adventurers: []storage.RosterEntry{e}}); w.Code != 200 {
|
||||||
|
t.Fatalf("seed roster = %d", w.Code)
|
||||||
|
}
|
||||||
|
if w := postDetail(t, s, "tok", detailPush{SnapshotAt: now, Players: []storage.PlayerDetail{{
|
||||||
|
Localpart: owner,
|
||||||
|
Token: "tok-josie",
|
||||||
|
Inventory: []storage.ItemView{
|
||||||
|
// A wearable magic item: carries an ID, so it can be equipped.
|
||||||
|
{ID: 501, Name: "Ring of Protection", Type: "ring", Tier: 4, Value: 900,
|
||||||
|
Slot: "ring_1", Attunement: true, Effect: "-8% damage taken"},
|
||||||
|
// Mundane gear: no ID, so no equip handle even though it has a slot.
|
||||||
|
{Name: "Miner's Pick", Type: "MasterworkGear", Tier: 3, Value: 300,
|
||||||
|
Slot: "weapon", SkillSource: "mining"},
|
||||||
|
},
|
||||||
|
Equipped: []storage.ItemView{
|
||||||
|
{Name: "Cloak of Elvenkind", Type: "wondrous", Value: 2000, Slot: "cloak",
|
||||||
|
Effect: "faster to act", Attunement: true, Attuned: true},
|
||||||
|
},
|
||||||
|
}}}); w.Code != 200 {
|
||||||
|
t.Fatalf("seed detail = %d", w.Code)
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func placeEquip(t *testing.T, s *Server, username string, req equipOrderReq) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
r := as(t, s, username, "POST", "/api/equip/order", req)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
s.handleEquipOrder(w, r)
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEquipOrderHappyEquip: the owner equips a backpack magic item; Pete resolves
|
||||||
|
// the item name and slot from its own record and queues a pending order.
|
||||||
|
func TestEquipOrderHappyEquip(t *testing.T) {
|
||||||
|
s := seedEquip(t, "reala")
|
||||||
|
|
||||||
|
w := placeEquip(t, s, "reala", equipOrderReq{Token: "tok-josie", Action: "equip", ItemID: 501})
|
||||||
|
if w.Code != 200 {
|
||||||
|
t.Fatalf("equip = %d body=%s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var o storage.EquipOrder
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &o); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if o.Status != storage.EquipPending || o.Action != "equip" || o.ItemID != 501 {
|
||||||
|
t.Fatalf("order = %+v", o)
|
||||||
|
}
|
||||||
|
// Name and slot come from Pete's own detail, not the request.
|
||||||
|
if o.ItemName != "Ring of Protection" || o.Slot != "ring_1" || o.CharacterName != "Josie" {
|
||||||
|
t.Fatalf("order didn't resolve from the owner's record: %+v", o)
|
||||||
|
}
|
||||||
|
if pending, _ := storage.PendingEquipOrders(10); len(pending) != 1 {
|
||||||
|
t.Fatal("order didn't land in the pending set")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEquipOrderHappyUnequip: taking off a worn item rides on the slot; item_id 0.
|
||||||
|
func TestEquipOrderHappyUnequip(t *testing.T) {
|
||||||
|
s := seedEquip(t, "reala")
|
||||||
|
|
||||||
|
w := placeEquip(t, s, "reala", equipOrderReq{Token: "tok-josie", Action: "unequip", Slot: "cloak"})
|
||||||
|
if w.Code != 200 {
|
||||||
|
t.Fatalf("unequip = %d body=%s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var o storage.EquipOrder
|
||||||
|
_ = json.Unmarshal(w.Body.Bytes(), &o)
|
||||||
|
if o.Action != "unequip" || o.Slot != "cloak" || o.ItemName != "Cloak of Elvenkind" || o.ItemID != 0 {
|
||||||
|
t.Fatalf("unequip order = %+v", o)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEquipOrderRejections: the honest failure surface — not your page, item not
|
||||||
|
// in the pack, empty slot, unequippable mundane gear, bad action.
|
||||||
|
func TestEquipOrderRejections(t *testing.T) {
|
||||||
|
s := seedEquip(t, "reala")
|
||||||
|
|
||||||
|
// A different signed-in user does not own Josie's page.
|
||||||
|
if w := placeEquip(t, s, "mallory", equipOrderReq{Token: "tok-josie", Action: "equip", ItemID: 501}); w.Code != 403 {
|
||||||
|
t.Errorf("non-owner equip = %d, want 403", w.Code)
|
||||||
|
}
|
||||||
|
// An item id that isn't in the pack.
|
||||||
|
if w := placeEquip(t, s, "reala", equipOrderReq{Token: "tok-josie", Action: "equip", ItemID: 999}); w.Code != 400 {
|
||||||
|
t.Errorf("unknown item = %d, want 400", w.Code)
|
||||||
|
}
|
||||||
|
// Mundane gear has a slot but no id, so it can never be named for equip.
|
||||||
|
if w := placeEquip(t, s, "reala", equipOrderReq{Token: "tok-josie", Action: "equip", ItemID: 0}); w.Code != 400 {
|
||||||
|
t.Errorf("no-id equip = %d, want 400", w.Code)
|
||||||
|
}
|
||||||
|
// Unequip of a slot nothing is in.
|
||||||
|
if w := placeEquip(t, s, "reala", equipOrderReq{Token: "tok-josie", Action: "unequip", Slot: "boots"}); w.Code != 400 {
|
||||||
|
t.Errorf("empty-slot unequip = %d, want 400", w.Code)
|
||||||
|
}
|
||||||
|
// A bogus action.
|
||||||
|
if w := placeEquip(t, s, "reala", equipOrderReq{Token: "tok-josie", Action: "wield", ItemID: 501}); w.Code != 400 {
|
||||||
|
t.Errorf("bad action = %d, want 400", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEquipWireIdempotentAndAuthed: gogobee's pending/verdict pair is bearer-only,
|
||||||
|
// never nulls, and files a verdict once.
|
||||||
|
func TestEquipWireIdempotentAndAuthed(t *testing.T) {
|
||||||
|
s := seedEquip(t, "reala")
|
||||||
|
if w := placeEquip(t, s, "reala", equipOrderReq{Token: "tok-josie", Action: "equip", ItemID: 501}); w.Code != 200 {
|
||||||
|
t.Fatalf("seed order = %d", w.Code)
|
||||||
|
}
|
||||||
|
// The owner sees their own order in the "my orders" strip.
|
||||||
|
if rows := mustEquipOrders(t, s, "reala"); len(rows) != 1 {
|
||||||
|
t.Fatalf("owner sees %d orders, want 1", len(rows))
|
||||||
|
}
|
||||||
|
|
||||||
|
// No bearer → 401 on both machine endpoints.
|
||||||
|
if w := httptest.NewRecorder(); func() bool {
|
||||||
|
s.handleEquipPending(w, jsonReq(t, "GET", "/api/equip/pending", "", nil))
|
||||||
|
return w.Code == 401
|
||||||
|
}() == false {
|
||||||
|
t.Error("pending without bearer should be 401")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pending returns the order (bearer-authed), never null.
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
s.handleEquipPending(w, jsonReq(t, "GET", "/api/equip/pending", "tok", nil))
|
||||||
|
if w.Code != 200 {
|
||||||
|
t.Fatalf("pending = %d", w.Code)
|
||||||
|
}
|
||||||
|
var pending []storage.EquipOrder
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &pending); err != nil || len(pending) != 1 {
|
||||||
|
t.Fatalf("pending body = %s err=%v", w.Body.String(), err)
|
||||||
|
}
|
||||||
|
guid := pending[0].GUID
|
||||||
|
|
||||||
|
// A verdict resolves it; a replay is a no-op.
|
||||||
|
verdict := func(status, detail string) *httptest.ResponseRecorder {
|
||||||
|
rw := httptest.NewRecorder()
|
||||||
|
s.handleEquipVerdict(rw, jsonReq(t, "POST", "/api/equip/verdict", "tok",
|
||||||
|
equipVerdict{GUID: guid, Status: status, Detail: detail}))
|
||||||
|
return rw
|
||||||
|
}
|
||||||
|
if w := verdict("applied", "worn"); w.Code != 200 {
|
||||||
|
t.Fatalf("verdict = %d body=%s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if w := verdict("rejected_not_owned", "too late"); w.Code != 200 {
|
||||||
|
t.Fatalf("replay verdict = %d", w.Code)
|
||||||
|
}
|
||||||
|
got, _ := storage.EquipOrderByGUID(guid)
|
||||||
|
if got.Status != storage.EquipApplied || got.Detail != "worn" {
|
||||||
|
t.Fatalf("replay overwrote the first verdict: %+v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unknown guid parks with a 400, not a silent retry.
|
||||||
|
rw := httptest.NewRecorder()
|
||||||
|
s.handleEquipVerdict(rw, jsonReq(t, "POST", "/api/equip/verdict", "tok",
|
||||||
|
equipVerdict{GUID: "ghost", Status: "applied"}))
|
||||||
|
if rw.Code != 400 {
|
||||||
|
t.Errorf("unknown guid = %d, want 400", rw.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// mustEquipOrders returns the raw "my orders" JSON rows for a user. Small helper
|
||||||
|
// so the wire test can pull the guid it just created without reaching into storage.
|
||||||
|
func mustEquipOrders(t *testing.T, s *Server, username string) []json.RawMessage {
|
||||||
|
t.Helper()
|
||||||
|
r := as(t, s, username, "GET", "/api/equip/orders", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
s.handleEquipOrders(w, r)
|
||||||
|
if w.Code != 200 {
|
||||||
|
t.Fatalf("orders = %d", w.Code)
|
||||||
|
}
|
||||||
|
var rows []json.RawMessage
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &rows); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
}
|
||||||
@@ -255,6 +255,13 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo
|
|||||||
mux.HandleFunc("GET /api/mischief/pending", s.handleMischiefPending)
|
mux.HandleFunc("GET /api/mischief/pending", s.handleMischiefPending)
|
||||||
mux.HandleFunc("POST /api/mischief/claim", s.handleMischiefClaim)
|
mux.HandleFunc("POST /api/mischief/claim", s.handleMischiefClaim)
|
||||||
|
|
||||||
|
// The equip queue's game-box wire: gogobee polls pending equip/unequip orders
|
||||||
|
// and pushes a verdict. Same bearer token and same reason as the pair above —
|
||||||
|
// the caller is a machine on the tailnet. The owner-facing half hangs off the
|
||||||
|
// auth block below.
|
||||||
|
mux.HandleFunc("GET /api/equip/pending", s.handleEquipPending)
|
||||||
|
mux.HandleFunc("POST /api/equip/verdict", s.handleEquipVerdict)
|
||||||
|
|
||||||
// The casino. Signed-in only — there is money in it — so these hang off the
|
// The casino. Signed-in only — there is money in it — so these hang off the
|
||||||
// auth block, and gamesReady() also insists on a Matrix server name: without
|
// auth block, and gamesReady() also insists on a Matrix server name: without
|
||||||
// one, no player can be named to gogobee's ledger and the tables stay shut.
|
// one, no player can be named to gogobee's ledger and the tables stay shut.
|
||||||
@@ -282,6 +289,12 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo
|
|||||||
mux.HandleFunc("GET /api/mischief/catalog", s.handleMischiefCatalog)
|
mux.HandleFunc("GET /api/mischief/catalog", s.handleMischiefCatalog)
|
||||||
mux.HandleFunc("POST /api/mischief/order", s.handleMischiefOrder)
|
mux.HandleFunc("POST /api/mischief/order", s.handleMischiefOrder)
|
||||||
mux.HandleFunc("GET /api/mischief/orders", s.handleMischiefOrders)
|
mux.HandleFunc("GET /api/mischief/orders", s.handleMischiefOrders)
|
||||||
|
|
||||||
|
// The equip queue, owner side. Signed-in only — an order dresses a
|
||||||
|
// specific owner's character — and gated on the adventure seam like the
|
||||||
|
// storefront, since without a board there is no detail page to equip from.
|
||||||
|
mux.HandleFunc("POST /api/equip/order", s.handleEquipOrder)
|
||||||
|
mux.HandleFunc("GET /api/equip/orders", s.handleEquipOrders)
|
||||||
}
|
}
|
||||||
if s.cfg.Push.Enabled {
|
if s.cfg.Push.Enabled {
|
||||||
mux.HandleFunc("POST /api/push/subscribe", s.handlePushSubscribe)
|
mux.HandleFunc("POST /api/push/subscribe", s.handlePushSubscribe)
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -20,6 +20,12 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
{{if .Desc}}<p class="text-xs text-[color:var(--ink)]/55 mt-1 leading-snug">{{.Desc}}</p>{{end}}
|
{{if .Desc}}<p class="text-xs text-[color:var(--ink)]/55 mt-1 leading-snug">{{.Desc}}</p>{{end}}
|
||||||
{{if .Effect}}<p class="text-xs text-theme-adventure/80 mt-0.5">{{.Effect}}</p>{{end}}
|
{{if .Effect}}<p class="text-xs text-theme-adventure/80 mt-0.5">{{.Effect}}</p>{{end}}
|
||||||
|
{{if .EquipAction}}
|
||||||
|
<div class="mt-1.5">
|
||||||
|
<button type="button" class="equip-btn text-[11px] rounded-full border border-theme-adventure/40 text-theme-adventure hover:bg-theme-adventure/10 px-2.5 py-0.5 font-semibold transition-colors"
|
||||||
|
data-action="{{.EquipAction}}" data-item-id="{{.ID}}" data-slot="{{.Slot}}" data-item-name="{{.Name}}">{{if eq .EquipAction "equip"}}Equip{{else}}Take off{{end}}</button>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
</li>
|
</li>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
@@ -274,7 +280,7 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 p-6 shadow-pete">
|
<div id="gear-panel" data-token="{{.Mark.Token}}" class="rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 p-6 shadow-pete">
|
||||||
{{if .Worn}}
|
{{if .Worn}}
|
||||||
<h2 class="font-display text-xl font-bold mb-1">Worn</h2>
|
<h2 class="font-display text-xl font-bold mb-1">Worn</h2>
|
||||||
<p class="text-xs text-[color:var(--ink)]/45 mb-3">{{.BondsUsed}} of 3 bonds in use</p>
|
<p class="text-xs text-[color:var(--ink)]/45 mb-3">{{.BondsUsed}} of 3 bonds in use</p>
|
||||||
@@ -299,6 +305,14 @@
|
|||||||
{{range .VaultRows}}{{template "itemrow" .}}{{end}}
|
{{range .VaultRows}}{{template "itemrow" .}}{{end}}
|
||||||
</ul>
|
</ul>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
<!-- Changes you've asked for. The queue is honest: an equip lands on the
|
||||||
|
game box's next poll, so a fresh order reads "queued", never "done".
|
||||||
|
JS fills this from /api/equip/orders. -->
|
||||||
|
<div id="equip-orders" class="mt-6 hidden">
|
||||||
|
<h3 class="font-display text-lg font-bold mb-2">Pending changes</h3>
|
||||||
|
<ul id="equip-orders-list" class="space-y-1.5 text-xs"></ul>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -363,5 +377,102 @@
|
|||||||
}
|
}
|
||||||
timer = setInterval(refresh, 60000);
|
timer = setInterval(refresh, 60000);
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
// The equip queue, owner side. Clicking Equip / Take off records the intent; the
|
||||||
|
// item actually moves on the game box's next poll. So the UI never claims a change
|
||||||
|
// landed — it shows "queued" and lets the order's own status be the truth.
|
||||||
|
(function () {
|
||||||
|
var panel = document.getElementById('gear-panel');
|
||||||
|
if (!panel) return; // only the owner gets this panel at all
|
||||||
|
var token = panel.getAttribute('data-token');
|
||||||
|
var box = document.getElementById('equip-orders');
|
||||||
|
var list = document.getElementById('equip-orders-list');
|
||||||
|
|
||||||
|
// How each terminal status reads to the owner. gogobee only rejects when the
|
||||||
|
// item slipped out from under the order or can't be worn — a bond-cap "inert"
|
||||||
|
// is still an applied change, and its detail line says so.
|
||||||
|
var STATUS = {
|
||||||
|
pending: 'queued',
|
||||||
|
applied: 'done',
|
||||||
|
rejected_not_owned: "couldn't — that item had already moved",
|
||||||
|
rejected_not_worn: "couldn't — that slot was already empty",
|
||||||
|
rejected_not_equippable: "couldn't — that item can't be worn"
|
||||||
|
};
|
||||||
|
|
||||||
|
var pollTimer = null;
|
||||||
|
|
||||||
|
function render(orders) {
|
||||||
|
list.innerHTML = '';
|
||||||
|
if (!orders || !orders.length) { box.classList.add('hidden'); return; }
|
||||||
|
box.classList.remove('hidden');
|
||||||
|
var anyPending = false;
|
||||||
|
orders.forEach(function (o) {
|
||||||
|
if (o.status === 'pending') anyPending = true;
|
||||||
|
var li = document.createElement('li');
|
||||||
|
li.className = 'flex items-baseline justify-between gap-3';
|
||||||
|
var verb = o.action === 'equip' ? 'Equip' : 'Take off';
|
||||||
|
var left = document.createElement('span');
|
||||||
|
left.className = 'flex-1';
|
||||||
|
left.textContent = verb + ' ' + (o.item_name || o.slot || 'item');
|
||||||
|
var right = document.createElement('span');
|
||||||
|
right.className = 'shrink-0 ' + (o.status === 'pending'
|
||||||
|
? 'text-[color:var(--ink)]/45'
|
||||||
|
: (o.status === 'applied' ? 'text-theme-adventure font-semibold' : 'text-[color:var(--warn)]'));
|
||||||
|
right.textContent = o.detail || STATUS[o.status] || o.status;
|
||||||
|
li.appendChild(left); li.appendChild(right);
|
||||||
|
list.appendChild(li);
|
||||||
|
});
|
||||||
|
// While anything is still queued, keep refreshing so the verdict lands without
|
||||||
|
// a reload; stop once everything is terminal.
|
||||||
|
if (anyPending && !pollTimer) {
|
||||||
|
pollTimer = setInterval(loadOrders, 15000);
|
||||||
|
} else if (!anyPending && pollTimer) {
|
||||||
|
clearInterval(pollTimer); pollTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadOrders() {
|
||||||
|
fetch('/api/equip/orders', { headers: { 'Accept': 'application/json' } })
|
||||||
|
.then(function (r) { return r.ok ? r.json() : null; })
|
||||||
|
.then(function (o) { if (o) render(o); })
|
||||||
|
.catch(function () { /* transient — a later tick will do */ });
|
||||||
|
}
|
||||||
|
|
||||||
|
panel.addEventListener('click', function (e) {
|
||||||
|
var btn = e.target.closest('.equip-btn');
|
||||||
|
if (!btn || btn.disabled) return;
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.classList.add('opacity-50');
|
||||||
|
btn.textContent = 'queuing…';
|
||||||
|
fetch('/api/equip/order', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
token: token,
|
||||||
|
action: btn.getAttribute('data-action'),
|
||||||
|
item_id: parseInt(btn.getAttribute('data-item-id') || '0', 10),
|
||||||
|
slot: btn.getAttribute('data-slot') || ''
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.then(function (r) { return r.json().then(function (j) { return { ok: r.ok, body: j }; }); })
|
||||||
|
.then(function (res) {
|
||||||
|
if (!res.ok) {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.classList.remove('opacity-50');
|
||||||
|
btn.textContent = (res.body && res.body.error) || 'try again';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
btn.textContent = 'queued';
|
||||||
|
loadOrders();
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.classList.remove('opacity-50');
|
||||||
|
btn.textContent = 'try again';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
loadOrders();
|
||||||
|
})();
|
||||||
</script>
|
</script>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -111,15 +111,35 @@ type whoPage struct {
|
|||||||
type itemRow struct {
|
type itemRow struct {
|
||||||
storage.ItemView
|
storage.ItemView
|
||||||
Worn bool
|
Worn bool
|
||||||
|
// EquipAction is the control this row offers its owner: "unequip" on anything
|
||||||
|
// worn, "equip" on a backpack item the magic-item path will accept, "" on
|
||||||
|
// everything else (vault items, mundane backpack gear). Empty means no button.
|
||||||
|
EquipAction string
|
||||||
}
|
}
|
||||||
|
|
||||||
func itemRows(items []storage.ItemView, worn bool) []itemRow {
|
// itemRows wraps gogobee's item views for one panel. panel is "worn", "backpack",
|
||||||
|
// or "vault"; it decides both the bond wording (only a worn item can be inert) and
|
||||||
|
// which equip control, if any, the row offers.
|
||||||
|
func itemRows(items []storage.ItemView, panel string) []itemRow {
|
||||||
if len(items) == 0 {
|
if len(items) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
worn := panel == "worn"
|
||||||
out := make([]itemRow, 0, len(items))
|
out := make([]itemRow, 0, len(items))
|
||||||
for _, it := range items {
|
for _, it := range items {
|
||||||
out = append(out, itemRow{ItemView: it, Worn: worn})
|
row := itemRow{ItemView: it, Worn: worn}
|
||||||
|
switch {
|
||||||
|
case worn:
|
||||||
|
// Everything in magic_item_equipped is a magic item and can come off; the
|
||||||
|
// slot is the handle gogobee unequips by.
|
||||||
|
row.EquipAction = storage.EquipActionUnequip
|
||||||
|
case panel == "backpack" && it.ID != 0:
|
||||||
|
// Only a wearable magic item carries a row id (gogobee sets it just in the
|
||||||
|
// magic-item branch), so the id gates Equip to exactly the items the
|
||||||
|
// magic-item path accepts — never mundane gear, never a vault item.
|
||||||
|
row.EquipAction = storage.EquipActionEquip
|
||||||
|
}
|
||||||
|
out = append(out, row)
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
@@ -204,9 +224,9 @@ func (s *Server) handleAdventureWho(w http.ResponseWriter, r *http.Request) {
|
|||||||
if self, ok, err := storage.PlayerDetailByOwner(buyerLocalpart(u), token); err == nil && ok {
|
if self, ok, err := storage.PlayerDetailByOwner(buyerLocalpart(u), token); err == nil && ok {
|
||||||
page.HasSelf = true
|
page.HasSelf = true
|
||||||
page.Self = self
|
page.Self = self
|
||||||
page.Worn = itemRows(self.Equipped, true)
|
page.Worn = itemRows(self.Equipped, "worn")
|
||||||
page.Backpack = itemRows(self.Inventory, false)
|
page.Backpack = itemRows(self.Inventory, "backpack")
|
||||||
page.VaultRows = itemRows(self.Vault, false)
|
page.VaultRows = itemRows(self.Vault, "vault")
|
||||||
for _, it := range self.Equipped {
|
for _, it := range self.Equipped {
|
||||||
if it.Attuned {
|
if it.Attuned {
|
||||||
page.BondsUsed++
|
page.BondsUsed++
|
||||||
|
|||||||
Reference in New Issue
Block a user