adventure: ask 7 — full equipment management from the web

Extends equip-from-the-web (ask 5, magic-only) to all five standard gear
slots. Owners get an Equipment panel on their own who page with:
  - Take off for worn masterwork/arena pieces (round-trippable to pack)
  - Upgrade to the next shop tier (spends euros, confirm-gated)
  - Repair a damaged slot (spends euros, confirm-gated)
The public Gear panel is hidden for the owner since this supersedes it.

Wire: equip_orders gains a tier column; new actions upgrade/repair; new
verdicts rejected_downgrade / rejected_insufficient_funds / rejected_max_tier.
PlayerDetail carries Slots (EquipSlotView x5) + Balance for the confirm
dialogs. handleEquipOrder resolves take-off/upgrade/repair from pd.Slots
server-side and rejects a client-forged tier (409), same as ask 5 trusts
only Pete's own record.

Verified: full suite green, headless render of the panel + confirm dialog
in both day and night phases. gogobee ships the poll-apply half separately;
Pete deploys first so its ingest accepts the new verdicts before gogobee
emits them.
This commit is contained in:
prosolis
2026-07-17 20:34:24 -07:00
parent 1159e64505
commit b0aeffd218
10 changed files with 719 additions and 49 deletions
+2
View File
@@ -105,6 +105,8 @@ func runMigrations(d *sql.DB) error {
// recorded before the treasure_found event existed carry NULL, which is right:
// they had no such noun to keep.
addColumnIfMissing(d, "adventure_events", "stakes", "TEXT")
// Ask 7: upgrade orders carry a target tier for the 5 standard equipment slots.
addColumnIfMissing(d, "equip_orders", "tier", "INTEGER NOT NULL DEFAULT 0")
// FTS5 virtual tables don't support IF NOT EXISTS reliably.
// Check sqlite_master before creating.
+27
View File
@@ -18,6 +18,33 @@ type PlayerDetail struct {
Equipped []ItemView `json:"equipped,omitempty"`
House HouseView `json:"house"`
Pets []PetView `json:"pets,omitempty"`
// Slots is the 5 standard equipment slots (weapon/armor/helmet/boots/tool) —
// owner-only, the input to the web equipment-management panel. Worn
// masterwork/arena pieces surface HERE (via CanTakeOff), not in Equipped, which
// stays magic-only (the DnD slots). See EquipSlotView.
Slots []EquipSlotView `json:"slots,omitempty"`
// Balance is the owner's euro balance, for the upgrade/repair confirm dialogs.
Balance float64 `json:"balance,omitempty"`
}
// EquipSlotView is one of the 5 standard equipment slots as gogobee pushed it,
// carrying everything the management panel needs to render its controls: what is
// worn now, whether it can be taken off (masterwork/arena round-trip to the pack),
// the next tier's name and price for an upgrade offer, and a repair cost when the
// piece is damaged. Pete renders it verbatim and trusts only these facts — a
// client-forged tier or price is ignored, resolved back against this view.
type EquipSlotView struct {
Slot string `json:"slot"` // weapon|armor|helmet|boots|tool
Name string `json:"name"`
Tier int `json:"tier"`
Condition int `json:"condition"`
Masterwork bool `json:"masterwork,omitempty"`
ArenaTier int `json:"arena_tier,omitempty"`
CanTakeOff bool `json:"can_take_off,omitempty"` // masterwork/arena → round-trippable to the pack
NextTier int `json:"next_tier,omitempty"` // 0 = at max tier (5), no upgrade offered
NextName string `json:"next_name,omitempty"`
NextPrice float64 `json:"next_price,omitempty"`
RepairCost int `json:"repair_cost,omitempty"` // 0 = full condition, nothing to repair
}
// ItemView is one item in a private panel — backpack, vault, or worn.
+29 -12
View File
@@ -32,7 +32,8 @@ type EquipOrder struct {
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
Action string `json:"action"` // equip / unequip / upgrade / repair
Tier int `json:"tier,omitempty"` // upgrade target tier (an EquipmentSlot tier); unused by the other actions
Status string `json:"status"`
Detail string `json:"detail,omitempty"`
CreatedAt int64 `json:"created_at"`
@@ -40,9 +41,15 @@ type EquipOrder struct {
}
// Actions. These cross the wire to gogobee, so they are part of the contract.
// equip/unequip move an inventory item (magic) or a masterwork/arena piece; a
// take-off of a standard slot rides unequip too (the slot vocabularies are
// disjoint, so the string alone tells gogobee which path to run). upgrade and
// repair act on the 5 standard EquipmentSlots and spend euros on the game box.
const (
EquipActionEquip = "equip"
EquipActionUnequip = "unequip"
EquipActionUpgrade = "upgrade"
EquipActionRepair = "repair"
)
// Order states. Terminal states are enumerated, not free-text, so the page can
@@ -57,19 +64,29 @@ const (
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
// Ask 7 additions. A downgrade equip/upgrade is blocked by user decision; the
// euro-spending actions can bounce on funds or top out at the max tier.
EquipRejectedDowngrade = "rejected_downgrade" // equipping/upgrading to something no better than what's worn
EquipRejectedNoFunds = "rejected_insufficient_funds" // the euro debit would breach the debt limit
EquipRejectedMaxTier = "rejected_max_tier" // already at the top standard tier, nothing to buy
)
// validEquipVerdict is the set of terminal states gogobee may hand back.
func validEquipVerdict(status string) bool {
switch status {
case EquipApplied, EquipRejectedNotOwned, EquipRejectedNotWorn, EquipRejectedNotEquipp:
case EquipApplied, EquipRejectedNotOwned, EquipRejectedNotWorn, EquipRejectedNotEquipp,
EquipRejectedDowngrade, EquipRejectedNoFunds, EquipRejectedMaxTier:
return true
}
return false
}
func validEquipAction(action string) bool {
return action == EquipActionEquip || action == EquipActionUnequip
switch action {
case EquipActionEquip, EquipActionUnequip, EquipActionUpgrade, EquipActionRepair:
return true
}
return false
}
var ErrNoSuchEquipOrder = errors.New("equip: no such order")
@@ -79,7 +96,7 @@ var ErrNoSuchEquipOrder = errors.New("equip: no such order")
// 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) {
func InsertEquipOrder(ownerSub, ownerLocalpart, characterName string, itemID int64, itemName, slot, action string, tier int) (EquipOrder, error) {
if !validEquipAction(action) {
return EquipOrder{}, fmt.Errorf("equip: bad action %q", action)
}
@@ -90,16 +107,16 @@ func InsertEquipOrder(ownerSub, ownerLocalpart, characterName string, itemID int
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,
(guid, owner_sub, owner_localpart, character_name, item_id, item_name, slot, action, tier, status, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
guid, ownerSub, ownerLocalpart, characterName, itemID, itemName, slot, action, tier, 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,
Slot: slot, Action: action, Tier: tier, Status: EquipPending, CreatedAt: now, UpdatedAt: now,
}, nil
}
@@ -112,7 +129,7 @@ func PendingEquipOrders(limit int) ([]EquipOrder, error) {
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
`SELECT guid, owner_sub, owner_localpart, character_name, item_id, item_name, slot, action, tier, status, COALESCE(detail, ''), created_at, updated_at
FROM equip_orders
WHERE status = ?
ORDER BY created_at
@@ -148,7 +165,7 @@ func ResolveEquipOrder(guid, status, detail string) (EquipOrder, error) {
// 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
`SELECT guid, owner_sub, owner_localpart, character_name, item_id, item_name, slot, action, tier, status, COALESCE(detail, ''), created_at, updated_at
FROM equip_orders WHERE guid = ?`, guid,
)
if err != nil {
@@ -173,7 +190,7 @@ func EquipOrdersByOwner(ownerSub string, limit int) ([]EquipOrder, error) {
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
`SELECT guid, owner_sub, owner_localpart, character_name, item_id, item_name, slot, action, tier, status, COALESCE(detail, ''), created_at, updated_at
FROM equip_orders
WHERE owner_sub = ?
ORDER BY created_at DESC
@@ -206,7 +223,7 @@ func scanEquipOrders(rows *sql.Rows) ([]EquipOrder, error) {
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.ItemID, &o.ItemName, &o.Slot, &o.Action, &o.Tier, &o.Status, &o.Detail,
&o.CreatedAt, &o.UpdatedAt); err != nil {
return nil, fmt.Errorf("equip: scan order: %w", err)
}
+7 -7
View File
@@ -9,7 +9,7 @@ import (
func TestEquipOrderLifecycle(t *testing.T) {
setupTestDB(t)
o, err := InsertEquipOrder("sub-1", "josie", "Josie", 42, "Cloak of Elvenkind", "cloak", EquipActionEquip)
o, err := InsertEquipOrder("sub-1", "josie", "Josie", 42, "Cloak of Elvenkind", "cloak", EquipActionEquip, 0)
if err != nil {
t.Fatal(err)
}
@@ -47,7 +47,7 @@ func TestEquipOrderLifecycle(t *testing.T) {
func TestEquipResolveIsIdempotent(t *testing.T) {
setupTestDB(t)
o, err := InsertEquipOrder("sub-1", "josie", "Josie", 7, "Ring of Protection", "ring_1", EquipActionEquip)
o, err := InsertEquipOrder("sub-1", "josie", "Josie", 7, "Ring of Protection", "ring_1", EquipActionEquip, 0)
if err != nil {
t.Fatal(err)
}
@@ -70,7 +70,7 @@ func TestEquipResolveUnknownAndBadVerdict(t *testing.T) {
t.Fatalf("unknown guid err = %v, want ErrNoSuchEquipOrder", err)
}
o, _ := InsertEquipOrder("sub-1", "josie", "Josie", 1, "Boots", "feet", EquipActionEquip)
o, _ := InsertEquipOrder("sub-1", "josie", "Josie", 1, "Boots", "feet", EquipActionEquip, 0)
if _, err := ResolveEquipOrder(o.GUID, "exploded", ""); err == nil {
t.Error("a bogus verdict status was accepted")
}
@@ -83,7 +83,7 @@ func TestEquipResolveUnknownAndBadVerdict(t *testing.T) {
// 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 {
if _, err := InsertEquipOrder("sub-1", "josie", "Josie", 1, "Thing", "cloak", "wield", 0); err == nil {
t.Fatal("a bogus action was accepted")
}
}
@@ -92,7 +92,7 @@ func TestEquipInsertRejectsBadAction(t *testing.T) {
// 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)
o, err := InsertEquipOrder("sub-1", "josie", "Josie", 0, "Cloak of Elvenkind", "cloak", EquipActionUnequip, 0)
if err != nil {
t.Fatal(err)
}
@@ -106,11 +106,11 @@ 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 {
if _, err := InsertEquipOrder("sub-A", "alice", "Alice", int64(i+1), "Item", "cloak", EquipActionEquip, 0); err != nil {
t.Fatal(err)
}
}
if _, err := InsertEquipOrder("sub-B", "bob", "Bob", 9, "Item", "cloak", EquipActionEquip); err != nil {
if _, err := InsertEquipOrder("sub-B", "bob", "Bob", 9, "Item", "cloak", EquipActionEquip, 0); err != nil {
t.Fatal(err)
}
+2 -1
View File
@@ -181,7 +181,8 @@ CREATE TABLE IF NOT EXISTS equip_orders (
item_id INTEGER NOT NULL DEFAULT 0,
item_name TEXT NOT NULL DEFAULT '',
slot TEXT NOT NULL DEFAULT '',
action TEXT NOT NULL, -- equip / unequip
action TEXT NOT NULL, -- equip / unequip / upgrade / repair
tier INTEGER NOT NULL DEFAULT 0, -- upgrade target tier; unused by the other actions
status TEXT NOT NULL, -- see the ladder above
detail TEXT, -- gogobee's human note on the verdict
created_at INTEGER NOT NULL,
+69 -18
View File
@@ -37,6 +37,7 @@ type equipOrderReq struct {
Action string `json:"action"`
ItemID int64 `json:"item_id"`
Slot string `json:"slot"`
Tier int `json:"tier"` // upgrade only: the target standard tier; verified against the pushed slot view
}
// handleEquipOrder places a pending equip/unequip for the signed-in owner. It
@@ -63,7 +64,10 @@ func (s *Server) handleEquipOrder(w http.ResponseWriter, r *http.Request) {
writeEquipError(w, http.StatusBadRequest, "no character")
return
}
if req.Action != storage.EquipActionEquip && req.Action != storage.EquipActionUnequip {
switch req.Action {
case storage.EquipActionEquip, storage.EquipActionUnequip,
storage.EquipActionUpgrade, storage.EquipActionRepair:
default:
writeEquipError(w, http.StatusBadRequest, "bad action")
return
}
@@ -82,25 +86,60 @@ func (s *Server) handleEquipOrder(w http.ResponseWriter, r *http.Request) {
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 {
// Resolve every fact of the order from the owner's own pushed detail — never
// from the client — so a forged name, slot, tier, or price can't ride in. An
// equip names a backpack item by its row id; an unequip/take-off names a worn
// slot (a magic DnD slot in Equipped, or a masterwork/arena standard slot in
// Slots); upgrade and repair name a standard slot in Slots and move money, so
// the target tier is trusted only when it matches the slot's pushed NextTier.
var (
itemName string
slot string
itemID int64
tier int
)
switch req.Action {
case 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")
itemName, slot, itemID = it.Name, it.Slot, req.ItemID
case storage.EquipActionUnequip:
// Magic take-off keys on a DnD slot in Equipped; masterwork/arena take-off
// keys on a standard slot in Slots (CanTakeOff). The vocabularies are disjoint,
// so try each — gogobee disambiguates the same way. A plain shop-tier slot has
// nothing to round-trip, so it never resolves here (revert is an upgrade path).
if it, found := findWornSlot(pd.Equipped, req.Slot); found {
itemName, slot = it.Name, it.Slot
} else if sv, found := findSlotView(pd.Slots, req.Slot); found && sv.CanTakeOff {
itemName, slot = sv.Name, sv.Slot
} else {
writeEquipError(w, http.StatusBadRequest, "nothing to take off there")
return
}
itemName, slot = it.Name, it.Slot
case storage.EquipActionUpgrade:
sv, found := findSlotView(pd.Slots, req.Slot)
if !found || sv.NextTier == 0 {
writeEquipError(w, http.StatusBadRequest, "no upgrade available for that slot")
return
}
if req.Tier != sv.NextTier {
// The web offers the next tier only; a request for anything else is a stale
// page or a forged jump. Refuse rather than debit for a tier the owner never
// saw priced.
writeEquipError(w, http.StatusConflict, "that upgrade is out of date — reload the page")
return
}
itemName, slot, tier = sv.NextName, sv.Slot, sv.NextTier
case storage.EquipActionRepair:
sv, found := findSlotView(pd.Slots, req.Slot)
if !found || sv.RepairCost == 0 {
writeEquipError(w, http.StatusBadRequest, "nothing to repair there")
return
}
itemName, slot = sv.Name, sv.Slot
}
since := time.Now().Add(-equipBurstWindow).Unix()
@@ -118,11 +157,7 @@ func (s *Server) handleEquipOrder(w http.ResponseWriter, r *http.Request) {
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)
order, err := storage.InsertEquipOrder(u.Sub, owner, characterName, itemID, itemName, slot, req.Action, tier)
if err != nil {
slog.Error("equip: insert order", "err", err)
writeEquipError(w, http.StatusInternalServerError, "internal error")
@@ -161,6 +196,22 @@ func findWornSlot(items []storage.ItemView, slot string) (storage.ItemView, bool
return storage.ItemView{}, false
}
// findSlotView finds one of the 5 standard equipment slots by name. It is the
// server-side source of truth for a take-off / upgrade / repair: the request
// names a slot, and every other fact (name, next tier, price, repair cost) is
// read from here rather than trusted from the client.
func findSlotView(slots []storage.EquipSlotView, slot string) (storage.EquipSlotView, bool) {
if slot == "" {
return storage.EquipSlotView{}, false
}
for _, sv := range slots {
if sv.Slot == slot {
return sv, true
}
}
return storage.EquipSlotView{}, 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) {
+115
View File
@@ -3,6 +3,7 @@ package web
import (
"encoding/json"
"net/http/httptest"
"strings"
"testing"
"time"
@@ -41,6 +42,16 @@ func seedEquip(t *testing.T, owner string) *Server {
{Name: "Cloak of Elvenkind", Type: "wondrous", Value: 2000, Slot: "cloak",
Effect: "faster to act", Attunement: true, Attuned: true},
},
// The 5 standard slots (ask 7). weapon is a worn masterwork (round-trippable,
// at max tier, damaged → repairable); boots is plain shop-tier at T3 with a
// T4 upgrade offered and nothing to take off or repair.
Slots: []storage.EquipSlotView{
{Slot: "weapon", Name: "Deepforged Blade", Tier: 5, Condition: 80,
Masterwork: true, CanTakeOff: true, RepairCost: 40},
{Slot: "boots", Name: "Leather Boots", Tier: 3, Condition: 100,
NextTier: 4, NextName: "Sturdy Boots", NextPrice: 25000},
},
Balance: 100000,
}}}); w.Code != 200 {
t.Fatalf("seed detail = %d", w.Code)
}
@@ -122,6 +133,110 @@ func TestEquipOrderRejections(t *testing.T) {
}
}
// TestEquipTakeOffMasterwork: a worn masterwork piece in a standard slot rides the
// unequip action, resolved from Slots (CanTakeOff), keyed on the slot with no item id.
func TestEquipTakeOffMasterwork(t *testing.T) {
s := seedEquip(t, "reala")
w := placeEquip(t, s, "reala", equipOrderReq{Token: "tok-josie", Action: "unequip", Slot: "weapon"})
if w.Code != 200 {
t.Fatalf("take off = %d body=%s", w.Code, w.Body.String())
}
var o storage.EquipOrder
_ = json.Unmarshal(w.Body.Bytes(), &o)
if o.Action != "unequip" || o.Slot != "weapon" || o.ItemName != "Deepforged Blade" || o.ItemID != 0 {
t.Fatalf("take-off order = %+v", o)
}
}
// TestEquipUpgradeHappy: upgrading the boots to their next tier queues an upgrade
// order carrying the target tier and the tier's name — both resolved from the
// pushed slot view, not the request.
func TestEquipUpgradeHappy(t *testing.T) {
s := seedEquip(t, "reala")
w := placeEquip(t, s, "reala", equipOrderReq{Token: "tok-josie", Action: "upgrade", Slot: "boots", Tier: 4})
if w.Code != 200 {
t.Fatalf("upgrade = %d body=%s", w.Code, w.Body.String())
}
var o storage.EquipOrder
_ = json.Unmarshal(w.Body.Bytes(), &o)
if o.Action != "upgrade" || o.Slot != "boots" || o.Tier != 4 || o.ItemName != "Sturdy Boots" || o.ItemID != 0 {
t.Fatalf("upgrade order = %+v", o)
}
}
// TestEquipRepairHappy: repairing a damaged slot queues a repair order keyed on the
// slot, no money in the request — the cost is gogobee's at apply time.
func TestEquipRepairHappy(t *testing.T) {
s := seedEquip(t, "reala")
w := placeEquip(t, s, "reala", equipOrderReq{Token: "tok-josie", Action: "repair", Slot: "weapon"})
if w.Code != 200 {
t.Fatalf("repair = %d body=%s", w.Code, w.Body.String())
}
var o storage.EquipOrder
_ = json.Unmarshal(w.Body.Bytes(), &o)
if o.Action != "repair" || o.Slot != "weapon" || o.ItemName != "Deepforged Blade" {
t.Fatalf("repair order = %+v", o)
}
}
// TestEquipUpgradeRepairRejections: the money-spending actions trust only the
// pushed slot view. A forged tier, a slot with no upgrade offered, a repair of a
// full-condition slot, and a non-owner all bounce before any order is placed.
func TestEquipUpgradeRepairRejections(t *testing.T) {
s := seedEquip(t, "reala")
// A tier that isn't the slot's pushed NextTier: a stale page or a forged jump.
if w := placeEquip(t, s, "reala", equipOrderReq{Token: "tok-josie", Action: "upgrade", Slot: "boots", Tier: 5}); w.Code != 409 {
t.Errorf("forged upgrade tier = %d, want 409", w.Code)
}
// weapon is at max tier (NextTier 0): no upgrade to offer.
if w := placeEquip(t, s, "reala", equipOrderReq{Token: "tok-josie", Action: "upgrade", Slot: "weapon", Tier: 6}); w.Code != 400 {
t.Errorf("upgrade with no offer = %d, want 400", w.Code)
}
// boots are at full condition: nothing to repair.
if w := placeEquip(t, s, "reala", equipOrderReq{Token: "tok-josie", Action: "repair", Slot: "boots"}); w.Code != 400 {
t.Errorf("repair of full-condition slot = %d, want 400", w.Code)
}
// A non-owner can't spend someone else's euros.
if w := placeEquip(t, s, "mallory", equipOrderReq{Token: "tok-josie", Action: "upgrade", Slot: "boots", Tier: 4}); w.Code != 403 {
t.Errorf("non-owner upgrade = %d, want 403", w.Code)
}
// No order should have survived any of those.
if pending, _ := storage.PendingEquipOrders(10); len(pending) != 0 {
t.Fatalf("a rejected money action still queued an order: %+v", pending)
}
}
// TestEquipPanelRenders: the owner's who page renders the Equipment panel with the
// three controls and the confirm data (balance) the money actions need.
func TestEquipPanelRenders(t *testing.T) {
s := seedEquip(t, "reala")
body := getWho(t, s, "tok-josie", "reala").Body.String()
for _, want := range []string{
"Equipment",
"Deepforged Blade",
"Take off", // the masterwork weapon is round-trippable
"Upgrade to Sturdy Boots", // the boots offer the next tier
"Repair", // the damaged weapon can be mended
"data-balance=\"100000.00\"", // the confirm dialog needs the balance
} {
if !strings.Contains(body, want) {
t.Errorf("equipment panel missing %q", want)
}
}
// The public Gear panel is suppressed for the owner (the Equipment panel
// supersedes it), so its heading must not appear on the owner render.
// A non-owner still sees the public sheet unchanged.
anon := getWho(t, s, "tok-josie", "").Body.String()
if strings.Contains(anon, "Deepforged Blade") {
t.Error("the owner-only equipment panel leaked onto the public page")
}
}
// TestEquipWireIdempotentAndAuthed: gogobee's pending/verdict pair is bearer-only,
// never nulls, and files a verdict once.
func TestEquipWireIdempotentAndAuthed(t *testing.T) {
File diff suppressed because one or more lines are too long
+120 -10
View File
@@ -117,6 +117,9 @@
{{end}}
</div>
{{/* Public gear list. Hidden for the owner: their own "Equipment" panel below
supersedes it with live tiers, conditions, and management controls. */}}
{{if not .HasSelf}}
<div class="rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 p-6 shadow-pete">
<h2 class="font-display text-xl font-bold mb-4">Gear</h2>
{{if .Detail.Gear}}
@@ -133,6 +136,7 @@
<p class="text-sm text-[color:var(--ink)]/50">Traveling light — nothing equipped.</p>
{{end}}
</div>
{{end}}
</section>
{{if .MapView}}
@@ -290,6 +294,46 @@
<span class="text-xs text-[color:var(--ink)]/45">only you can see the panels below</span>
</div>
{{/* Equipment: the 5 standard slots, owner-only management. Take off round-trips
a masterwork/arena piece to the pack (no money); Upgrade buys the next tier
and Repair mends condition, both spending euros behind a confirm that shows
the cost and the resulting balance. Every price and tier here is gogobee's,
resolved server-side — the buttons only carry what was pushed. */}}
{{if .Self.Slots}}
<div id="equip-panel" data-token="{{.Mark.Token}}" data-balance="{{printf "%.2f" .Self.Balance}}"
class="rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 p-6 shadow-pete mb-6">
<div class="flex items-baseline justify-between mb-4">
<h2 class="font-display text-xl font-bold">Equipment</h2>
<span class="text-xs text-[color:var(--ink)]/50">Balance <span class="font-semibold text-[color:var(--ink)]/70">€{{printf "%.2f" .Self.Balance}}</span></span>
</div>
<ul class="grid gap-3 sm:grid-cols-2">
{{range .Self.Slots}}
<li class="slot-card rounded-2xl bg-[color:var(--ink)]/5 p-4">
<div class="flex items-baseline justify-between gap-2">
<span class="text-[10px] uppercase tracking-wider text-[color:var(--ink)]/45">{{.Slot}}</span>
<span class="text-xs text-[color:var(--ink)]/50 shrink-0">T{{.Tier}}{{if .Condition}} · {{.Condition}}%{{end}}</span>
</div>
<div class="font-semibold mt-0.5 flex items-center flex-wrap gap-1.5">
<span>{{.Name}}</span>
{{if .Masterwork}}<span class="text-theme-adventure" title="masterwork"></span>{{end}}
{{if .ArenaTier}}<span class="text-[11px] rounded-full bg-theme-adventure/20 px-2 py-0.5 text-theme-adventure font-semibold">arena T{{.ArenaTier}}</span>{{end}}
</div>
{{if or .CanTakeOff .NextTier .RepairCost}}
<div class="mt-2 flex flex-wrap gap-1.5">
{{if .CanTakeOff}}<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="unequip" data-slot="{{.Slot}}" data-item-name="{{.Name}}">Take off</button>{{end}}
{{if .NextTier}}<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="upgrade" data-slot="{{.Slot}}" data-tier="{{.NextTier}}" data-cost="{{printf "%.2f" .NextPrice}}" data-item-name="{{.NextName}}">Upgrade to {{.NextName}} · €{{printf "%.0f" .NextPrice}}</button>{{end}}
{{if .RepairCost}}<button type="button" class="equip-btn text-[11px] rounded-full border border-[color:var(--warn)]/40 text-[color:var(--warn)] hover:bg-amber-400/10 px-2.5 py-0.5 font-semibold transition-colors"
data-action="repair" data-slot="{{.Slot}}" data-cost="{{.RepairCost}}" data-item-name="{{.Name}}">Repair · €{{.RepairCost}}</button>{{end}}
</div>
{{end}}
</li>
{{end}}
</ul>
</div>
{{end}}
<div class="grid gap-6 sm:grid-cols-2">
<div class="rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 p-6 shadow-pete">
<h2 class="font-display text-xl font-bold mb-4">Home</h2>
@@ -417,12 +461,22 @@
// 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');
// Two owner panels can carry equip controls: the magic Worn/Backpack panel
// (#gear-panel) and the standard-slot management panel (#equip-panel). Either
// may be absent, so wire whichever exist and share one handler.
var gearPanel = document.getElementById('gear-panel');
var equipPanel = document.getElementById('equip-panel');
var panels = [gearPanel, equipPanel].filter(Boolean);
if (!panels.length) return; // only the owner gets these panels at all
var token = (equipPanel || gearPanel).getAttribute('data-token');
var balance = equipPanel ? parseFloat(equipPanel.getAttribute('data-balance') || '0') : 0;
var box = document.getElementById('equip-orders');
var list = document.getElementById('equip-orders-list');
function euroFmt(n) {
return (Math.round(n * 100) / 100).toLocaleString('en-US', { maximumFractionDigits: 2 });
}
// 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.
@@ -431,9 +485,14 @@
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"
rejected_not_equippable: "couldn't — that item can't be worn",
rejected_downgrade: "couldn't — that wouldn't be an upgrade",
rejected_insufficient_funds: "couldn't — not enough euros",
rejected_max_tier: "couldn't — already at the top tier"
};
var VERB = { equip: 'Equip', unequip: 'Take off', upgrade: 'Upgrade', repair: 'Repair' };
var pollTimer = null;
function render(orders) {
@@ -445,7 +504,7 @@
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 verb = VERB[o.action] || o.action;
var left = document.createElement('span');
left.className = 'flex-1';
left.textContent = verb + ' ' + (o.item_name || o.slot || 'item');
@@ -473,9 +532,9 @@
.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;
// placeOrder records the intent. equip / unequip / take-off go straight here;
// upgrade / repair pass through askConfirm first (they spend euros).
function placeOrder(btn) {
btn.disabled = true;
btn.classList.add('opacity-50');
btn.textContent = 'queuing…';
@@ -486,7 +545,8 @@
token: token,
action: btn.getAttribute('data-action'),
item_id: parseInt(btn.getAttribute('data-item-id') || '0', 10),
slot: btn.getAttribute('data-slot') || ''
slot: btn.getAttribute('data-slot') || '',
tier: parseInt(btn.getAttribute('data-tier') || '0', 10)
})
})
.then(function (r) { return r.json().then(function (j) { return { ok: r.ok, body: j }; }); })
@@ -505,7 +565,57 @@
btn.classList.remove('opacity-50');
btn.textContent = 'try again';
});
});
}
// askConfirm is the money gate: an upgrade or repair debits euros on the game
// box, so show the cost and the resulting balance before anything is queued.
// Placing it here in the DOM (rather than a native dialog) keeps the page's look
// and never blocks the browser event loop.
function askConfirm(btn) {
var card = btn.closest('.slot-card') || btn.parentElement;
var existing = card.querySelector('.equip-confirm');
if (existing) existing.remove();
var cost = parseFloat(btn.getAttribute('data-cost') || '0');
var action = btn.getAttribute('data-action');
var name = btn.getAttribute('data-item-name') || 'this slot';
var head = action === 'upgrade' ? ('Upgrade to ' + name) : ('Repair ' + name);
var boxEl = document.createElement('div');
boxEl.className = 'equip-confirm mt-2 rounded-xl bg-[color:var(--ink)]/5 p-2.5 text-xs';
var p = document.createElement('p');
p.className = 'text-[color:var(--ink)]/70';
p.textContent = head + ' for €' + euroFmt(cost) + '? Balance €' + euroFmt(balance) + ' → €' + euroFmt(balance - cost) + '.';
var row = document.createElement('div');
row.className = 'mt-2 flex gap-1.5';
var yes = document.createElement('button');
yes.type = 'button';
yes.className = 'rounded-full bg-theme-adventure text-white px-2.5 py-0.5 font-semibold';
yes.textContent = 'Confirm · €' + euroFmt(cost);
yes.addEventListener('click', function () { boxEl.remove(); placeOrder(btn); });
var no = document.createElement('button');
no.type = 'button';
no.className = 'rounded-full border border-[color:var(--ink)]/20 text-[color:var(--ink)]/60 px-2.5 py-0.5';
no.textContent = 'Cancel';
no.addEventListener('click', function () { boxEl.remove(); });
row.appendChild(yes);
row.appendChild(no);
boxEl.appendChild(p);
boxEl.appendChild(row);
card.appendChild(boxEl);
}
function onClick(e) {
var btn = e.target.closest('.equip-btn');
if (!btn || btn.disabled) return;
var action = btn.getAttribute('data-action');
if (action === 'upgrade' || action === 'repair') {
askConfirm(btn); // money moves — confirm cost + balance first
return;
}
placeOrder(btn);
}
panels.forEach(function (p) { p.addEventListener('click', onClick); });
loadOrders();
})();