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
+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) {