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