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:
@@ -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.
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user