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