// 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, go back in, set out, hire the sitter) // and the war room (take today's bout), which is why it lives in a file rather // than inline in either. // // Three of the verbs spend euros, so those confirm the cost and the resulting // balance first — the equip panel's rule, and for the same reason: a button that // quietly moves money is a button people stop pressing. (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", rejected_busy: "couldn't, you're already out there", rejected_insufficient_funds: "couldn't cover it", rejected_zone_locked: "couldn't, that zone isn't open to you", rejected_nothing_to_resume: "couldn't, there's nothing waiting for you", rejected_is_leader: "couldn't, you're the one leading it", rejected_nothing_to_cancel: "couldn't, no sitter is engaged" }; // 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. // INVALIDATES is what an applied verb makes untrue about the OTHER verbs on // the page. Hiding only the offer that was taken is not enough, and watching it // run is what showed why: after "Call the whole thing off" landed, the panel // went on offering "Pull out of the run" — directly under a verdict saying the // expedition had been abandoned. That is the same lie W5a fixed for the bout, // in a new place. // // The one asymmetry worth keeping: an applied EXTRACT does not hide the // abandon. An extracted run is still the owner's to close — that is exactly // what the abandon verb is for from town — so taking the button away there // would remove the next thing they might legitimately want. var INVALIDATES = { expedition_abandon: ['extract', 'expedition_leave'], expedition_leave: ['extract', 'expedition_abandon'], extract: ['expedition_leave'] }; function hideOffer(action) { var btn = document.querySelector('.adv-action-btn[data-action="' + action + '"]'); if (!btn) return; (btn.closest('[data-offer]') || btn).classList.add('hidden'); } 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'); (INVALIDATES[action] || []).forEach(hideOffer); return; } // All three halves of the restore matter, and each is easy to forget. // Un-hiding the wrapper: re-enabling a button inside a wrapper an earlier // pass hid gives back a control nobody can see. Restoring the LABEL: the // clicked button still says "asked for". And doing it to every button in // the offer, not just the one whose action matched — placeOrder disables // the whole group (three loadouts, or the sitter's two durations), so // restoring one would leave the rest greyed out for good. offer.classList.remove('hidden'); var group = offer.querySelectorAll('.adv-action-btn[data-action="' + action + '"]'); Array.prototype.forEach.call(group, function (b) { b.disabled = false; b.classList.remove('opacity-50'); b.textContent = b.getAttribute('data-label') || b.textContent; }); }); } var VERB = { extract: 'Pull out', siege_join: 'Join the defence', expedition_start: 'Set out', expedition_resume: 'Go back in', babysit: 'Hire the sitter', expedition_abandon: 'Call it off', expedition_leave: 'Turn back', babysit_cancel: 'Send the sitter home' }; // The owner's euro balance as of the render, for the money confirms. Absent on // the war room, whose one verb is free — euroFmt(NaN) never runs there because // no button on that page carries a data-cost. var panelBalance = (function () { var el = document.querySelector('.adv-actions[data-balance]'); return el ? parseFloat(el.getAttribute('data-balance') || '0') : 0; })(); function euroFmt(n) { return (Math.round(n * 100) / 100).toLocaleString(undefined, { maximumFractionDigits: 2 }); } // The zone picker shows one loadout row at a time: prices are per tier, so each // zone gets its own server-rendered row and this only swaps which is visible. // Pete never reprices anything in the browser. (function initZonePicker() { var pick = document.getElementById('adv-zone-pick'); if (!pick) return; var groups = Array.prototype.slice.call(document.querySelectorAll('.adv-zone-loadouts')); function show() { groups.forEach(function (g) { g.classList.toggle('hidden', g.getAttribute('data-zone') !== pick.value); }); } pick.addEventListener('change', show); show(); })(); 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 */ }); } // siblings are every button in the same offer — the three loadouts of a zone, // the sitter's week and month. All of them are disabled together, because the // game allows one outstanding order per verb and a second click would be // refused with "already asked", which reads as a broken button rather than as // the guard it is. function siblings(btn) { var offer = btn.closest('[data-offer]'); if (!offer) return [btn]; return Array.prototype.slice.call(offer.querySelectorAll('.adv-action-btn')); } function placeOrder(btn) { var group = siblings(btn); group.forEach(function (b) { b.disabled = true; b.classList.add('opacity-50'); }); var was = btn.textContent; btn.textContent = 'asking…'; var body = { action: btn.getAttribute('data-action') }; // Only the verbs that take arguments send any. An attribute that is not on // the button is simply absent from the body, which is what extract and // siege_join mean. if (btn.hasAttribute('data-zone')) body.zone = btn.getAttribute('data-zone'); if (btn.hasAttribute('data-loadout')) body.loadout = btn.getAttribute('data-loadout'); if (btn.hasAttribute('data-days')) body.days = parseInt(btn.getAttribute('data-days'), 10); fetch('/api/adventure/order', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }) .then(function (r) { return r.json().then(function (j) { return { ok: r.ok, body: j }; }); }) .then(function (res) { if (!res.ok) { group.forEach(function (b) { b.disabled = false; b.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 () { group.forEach(function (b) { b.disabled = false; b.classList.remove('opacity-50'); }); btn.textContent = 'try again'; setTimeout(function () { btn.textContent = was; }, 4000); }); } // Every verb confirms. The two free ones are one-way (an extraction ends the // run for the whole party; a bout is the only one you get today) and the three // W5b ones spend euros, so those also print the cost and the balance it leaves // — the equip panel's money gate, lifted. // // 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) { // Anchor the confirm to the OFFER, not to the panel. With one offer on the // page those were the same element; with four they are not, and appending to // the panel put the "set out?" box at the bottom of the section, under the // sitter, detached from the button that raised it. var panel = btn.closest('[data-offer]') || btn.closest('.adv-actions') || btn.parentElement; var existing = document.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 cost = parseFloat(btn.getAttribute('data-cost') || '0'); if (cost > 0) { var money = document.createElement('p'); money.className = 'mt-1.5 font-semibold text-[color:var(--ink)]/80'; if (panelBalance - cost < 0) { // No arrow when it does not cover. The game can carry a small debt, so // the resulting figure is not always nonsense — but printing "€-6,300" // next to "that won't cover it" is one number too many, and the minus // lands on the wrong side of the sign. money.textContent = '€' + euroFmt(cost) + " — you have €" + euroFmt(panelBalance) + ". That won't cover it."; money.className += ' text-[color:var(--warn)]'; } else { money.textContent = '€' + euroFmt(cost) + ' — balance €' + euroFmt(panelBalance) + ' → €' + euroFmt(panelBalance - cost) + '.'; } boxEl.appendChild(p); boxEl.appendChild(money); p = null; // already placed; the tail below appends whatever is left } var row = document.createElement('div'); row.className = 'mt-2 flex gap-1.5'; var yes = document.createElement('button'); yes.type = 'button'; // A destructive verb gets the red the mischief storefront already uses for // "this is the one that does something to somebody". "Call the whole thing // off" throws away a whole party's day and was raising a confirm identical to // the one for hiring a pet sitter — the two most different decisions on the // page, in the same purple. // // Red rather than the button's own --warn, and that is not a style // preference: --warn is a dark amber in every light theme and a LIGHT amber // in the dark one (it is the only dark card), so white on it is unreadable in // exactly the theme this was first tried in. Seen, not reasoned about. yes.className = btn.getAttribute('data-confirm-tone') === 'warn' ? 'rounded-full bg-red-500 text-white px-3 py-1 font-semibold' : 'rounded-full bg-theme-adventure text-white px-3 py-1 font-semibold'; yes.textContent = (btn.getAttribute('data-confirm-label') || 'Yes, do it') + (cost > 0 ? ' · €' + euroFmt(cost) : ''); 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); if (p) 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(); })();