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
+69 -18
View File
@@ -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) {
+115
View File
@@ -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
View File
@@ -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();
})();