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