adventure: let an owner equip and unequip from their own page

The one adventure ask that carries intent back to the game box, built the
mischief way: no new network route, Pete records the equip/unequip and gogobee
polls it and files a verdict. The who page grows Equip / Take off buttons on the
owner's own worn and backpack panels, and a pending-changes strip that shows
'queued' until the verdict lands, never claiming a change it can't see.

The item handle is the inventory row id, sent only on wearable magic items, so a
non-zero id is also what gates the button. gogobee resolves the owner by
localpart, never a name.
This commit is contained in:
prosolis
2026-07-17 08:44:28 -07:00
parent e90deda498
commit 1589c36e96
10 changed files with 999 additions and 7 deletions

View File

@@ -20,6 +20,12 @@
{{end}}
{{if .Desc}}<p class="text-xs text-[color:var(--ink)]/55 mt-1 leading-snug">{{.Desc}}</p>{{end}}
{{if .Effect}}<p class="text-xs text-theme-adventure/80 mt-0.5">{{.Effect}}</p>{{end}}
{{if .EquipAction}}
<div class="mt-1.5">
<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="{{.EquipAction}}" data-item-id="{{.ID}}" data-slot="{{.Slot}}" data-item-name="{{.Name}}">{{if eq .EquipAction "equip"}}Equip{{else}}Take off{{end}}</button>
</div>
{{end}}
</li>
{{end}}
@@ -274,7 +280,7 @@
{{end}}
</div>
<div class="rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 p-6 shadow-pete">
<div id="gear-panel" data-token="{{.Mark.Token}}" class="rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 p-6 shadow-pete">
{{if .Worn}}
<h2 class="font-display text-xl font-bold mb-1">Worn</h2>
<p class="text-xs text-[color:var(--ink)]/45 mb-3">{{.BondsUsed}} of 3 bonds in use</p>
@@ -299,6 +305,14 @@
{{range .VaultRows}}{{template "itemrow" .}}{{end}}
</ul>
{{end}}
<!-- Changes you've asked for. The queue is honest: an equip lands on the
game box's next poll, so a fresh order reads "queued", never "done".
JS fills this from /api/equip/orders. -->
<div id="equip-orders" class="mt-6 hidden">
<h3 class="font-display text-lg font-bold mb-2">Pending changes</h3>
<ul id="equip-orders-list" class="space-y-1.5 text-xs"></ul>
</div>
</div>
</div>
</section>
@@ -363,5 +377,102 @@
}
timer = setInterval(refresh, 60000);
})();
// The equip queue, owner side. Clicking Equip / Take off records the intent; the
// 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');
var box = document.getElementById('equip-orders');
var list = document.getElementById('equip-orders-list');
// 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.
var STATUS = {
pending: 'queued',
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"
};
var pollTimer = null;
function render(orders) {
list.innerHTML = '';
if (!orders || !orders.length) { box.classList.add('hidden'); return; }
box.classList.remove('hidden');
var anyPending = false;
orders.forEach(function (o) {
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 left = document.createElement('span');
left.className = 'flex-1';
left.textContent = verb + ' ' + (o.item_name || o.slot || 'item');
var right = document.createElement('span');
right.className = 'shrink-0 ' + (o.status === 'pending'
? 'text-[color:var(--ink)]/45'
: (o.status === 'applied' ? 'text-theme-adventure font-semibold' : 'text-[color:var(--warn)]'));
right.textContent = o.detail || STATUS[o.status] || o.status;
li.appendChild(left); li.appendChild(right);
list.appendChild(li);
});
// While anything is still queued, keep refreshing so the verdict lands without
// a reload; stop once everything is terminal.
if (anyPending && !pollTimer) {
pollTimer = setInterval(loadOrders, 15000);
} else if (!anyPending && pollTimer) {
clearInterval(pollTimer); pollTimer = null;
}
}
function loadOrders() {
fetch('/api/equip/orders', { headers: { 'Accept': 'application/json' } })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (o) { if (o) render(o); })
.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;
btn.disabled = true;
btn.classList.add('opacity-50');
btn.textContent = 'queuing…';
fetch('/api/equip/order', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
token: token,
action: btn.getAttribute('data-action'),
item_id: parseInt(btn.getAttribute('data-item-id') || '0', 10),
slot: btn.getAttribute('data-slot') || ''
})
})
.then(function (r) { return r.json().then(function (j) { return { ok: r.ok, body: j }; }); })
.then(function (res) {
if (!res.ok) {
btn.disabled = false;
btn.classList.remove('opacity-50');
btn.textContent = (res.body && res.body.error) || 'try again';
return;
}
btn.textContent = 'queued';
loadOrders();
})
.catch(function () {
btn.disabled = false;
btn.classList.remove('opacity-50');
btn.textContent = 'try again';
});
});
loadOrders();
})();
</script>
{{end}}