Phase 2: bases, quest board, and known-vs-new targets

Four allied bases, placed by greedy farthest-point so no choke point can strand
the player, with the first at the spawn junction so the loop is available at
once. Missions hand in at any base, not the issuing one.

The board offers known and new targets side by side and describes each route in
words. Crucially it never reads heat directly — it reads sim/intel.ts, a record
of what the player has actually observed and when. Notes age while roads keep
escalating unwatched, so a known route is knowable, stale, and never a promise.
Recon missions survey everything within 130m, which is how a new target becomes
a known one without driving every road there.

New targets pay 1.8x. That multiplier is the phase's tuning dial: too low and
nobody takes the unknown, too high and nobody takes the known.

Payment is parts, which repair the car. To keep "decline, not reset" intact,
each subsystem gains a ceiling that falls permanently with damage — repairs
restore up to what the car is still capable of, never to what it was. Without an
economy the board's rewards had no stakes; with one, the risky job is what keeps
the car running a while longer.

Adds Dijkstra over the road graph, taking a cost function so a "safest route"
preview is a small change later.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
dejvino
2026-08-07 10:06:32 +02:00
co-authored by Claude Opus 5
parent 475db8773c
commit 73e8ac68f1
14 changed files with 1255 additions and 70 deletions
+87
View File
@@ -0,0 +1,87 @@
import type { Base } from '../sim/bases';
import type { HeatLevel } from '../sim/heat';
import type { Offer } from '../sim/quests';
/**
* 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>
${offer.type === 'recon' ? 'Survey' : 'Supply run'} —
${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">${describe(offer)}</div>
</li>`,
)
.join('')}</ol>
<footer>press 1–${offers.length} to accept</footer>`;
},
hide() {
el.classList.remove('open');
},
get isOpen() {
return el.classList.contains('open');
},
};
}
+60 -21
View File
@@ -1,20 +1,52 @@
import type { CarCondition } from '../sim/car';
import { SUBSYSTEMS, type CarCondition } from '../sim/car';
import type { HeatLevel } from '../sim/heat';
import type { MissionType } from '../sim/quests';
import { WORK_SECONDS } from '../sim/quests';
const BAR_WIDTH = 12;
function bar(value: number): string {
/**
* Draws a bar with a notch for the ceiling: the fillable part is what repairs
* can still reach, and the gap past it is gone for good.
*/
function bar(value: number, ceiling = 1): string {
const filled = Math.round(value * BAR_WIDTH);
return '█'.repeat(filled) + '·'.repeat(BAR_WIDTH - filled);
const reachable = Math.round(ceiling * BAR_WIDTH);
return (
'█'.repeat(filled) + '·'.repeat(Math.max(0, reachable - filled)) + '×'.repeat(BAR_WIDTH - reachable)
);
}
export interface HeatReadout {
segmentId: number | null;
/** False while on the verge — still counts as using the road. */
onTarmac: boolean;
value: number;
level: HeatLevel | null;
hottest: number;
export interface HudModel {
heat: {
segmentId: number | null;
/** False while on the verge — still counts as using the road. */
onTarmac: boolean;
value: number;
level: HeatLevel | null;
};
quest: {
type: MissionType;
stage: 'travel' | 'working' | 'return';
novel: boolean;
worked: number;
distance: number;
} | null;
completed: number;
parts: number;
notice: string;
}
function questLines(quest: NonNullable<HudModel['quest']>): string[] {
const label = quest.type === 'recon' ? 'Survey' : 'Supply run';
const kind = quest.novel ? 'new target' : 'known target';
if (quest.stage === 'return') {
return [`${label} done — return to any base`];
}
if (quest.stage === 'working') {
return [`${label} — working ${quest.worked.toFixed(1)}/${WORK_SECONDS}s (hold still)`];
}
return [`${label} (${kind}) — ${(quest.distance / 1000).toFixed(2)} km to target`];
}
export function createHud(seed: number) {
@@ -22,30 +54,37 @@ export function createHud(seed: number) {
let last = 0;
return {
update(speedMs: number, condition: CarCondition, now: number, heat: HeatReadout) {
update(speedMs: number, condition: CarCondition, now: number, model: HudModel) {
// The HUD is debug scaffolding, not the real interface — 10 Hz is plenty.
if (now - last < 0.1) return;
last = now;
el.textContent = [
const lines = [
`${Math.abs(speedMs * 3.6).toFixed(0).padStart(3)} km/h`,
'',
`engine ${bar(condition.engine)} ${(condition.engine * 100).toFixed(0)}%`,
`tires ${bar(condition.tires)} ${(condition.tires * 100).toFixed(0)}%`,
`chassis ${bar(condition.chassis)} ${(condition.chassis * 100).toFixed(0)}%`,
...SUBSYSTEMS.map(
(part) =>
`${part.padEnd(7)} ${bar(condition.level[part], condition.ceiling[part])} ` +
`${(condition.level[part] * 100).toFixed(0)}%`,
),
`parts ${model.parts.toFixed(2)} runs ${model.completed}`,
'',
...(model.quest ? questLines(model.quest) : ['no mission — find a base']),
'',
// Debug only. The real game signals heat through the road itself —
// see the design brief: no numeric meter ships.
`[debug] road ${
heat.segmentId === null
`[debug] road ${
model.heat.segmentId === null
? 'off-route'
: `#${heat.segmentId} ${heat.onTarmac ? '(on road)' : '(alongside)'}`
: `#${model.heat.segmentId} ${model.heat.onTarmac ? '(on road)' : '(alongside)'}`
}`,
`[debug] heat ${bar(heat.value)} ${heat.level ?? '—'}`,
`[debug] hottest ${bar(heat.hottest)}`,
`[debug] heat ${bar(model.heat.value)} ${model.heat.level ?? '—'}`,
'',
`seed ${seed}`,
'WASD drive · space handbrake · R respawn',
].join('\n');
];
if (model.notice) lines.push('', `» ${model.notice}`);
el.textContent = lines.join('\n');
},
};
}