A way out of a mission that is not wiping the campaign

state.active was only ever cleared by completing a job. A target can
end up behind concrete there is no way round, and the only exit was
holding R for five seconds - which does not drop the job, it destroys
every mile of wear, every road the enemy learned and the whole map you
built.

Park at any base with a job you have not started bringing home and the
board offers to take it off you for X. It costs 12m of front line, half
what dying costs: deciding a route is not worth it is a judgement the
game should let you make, and it cannot be free or the board becomes a
reroll button. Not offered on the return leg, since that arrival is a
hand-in and it is about to pay you.

Dropped jobs are counted apart from completed ones. They are not runs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
dejvino 2026-08-09 07:14:21 +02:00
parent 2e0373fb27
commit 9e476087d4
5 changed files with 67 additions and 1 deletions

View File

@ -9,6 +9,8 @@ export interface DriverInput {
select: number | null;
/** True on the frame M was pressed. Consumed on read. */
toggleMute: boolean;
/** True on the frame X was pressed: give up the job in hand. Consumed on read. */
abandon: boolean;
}
const KEYS = {
@ -19,6 +21,7 @@ const KEYS = {
handbrake: ['Space'],
respawn: ['KeyR'],
mute: ['KeyM'],
abandon: ['KeyX'],
} as const;
const SELECT_KEYS = ['Digit1', 'Digit2', 'Digit3', 'Digit4'];
@ -34,6 +37,7 @@ export function createInput(): {
// landed between two fixed steps, and must not fire twice if it spans three.
let pendingSelect: number | null = null;
let pendingMute = false;
let pendingAbandon = false;
/** Called on the first real interaction, to satisfy autoplay policy. */
let onFirstGesture: (() => void) | null = null;
@ -44,6 +48,7 @@ export function createInput(): {
}
if (!down.has(e.code)) {
if (e.code === 'KeyM') pendingMute = true;
if (e.code === 'KeyX') pendingAbandon = true;
const index = SELECT_KEYS.indexOf(e.code);
if (index >= 0) pendingSelect = index + 1;
}
@ -57,6 +62,7 @@ export function createInput(): {
down.clear();
pendingSelect = null;
pendingMute = false;
pendingAbandon = false;
};
window.addEventListener('keydown', onDown);
@ -67,8 +73,10 @@ export function createInput(): {
read: () => {
const select = pendingSelect;
const toggleMute = pendingMute;
const abandon = pendingAbandon;
pendingSelect = null;
pendingMute = false;
pendingAbandon = false;
return {
throttle: (held(KEYS.forward) ? 1 : 0) - (held(KEYS.back) ? 1 : 0),
steer: (held(KEYS.left) ? 1 : 0) - (held(KEYS.right) ? 1 : 0),
@ -76,6 +84,7 @@ export function createInput(): {
respawn: held(KEYS.respawn),
select,
toggleMute,
abandon,
};
},

View File

@ -61,6 +61,14 @@ export const DEBUG = new URLSearchParams(location.search).has('debug');
const SIGHT_RADIUS = 85;
/** Seconds R must be held down to wipe the save and start a fresh campaign. */
const RESET_HOLD_SECONDS = 5;
/**
* Metres of front line given up for walking away from a job.
*
* Half what dying costs (`KIA_LAND_LOSS`). Deciding a route is not worth it is
* a judgement the game should let you make, but it cannot be free, or the board
* becomes a reroll button: park, dislike the route, drop it, take another.
*/
const ABANDON_LAND_LOSS = 12;
function resolveSeed(): number {
const raw = new URLSearchParams(location.search).get('seed');
@ -480,6 +488,33 @@ async function boot() {
// Checkpoint: taking a job is a decision worth not having to make twice.
persistence.checkpoint(elapsed, snapshot());
}
} else if (base && stopped && quests.active && quests.active.stage !== 'return') {
// Parked somewhere friendly with a job you have not started bringing
// home. There has to be a way out of one: a target can end up behind
// concrete there is no way round, and the only exit used to be holding
// R for five seconds, which does not drop the job — it destroys the
// whole campaign. Not offered on the return leg, because that arrival
// is a hand-in and stepQuest is about to pay you for it.
if (openBase !== base.nodeId) {
openBase = base.nodeId;
board.showActive(base, MISSION_SHAPE[quests.active.type].label.toLowerCase());
audio.ui('open');
}
if (cmd.abandon) {
const dropped = failQuest(quests);
// Walking away is cheaper than dying, and it still costs the war
// something. Otherwise the board is a free reroll: park, dislike the
// route, drop it, take another.
applyMissionImpact(front, -ABANDON_LAND_LOSS);
board.hide();
openBase = null;
audio.ui('open');
say(
`${MISSION_SHAPE[dropped!.type].label} dropped. That is ground somebody else has to take back.`,
6,
);
persistence.checkpoint(elapsed, snapshot());
}
} else if (openBase !== null) {
openBase = null;
board.hide();

View File

@ -20,6 +20,7 @@ const IDLE: DriverInput = {
respawn: false,
select: null,
toggleMute: false,
abandon: false,
};
/** Rapier runs headless, so vehicle tuning is checkable without a browser. */

View File

@ -89,6 +89,26 @@ export function createBoard() {
.join('')}</ol>
<footer>press 1${offers.length} to accept</footer>`;
},
/**
* What a base has to say to somebody who turns up still carrying a job.
*
* There has to be a way out of a mission. A target can end up behind
* concrete you cannot get round, and until now the only exit was holding R
* for five seconds which does not drop the job, it destroys the campaign.
*/
showActive(base: Base, label: string) {
el.classList.add('open');
el.innerHTML = `
<h2>${base.name}</h2>
<ol><li>
You are still carrying: ${label}.
<div class="meta">Nobody here will take it off your hands, and nobody
here will hold the board open while you decide.</div>
</li></ol>
<footer><span class="key">[X]</span> give it up we lose ground for it</footer>`;
},
hide() {
el.classList.remove('open');
},

View File

@ -153,7 +153,8 @@ export function createHud(seed: number) {
`[debug] area ${bar(model.heat.area)}`,
'',
`seed ${seed}`,
`WASD drive · space handbrake · R respawn · M sound ${model.muted ? 'off' : 'on'}`,
`WASD drive · space handbrake · R respawn · X drop job at a base` +
` · M sound ${model.muted ? 'off' : 'on'}`,
'hold R to reset the campaign',
];
if (model.resetProgress > 0.06) {