drive-between-the-lines/src/ui/board.ts
dejvino 9e476087d4 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>
2026-08-09 07:14:21 +02:00

120 lines
4.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import type { Base } from '../sim/bases';
import type { HeatLevel } from '../sim/heat';
import { MISSION_SHAPE, type MissionType, type Offer } from '../sim/quests';
/**
* What each job actually asks of you, in the words the people asking would use.
* The point is that the four read as different *risks*, not different nouns.
*/
const BRIEFING: Record<MissionType, string> = {
supply: 'drop the load and come back — quick work at the far end',
retrieval: 'collect and carry it home; it pays for what arrives intact',
intel: 'sit still and transmit, a long minute in the open',
recon: 'eyes on the area — you will come back knowing the roads',
};
/**
* The quest board. Deliberately words, not numbers: the player is reading a
* briefing, not a stat sheet. "Barricaded, four minutes ago" is a decision;
* "heat 0.62" is a spreadsheet.
*/
const LEVEL_WORDS: Record<HeatLevel, string> = {
clear: 'quiet',
patrol: 'patrolled',
barricade: 'barricaded',
turret: 'fortified',
};
function ago(seconds: number): string {
if (seconds < 90) return 'just now';
const minutes = Math.round(seconds / 60);
return `${minutes} min ago`;
}
function describe(offer: Offer): string {
const { worst, unknownCount, stalest } = offer.intel;
const total = offer.route.segments.length;
if (unknownCount === total) return 'no one has been down that way';
if (unknownCount > 0) {
return `${LEVEL_WORDS[worst]} as far as we know · ${unknownCount} of ${total} roads unscouted`;
}
return `${LEVEL_WORDS[worst]}${stalest === null ? '' : ` · last word ${ago(stalest)}`}`;
}
export function createBoard() {
const el = document.createElement('div');
el.id = 'board';
document.body.appendChild(el);
const style = document.createElement('style');
style.textContent = `
#board {
position: fixed; left: 50%; bottom: 36px; transform: translateX(-50%);
width: min(680px, calc(100vw - 32px));
font: 13px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace;
color: #dbe2e8; background: rgba(12,15,19,.92);
border: 1px solid #2f3944; border-radius: 6px; padding: 14px 16px;
display: none;
}
#board.open { display: block; }
#board h2 { font-size: 13px; font-weight: 600; margin: 0 0 10px; color: #8fa3b5; }
#board ol { list-style: none; margin: 0; padding: 0; }
#board li { padding: 7px 0; border-top: 1px solid #202832; }
#board .key { color: #4ea3ff; }
#board .novel { color: #e2b04a; }
#board .meta { color: #7d8b98; }
#board footer { margin-top: 10px; color: #6d7883; }
`;
document.head.appendChild(style);
return {
show(base: Base, offers: Offer[]) {
el.classList.add('open');
el.innerHTML = `
<h2>${base.name} — quest board</h2>
<ol>${offers
.map(
(offer, i) => `
<li>
<span class="key">[${i + 1}]</span>
${MISSION_SHAPE[offer.type].label}
${offer.novel ? '<span class="novel">new target</span>' : 'known target'}
· ${(offer.route.length / 1000).toFixed(1)} km
· ${offer.reward.toFixed(2)} parts
<div class="meta">${BRIEFING[offer.type]}</div>
<div class="meta">${describe(offer)}</div>
</li>`,
)
.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');
},
get isOpen() {
return el.classList.contains('open');
},
};
}