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:
@@ -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();
|
||||
})();
|
||||
Reference in New Issue
Block a user