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
179 lines
7.7 KiB
JavaScript
179 lines
7.7 KiB
JavaScript
// 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();
|
|
})();
|