Files
Pete/internal/web/static/js/adventure-actions.js
T
prosolis b07abc1d13 adventure: let a player back out from the web, not only in Matrix
Three verbs to match gogobee's: call off an expedition, turn back out of
somebody else's party, send the pet sitter home. Which one this page
offers is derived here rather than pushed — leadership is already legible
in the party seats and the sitter's standing is already in the babysit
offer, so nothing new crosses the wire.

Two things running it turned up that no test would have. An applied
abandon left "Pull out of the run" sitting under a verdict saying the
expedition was over, so an applied verb now also hides the other verbs it
just made untrue. And a party member was being offered that same button
in the first place, beside the one that actually works — Pete knows from
the seat it just read that gogobee would refuse it, so it is withheld.

Also: heal the Matrix handle onto push rows stored before the column
existed, on its own endpoint rather than through the subscribe upsert,
which resets both watermarks and would have silenced the digest for
anybody who reads the site regularly. And stack the board row below sm —
four flex columns that wrapped to six lines on a phone, pre-existing.
2026-07-24 21:42:59 -07:00

315 lines
15 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, 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();
})();