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>
120 lines
4.2 KiB
TypeScript
120 lines
4.2 KiB
TypeScript
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');
|
||
},
|
||
};
|
||
}
|