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:
+69
-18
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user