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