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,
|
||||
|
||||
+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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
File diff suppressed because one or more lines are too long
+120
-10
@@ -117,6 +117,9 @@
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
{{/* Public gear list. Hidden for the owner: their own "Equipment" panel below
|
||||
supersedes it with live tiers, conditions, and management controls. */}}
|
||||
{{if not .HasSelf}}
|
||||
<div class="rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 p-6 shadow-pete">
|
||||
<h2 class="font-display text-xl font-bold mb-4">Gear</h2>
|
||||
{{if .Detail.Gear}}
|
||||
@@ -133,6 +136,7 @@
|
||||
<p class="text-sm text-[color:var(--ink)]/50">Traveling light — nothing equipped.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</section>
|
||||
|
||||
{{if .MapView}}
|
||||
@@ -290,6 +294,46 @@
|
||||
<span class="text-xs text-[color:var(--ink)]/45">only you can see the panels below</span>
|
||||
</div>
|
||||
|
||||
{{/* Equipment: the 5 standard slots, owner-only management. Take off round-trips
|
||||
a masterwork/arena piece to the pack (no money); Upgrade buys the next tier
|
||||
and Repair mends condition, both spending euros behind a confirm that shows
|
||||
the cost and the resulting balance. Every price and tier here is gogobee's,
|
||||
resolved server-side — the buttons only carry what was pushed. */}}
|
||||
{{if .Self.Slots}}
|
||||
<div id="equip-panel" data-token="{{.Mark.Token}}" data-balance="{{printf "%.2f" .Self.Balance}}"
|
||||
class="rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 p-6 shadow-pete mb-6">
|
||||
<div class="flex items-baseline justify-between mb-4">
|
||||
<h2 class="font-display text-xl font-bold">Equipment</h2>
|
||||
<span class="text-xs text-[color:var(--ink)]/50">Balance <span class="font-semibold text-[color:var(--ink)]/70">€{{printf "%.2f" .Self.Balance}}</span></span>
|
||||
</div>
|
||||
<ul class="grid gap-3 sm:grid-cols-2">
|
||||
{{range .Self.Slots}}
|
||||
<li class="slot-card rounded-2xl bg-[color:var(--ink)]/5 p-4">
|
||||
<div class="flex items-baseline justify-between gap-2">
|
||||
<span class="text-[10px] uppercase tracking-wider text-[color:var(--ink)]/45">{{.Slot}}</span>
|
||||
<span class="text-xs text-[color:var(--ink)]/50 shrink-0">T{{.Tier}}{{if .Condition}} · {{.Condition}}%{{end}}</span>
|
||||
</div>
|
||||
<div class="font-semibold mt-0.5 flex items-center flex-wrap gap-1.5">
|
||||
<span>{{.Name}}</span>
|
||||
{{if .Masterwork}}<span class="text-theme-adventure" title="masterwork">★</span>{{end}}
|
||||
{{if .ArenaTier}}<span class="text-[11px] rounded-full bg-theme-adventure/20 px-2 py-0.5 text-theme-adventure font-semibold">arena T{{.ArenaTier}}</span>{{end}}
|
||||
</div>
|
||||
{{if or .CanTakeOff .NextTier .RepairCost}}
|
||||
<div class="mt-2 flex flex-wrap gap-1.5">
|
||||
{{if .CanTakeOff}}<button type="button" class="equip-btn text-[11px] rounded-full border border-theme-adventure/40 text-theme-adventure hover:bg-theme-adventure/10 px-2.5 py-0.5 font-semibold transition-colors"
|
||||
data-action="unequip" data-slot="{{.Slot}}" data-item-name="{{.Name}}">Take off</button>{{end}}
|
||||
{{if .NextTier}}<button type="button" class="equip-btn text-[11px] rounded-full border border-theme-adventure/40 text-theme-adventure hover:bg-theme-adventure/10 px-2.5 py-0.5 font-semibold transition-colors"
|
||||
data-action="upgrade" data-slot="{{.Slot}}" data-tier="{{.NextTier}}" data-cost="{{printf "%.2f" .NextPrice}}" data-item-name="{{.NextName}}">Upgrade to {{.NextName}} · €{{printf "%.0f" .NextPrice}}</button>{{end}}
|
||||
{{if .RepairCost}}<button type="button" class="equip-btn text-[11px] rounded-full border border-[color:var(--warn)]/40 text-[color:var(--warn)] hover:bg-amber-400/10 px-2.5 py-0.5 font-semibold transition-colors"
|
||||
data-action="repair" data-slot="{{.Slot}}" data-cost="{{.RepairCost}}" data-item-name="{{.Name}}">Repair · €{{.RepairCost}}</button>{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="grid gap-6 sm:grid-cols-2">
|
||||
<div class="rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 p-6 shadow-pete">
|
||||
<h2 class="font-display text-xl font-bold mb-4">Home</h2>
|
||||
@@ -417,12 +461,22 @@
|
||||
// item actually moves on the game box's next poll. So the UI never claims a change
|
||||
// landed — it shows "queued" and lets the order's own status be the truth.
|
||||
(function () {
|
||||
var panel = document.getElementById('gear-panel');
|
||||
if (!panel) return; // only the owner gets this panel at all
|
||||
var token = panel.getAttribute('data-token');
|
||||
// Two owner panels can carry equip controls: the magic Worn/Backpack panel
|
||||
// (#gear-panel) and the standard-slot management panel (#equip-panel). Either
|
||||
// may be absent, so wire whichever exist and share one handler.
|
||||
var gearPanel = document.getElementById('gear-panel');
|
||||
var equipPanel = document.getElementById('equip-panel');
|
||||
var panels = [gearPanel, equipPanel].filter(Boolean);
|
||||
if (!panels.length) return; // only the owner gets these panels at all
|
||||
var token = (equipPanel || gearPanel).getAttribute('data-token');
|
||||
var balance = equipPanel ? parseFloat(equipPanel.getAttribute('data-balance') || '0') : 0;
|
||||
var box = document.getElementById('equip-orders');
|
||||
var list = document.getElementById('equip-orders-list');
|
||||
|
||||
function euroFmt(n) {
|
||||
return (Math.round(n * 100) / 100).toLocaleString('en-US', { maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
// How each terminal status reads to the owner. gogobee only rejects when the
|
||||
// item slipped out from under the order or can't be worn — a bond-cap "inert"
|
||||
// is still an applied change, and its detail line says so.
|
||||
@@ -431,9 +485,14 @@
|
||||
applied: 'done',
|
||||
rejected_not_owned: "couldn't — that item had already moved",
|
||||
rejected_not_worn: "couldn't — that slot was already empty",
|
||||
rejected_not_equippable: "couldn't — that item can't be worn"
|
||||
rejected_not_equippable: "couldn't — that item can't be worn",
|
||||
rejected_downgrade: "couldn't — that wouldn't be an upgrade",
|
||||
rejected_insufficient_funds: "couldn't — not enough euros",
|
||||
rejected_max_tier: "couldn't — already at the top tier"
|
||||
};
|
||||
|
||||
var VERB = { equip: 'Equip', unequip: 'Take off', upgrade: 'Upgrade', repair: 'Repair' };
|
||||
|
||||
var pollTimer = null;
|
||||
|
||||
function render(orders) {
|
||||
@@ -445,7 +504,7 @@
|
||||
if (o.status === 'pending') anyPending = true;
|
||||
var li = document.createElement('li');
|
||||
li.className = 'flex items-baseline justify-between gap-3';
|
||||
var verb = o.action === 'equip' ? 'Equip' : 'Take off';
|
||||
var verb = VERB[o.action] || o.action;
|
||||
var left = document.createElement('span');
|
||||
left.className = 'flex-1';
|
||||
left.textContent = verb + ' ' + (o.item_name || o.slot || 'item');
|
||||
@@ -473,9 +532,9 @@
|
||||
.catch(function () { /* transient — a later tick will do */ });
|
||||
}
|
||||
|
||||
panel.addEventListener('click', function (e) {
|
||||
var btn = e.target.closest('.equip-btn');
|
||||
if (!btn || btn.disabled) return;
|
||||
// placeOrder records the intent. equip / unequip / take-off go straight here;
|
||||
// upgrade / repair pass through askConfirm first (they spend euros).
|
||||
function placeOrder(btn) {
|
||||
btn.disabled = true;
|
||||
btn.classList.add('opacity-50');
|
||||
btn.textContent = 'queuing…';
|
||||
@@ -486,7 +545,8 @@
|
||||
token: token,
|
||||
action: btn.getAttribute('data-action'),
|
||||
item_id: parseInt(btn.getAttribute('data-item-id') || '0', 10),
|
||||
slot: btn.getAttribute('data-slot') || ''
|
||||
slot: btn.getAttribute('data-slot') || '',
|
||||
tier: parseInt(btn.getAttribute('data-tier') || '0', 10)
|
||||
})
|
||||
})
|
||||
.then(function (r) { return r.json().then(function (j) { return { ok: r.ok, body: j }; }); })
|
||||
@@ -505,7 +565,57 @@
|
||||
btn.classList.remove('opacity-50');
|
||||
btn.textContent = 'try again';
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// askConfirm is the money gate: an upgrade or repair debits euros on the game
|
||||
// box, so show the cost and the resulting balance before anything is queued.
|
||||
// Placing it here in the DOM (rather than a native dialog) keeps the page's look
|
||||
// and never blocks the browser event loop.
|
||||
function askConfirm(btn) {
|
||||
var card = btn.closest('.slot-card') || btn.parentElement;
|
||||
var existing = card.querySelector('.equip-confirm');
|
||||
if (existing) existing.remove();
|
||||
var cost = parseFloat(btn.getAttribute('data-cost') || '0');
|
||||
var action = btn.getAttribute('data-action');
|
||||
var name = btn.getAttribute('data-item-name') || 'this slot';
|
||||
var head = action === 'upgrade' ? ('Upgrade to ' + name) : ('Repair ' + name);
|
||||
|
||||
var boxEl = document.createElement('div');
|
||||
boxEl.className = 'equip-confirm mt-2 rounded-xl bg-[color:var(--ink)]/5 p-2.5 text-xs';
|
||||
var p = document.createElement('p');
|
||||
p.className = 'text-[color:var(--ink)]/70';
|
||||
p.textContent = head + ' for €' + euroFmt(cost) + '? Balance €' + euroFmt(balance) + ' → €' + euroFmt(balance - cost) + '.';
|
||||
var row = document.createElement('div');
|
||||
row.className = 'mt-2 flex gap-1.5';
|
||||
var yes = document.createElement('button');
|
||||
yes.type = 'button';
|
||||
yes.className = 'rounded-full bg-theme-adventure text-white px-2.5 py-0.5 font-semibold';
|
||||
yes.textContent = 'Confirm · €' + euroFmt(cost);
|
||||
yes.addEventListener('click', function () { boxEl.remove(); placeOrder(btn); });
|
||||
var no = document.createElement('button');
|
||||
no.type = 'button';
|
||||
no.className = 'rounded-full border border-[color:var(--ink)]/20 text-[color:var(--ink)]/60 px-2.5 py-0.5';
|
||||
no.textContent = 'Cancel';
|
||||
no.addEventListener('click', function () { boxEl.remove(); });
|
||||
row.appendChild(yes);
|
||||
row.appendChild(no);
|
||||
boxEl.appendChild(p);
|
||||
boxEl.appendChild(row);
|
||||
card.appendChild(boxEl);
|
||||
}
|
||||
|
||||
function onClick(e) {
|
||||
var btn = e.target.closest('.equip-btn');
|
||||
if (!btn || btn.disabled) return;
|
||||
var action = btn.getAttribute('data-action');
|
||||
if (action === 'upgrade' || action === 'repair') {
|
||||
askConfirm(btn); // money moves — confirm cost + balance first
|
||||
return;
|
||||
}
|
||||
placeOrder(btn);
|
||||
}
|
||||
|
||||
panels.forEach(function (p) { p.addEventListener('click', onClick); });
|
||||
|
||||
loadOrders();
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user