adventure: let a player act from the web, not just read about it

The equip queue proved the reverse pipe works. This gives it verbs that
play the game: pull out of a run from the adventurer page, take today's
bout from the war room.

Its own table and its own poll, not more actions on equip_orders. Every
column of that table is equip vocabulary (item, slot, tier) and these
verbs act on the character rather than on something it is carrying.

Nothing in a request names an adventurer. The session maps to one
localpart and a localpart to one adventurer, so Pete resolves the
character itself and there is no id on the wire to forge.

The panel's copy is kept honest by the verdict: an applied action hides
the offer it has just spent, a refusal puts the button back. Watching it
run is what put that there, along with the strip's layout — gogobee
answers a bout with a whole sentence of damage, which the equip strip's
two-column row squeezed into a column and wrapped the verb.

Claude-Session: https://claude.ai/code/session_012bxpQQJDjC1mTtLN3VVtBQ
This commit is contained in:
prosolis
2026-07-24 18:55:42 -07:00
parent b19ab5eff0
commit 6b0aae9f4a
10 changed files with 1178 additions and 9 deletions
+178
View File
@@ -0,0 +1,178 @@
// The action queue, owner side — the first buttons on this site that play the
// game rather than read it.
//
// Same honesty rule as the equip queue: clicking records an intent, and the game
// box acts on its next poll. So nothing here ever says "done" on its own. It says
// "asked for", then shows whatever verdict gogobee filed, including a refusal.
//
// Shared by the adventurer page (pull out of a run) and the war room (take
// today's bout), which is why it lives in a file rather than inline in either.
(function () {
var panels = Array.prototype.slice.call(document.querySelectorAll('.adv-actions'));
if (!panels.length) return; // not an owner, or not a page with actions
var list = document.getElementById('adv-action-orders');
var box = document.getElementById('adv-action-orders-box');
// How each terminal status reads. gogobee's own detail line is preferred when
// it sent one — it names the zone, the day, the damage — and these are the
// fallback for a verdict that arrived without prose.
var STATUS = {
pending: 'asked for…',
applied: 'done',
rejected_not_running: "couldn't, you weren't on an expedition",
rejected_not_leader: "couldn't, only the party leader can call it",
rejected_no_siege: "couldn't, no Siege is camped outside town",
rejected_already_fought: "couldn't, today's bout is already spent",
rejected_unavailable: "couldn't right now"
};
// syncOffers keeps the panel's own copy from outliving the truth. Watching it
// run for real is what put this here: after a bout landed, the page went on
// saying "your bout is unspent" above a dead button, under a verdict that said
// the fight was over.
//
// Applied hides the offer, because the thing on offer has happened. A REFUSAL
// puts the button back, and that asymmetry is the point: a refusal is often
// about a stale page, and taking away the retry would leave them nothing to do
// about it.
function syncOffers(orders) {
var newest = {};
orders.forEach(function (o) { if (!(o.action in newest)) newest[o.action] = o; });
Object.keys(newest).forEach(function (action) {
var o = newest[action];
if (o.status === 'pending') return; // still out; leave the button disabled
var btn = document.querySelector('.adv-action-btn[data-action="' + action + '"]');
if (!btn) return;
var offer = btn.closest('[data-offer]') || btn;
if (o.status === 'applied') {
offer.classList.add('hidden');
return;
}
// Both halves of the restore matter, and the second is easy to forget:
// re-enabling a button inside a wrapper this function hid on an earlier
// pass gives back a control nobody can see.
offer.classList.remove('hidden');
btn.disabled = false;
btn.classList.remove('opacity-50');
btn.textContent = btn.getAttribute('data-label') || btn.textContent;
});
}
var VERB = { extract: 'Pull out', siege_join: 'Join the defence' };
var pollTimer = null;
function render(orders) {
if (!list || !box) return;
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;
// Stacked, not the equip strip's justify-between row. That layout is right
// for a two-word verdict and wrong here: gogobee answers a bout with a
// whole sentence of damage numbers, which squeezed into a right-hand column
// and pushed the verb itself onto two lines.
var li = document.createElement('li');
var verb = document.createElement('div');
verb.className = 'font-semibold text-[color:var(--ink)]/70';
verb.textContent = VERB[o.action] || o.action;
var said = document.createElement('div');
said.className = 'mt-0.5 leading-snug ' + (o.status === 'pending'
? 'text-[color:var(--ink)]/45'
: (o.status === 'applied' ? 'text-theme-adventure font-semibold' : 'text-[color:var(--warn)]'));
said.textContent = o.detail || STATUS[o.status] || o.status;
li.appendChild(verb); li.appendChild(said);
list.appendChild(li);
});
syncOffers(orders);
// Keep refreshing while anything is unanswered so the verdict lands without a
// reload; stop once everything is terminal. A Siege bout runs a whole combat
// on the game box, so this can legitimately sit on "asked for" for a while.
if (anyPending && !pollTimer) {
pollTimer = setInterval(loadOrders, 10000);
} else if (!anyPending && pollTimer) {
clearInterval(pollTimer); pollTimer = null;
}
}
function loadOrders() {
fetch('/api/adventure/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 */ });
}
function placeOrder(btn) {
btn.disabled = true;
btn.classList.add('opacity-50');
var was = btn.textContent;
btn.textContent = 'asking…';
fetch('/api/adventure/order', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: btn.getAttribute('data-action') })
})
.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';
setTimeout(function () { btn.textContent = was; }, 4000);
return;
}
btn.textContent = 'asked for';
loadOrders();
})
.catch(function () {
btn.disabled = false;
btn.classList.remove('opacity-50');
btn.textContent = 'try again';
setTimeout(function () { btn.textContent = was; }, 4000);
});
}
// Both verbs are one-way — an extraction ends the run for the whole party and a
// bout is the only one you get today — so both confirm. Built in the DOM rather
// than with confirm(), which would block the event loop and, on this site's own
// evidence, wedge an automated browser.
function askConfirm(btn) {
var panel = btn.closest('.adv-actions') || btn.parentElement;
var existing = panel.querySelector('.adv-action-confirm');
if (existing) existing.remove();
var boxEl = document.createElement('div');
boxEl.className = 'adv-action-confirm mt-3 rounded-xl bg-[color:var(--ink)]/5 p-3 text-sm';
var p = document.createElement('p');
p.className = 'text-[color:var(--ink)]/70';
p.textContent = btn.getAttribute('data-confirm') || 'Are you sure?';
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-3 py-1 font-semibold';
yes.textContent = btn.getAttribute('data-confirm-label') || 'Yes, do it';
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-3 py-1';
no.textContent = 'Not yet';
no.addEventListener('click', function () { boxEl.remove(); });
row.appendChild(yes); row.appendChild(no);
boxEl.appendChild(p); boxEl.appendChild(row);
panel.appendChild(boxEl);
}
panels.forEach(function (panel) {
panel.addEventListener('click', function (e) {
var btn = e.target.closest('.adv-action-btn');
if (!btn || btn.disabled) return;
askConfirm(btn);
});
});
loadOrders();
})();