adventure: let a player leave town from the web, not only read about it

W5a gave the web two verbs that cost nothing. These are the three that take
arguments and spend coins: set out for a zone with a supply loadout, walk back
into the run you extracted from, hire the pet sitter for a week or a month.
Between them they cover the most common thing anybody does in the game, which
until now could only be typed into Matrix.

Arguments are the new surface, so they are the thing to be careful with. Nothing
in a request is trusted: every zone, loadout and duration is looked up in the
offer list gogobee pushed onto that owner's own private row, and the order stores
what was found there rather than what was sent. A forged zone resolves to nothing
and never becomes an order. gogobee then re-resolves all of it anyway, because an
offer is a quote and a quote is not a permission.

The money confirm is the equip panel's, lifted: cost, balance, and the balance it
leaves. When it does not cover, it says so instead of printing a negative.

Verified in a browser rather than only in tests, which is where both real defects
came from: the confirm box was appending to the whole panel and so appeared at
the bottom of the section instead of under the button that raised it, and button
prices printed as EUR45000 above a dialog reading EUR45,000.

Claude-Session: https://claude.ai/code/session_012bxpQQJDjC1mTtLN3VVtBQ
This commit is contained in:
prosolis
2026-07-24 19:47:26 -07:00
parent 6b0aae9f4a
commit 868a29e992
11 changed files with 685 additions and 57 deletions
+120 -25
View File
@@ -5,8 +5,13 @@
// 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.
// 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
@@ -24,7 +29,11 @@
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_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"
};
// syncOffers keeps the panel's own copy from outliving the truth. Watching it
@@ -49,17 +58,58 @@
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.
// 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');
btn.disabled = false;
btn.classList.remove('opacity-50');
btn.textContent = btn.getAttribute('data-label') || btn.textContent;
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' };
var VERB = {
extract: 'Pull out',
siege_join: 'Join the defence',
expedition_start: 'Set out',
expedition_resume: 'Go back in',
babysit: 'Hire the sitter'
};
// 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;
@@ -105,21 +155,38 @@
.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) {
btn.disabled = true;
btn.classList.add('opacity-50');
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({ action: btn.getAttribute('data-action') })
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) {
btn.disabled = false;
btn.classList.remove('opacity-50');
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;
@@ -128,20 +195,26 @@
loadOrders();
})
.catch(function () {
btn.disabled = false;
btn.classList.remove('opacity-50');
group.forEach(function (b) { b.disabled = false; b.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.
// 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) {
var panel = btn.closest('.adv-actions') || btn.parentElement;
var existing = panel.querySelector('.adv-action-confirm');
// 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');
@@ -149,12 +222,33 @@
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';
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.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';
@@ -162,7 +256,8 @@
no.textContent = 'Not yet';
no.addEventListener('click', function () { boxEl.remove(); });
row.appendChild(yes); row.appendChild(no);
boxEl.appendChild(p); boxEl.appendChild(row);
if (p) boxEl.appendChild(p);
boxEl.appendChild(row);
panel.appendChild(boxEl);
}