Files
Pete/internal/web/static/js/adventure-actions.js
T
prosolis a15d67e210 web: the muted text that was never muted, and the stylesheet fetched every time
Every text-[color:var(--ink)]/50, border-[color:var(--ink)]/10 and
bg-[color:var(--ink)]/5 in the templates compiled to nothing. Tailwind v3
cannot put an opacity on an arbitrary var() colour, so all 538 of them were
dropped from output.css without a word. Meta text rendered at full ink, card
borders fell back to Tailwind's default grey (bright white rings at night),
and the washes, backdrops and hover pills simply weren't there, on the news
site, the casino and the adventure pages alike.

The palette is now written once per phase and casino room as bare rgb
channels (--ink-rgb and friends), the plain vars are built from them, and
tailwind.config.js exposes ink, muted, accent, warn and card as real colours.
The classes are text-ink/80, border-ink/10, bg-accent/20 now, and they compile.

Restoring the intended opacities as-is would have traded flat for illegible:
ink at 50% measures 2.7 to 2.9:1 on the lit phases. So secondary text gets
its own colour, --muted, per phase and per casino room, taken as faint as it
can go while still clearing 4.5:1 on both the card and the page. Everything
that was ink at 35 to 70% is text-muted. The 20-30% ones are separators and
empty-cell dashes and stay faint.

The bg-theme-* chips carry white text everywhere they appear, and eight of
them measured 2.5 to 3.2:1. Same hues, taken just dark enough to pass (4.6 to
4.7:1). eu, lego and adventure already passed and are unchanged, and the
underline and border colours keep the old pastels since they carry no text.

Static files came out of the embedded FS with no Last-Modified, no ETag and
no Cache-Control, so every page view downloaded the stylesheet and every
script in full, and the service worker's "cheap 304" never happened. Each
file is hashed once now. Templates link through {{asset}}, which appends
?v=<hash>; a request naming the current hash is served immutable for a year,
and everything else gets the hash as its ETag and revalidates to a 304. The
service worker serves versioned URLs cache-first, drops older versions of the
same file as new ones land, and moves to cache v6.
2026-09-22 22:54:50 -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-muted';
verb.textContent = VERB[o.action] || o.action;
var said = document.createElement('div');
said.className = 'mt-0.5 leading-snug ' + (o.status === 'pending'
? 'text-muted'
: (o.status === 'applied' ? 'text-theme-adventure font-semibold' : 'text-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-ink/5 p-3 text-sm';
var p = document.createElement('p');
p.className = 'text-muted';
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-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-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-ink/20 text-muted 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();
})();