diff --git a/README.md b/README.md index 549083e..3f5cb3c 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,7 @@ Endless driving survival game. - **Phase 0** — a drivable car with persistent wear that is felt through the wheel. - **Phase 1** — a road network whose roads remember being driven, and escalate. +- **Phase 2** — bases, a quest board, and known-vs-new target selection. ## Running @@ -20,6 +21,10 @@ seeds may be numbers or words. | `A` / `D` | steer | | `Space` | handbrake (rear wheels only) | | `R` | respawn the car — **does not** repair it | +| `1`–`3` | accept a mission, when parked at a base | + +Drive to a green beacon (a base) and stop. Take a job, drive to the amber +beacon, stop and hold position for three seconds, then return to **any** base. ```bash npm test # sim + headless physics @@ -78,6 +83,45 @@ The road network is a jittered grid with roughly a quarter of its edges removed, keeping the whole thing connected. The loops are the point: "take a different road this time" is not a decision unless alternative routes exist. +## Missions and intel + +The board at each base offers a mix of **known targets** and **new** ones, and +describes the route to each in words rather than numbers — "barricaded · last +word 6 min ago", "no one has been down that way". + +The important part is *where those words come from*. The board never reads heat +directly. It reads `sim/intel.ts`, which is a snapshot of what the player has +actually observed and when. Drive a road and you write down what was on it; +leave it alone and your note ages while the road keeps escalating without you. +So a known route is genuinely knowable, genuinely stale, and never a guarantee — +which is the tension the brief asks for. + +**Recon missions** are the tool for converting a new target into a known one: +completing one surveys every road within 130 m, filling in the map without +having to drive each one. + +Two mission types run end to end: **supply runs** and **surveys**. Both are +travel → hold position at the target → return. Hand-in works at *any* base, not +the issuing one, because the brief is explicit that a blocked road home must +never strand the player. + +### Rewards, and why repairs exist + +New targets pay `NOVELTY_BONUS` (1.8×) more than known ones. That multiplier is +the phase's tuning dial: if you never take the new target it is too low, and if +you never take the known one it is too high. + +Payment is in **parts**, which repair the car — the only thing in the game that +pushes condition back up. Repairs go to the worst subsystem first and stop at +its **ceiling**, which drops permanently with every bit of damage taken +(`PERMANENT_SHARE`, currently 30%). So a car can be patched back to what it is +still capable of, but never to what it was. The HUD bars show this directly: `█` +is current condition, `·` is what repairs could still reach, `×` is gone. + +This is what gives the board's reward a reason to matter. Without an economy the +choice between a safe route and a risky one has no stakes; with one, the risky +job is what keeps the car alive a while longer. + ## Layout The one rule worth keeping: **`src/sim/` imports neither three.js nor Rapier.** @@ -88,11 +132,14 @@ ever opening a browser. ``` src/ - sim/ pure model — roads, heat, world generation, condition → handling + sim/ pure model, no engine imports: + world, roads, heat — the map and its memory + intel, routing, quests — what the player knows and is asked to do + bases, car — where missions come from, and decline physics/ Rapier world, raycast vehicle, input → wheel forces - render/ three.js scene, roads, chase camera + render/ three.js scene, roads, markers, chase camera core/ fixed-timestep loop, seeded RNG, keyboard - ui/ debug HUD + ui/ debug HUD, quest board carSpec.ts shared car dimensions, so body and mesh cannot drift apart heatProps.ts integration layer: heat levels → colliders + meshes ``` @@ -115,8 +162,16 @@ one place where "how broken the car is" turns into "how it drives". rather than hours. Turn them down in `applyWear` once the curve reads right. - **No interpolation** between physics steps. Fine at 60 Hz; revisit if the step rate changes. -- **Condition and heat are not yet persisted** across reloads. IndexedDB comes - with the campaign layer. +- **Nothing is persisted** across reloads — condition, heat, intel and completed + runs all reset. IndexedDB comes with the campaign layer, and `sim/intel.ts` + plus `sim/heat.ts` are the two things that most need it. +- **Repairs are automatic on hand-in.** Parts get spent the moment you report + back. The brief wants repair to be a physical, on-foot act with scavenged + parts; that is Phase 6. +- **The board's route preview assumes the shortest path.** If you drive a + different way, the intel you were shown described a route you did not take. + `findRoute` already accepts a cost function, so a "safest route" preview is a + small change when it is wanted. - **Junctions have no heat of their own.** A point near a junction is credited to whichever segment is nearest, so a heavily used crossroads never fortifies as a crossroads. Known gap, deferred on purpose. @@ -138,11 +193,18 @@ one place where "how broken the car is" turns into "how it drives". ## Next -Phase 1's gate: **you catch yourself avoiding a road because of its history, not -its distance.** That cannot be checked by a test — it needs you driving the same -routes for a while and noticing what you start doing. +Two gates are open at once, and both need a person, not a test. -If it fails, the likely culprits, in order: escalation is too slow to matter -within a session (`METRES_PER_HEAT`), decay is so fast that nothing accumulates -(`DECAY_PER_SECOND`), or the props are not actually inconvenient enough to route -around. Tune before building Phase 2 on top. +**Phase 1:** you catch yourself avoiding a road because of its history, not its +distance. If it fails, check `METRES_PER_HEAT` (escalation too slow to matter in +one session), `DECAY_PER_SECOND` (nothing accumulates), or whether the props are +actually inconvenient enough to route around. + +**Phase 2:** you take the new target a meaningful fraction of the time, for +reasons other than curiosity — because the known one has genuinely got +expensive. If the known option is always strictly better, raise `NOVELTY_BONUS`. +If you never take a known target, lower it. + +Watch for one failure mode in particular: if you find yourself taking whichever +job is *nearest* and ignoring the intel line entirely, the board is decorative +and the phase has not landed, whatever the reward numbers say. diff --git a/src/core/input.ts b/src/core/input.ts index 999d133..d2d233a 100644 --- a/src/core/input.ts +++ b/src/core/input.ts @@ -5,6 +5,8 @@ export interface DriverInput { steer: number; handbrake: boolean; respawn: boolean; + /** 1-based board selection pressed this frame, or null. Consumed on read. */ + select: number | null; } const KEYS = { @@ -16,30 +18,47 @@ const KEYS = { respawn: ['KeyR'], } as const; +const SELECT_KEYS = ['Digit1', 'Digit2', 'Digit3', 'Digit4']; + export function createInput(): { read(): DriverInput; dispose(): void } { const down = new Set(); const held = (codes: readonly string[]) => codes.some((c) => down.has(c)); + // Buffered rather than polled: a keypress must not be missed just because it + // landed between two fixed steps, and must not fire twice if it spans three. + let pendingSelect: number | null = null; const onDown = (e: KeyboardEvent) => { + if (!down.has(e.code)) { + const index = SELECT_KEYS.indexOf(e.code); + if (index >= 0) pendingSelect = index + 1; + } down.add(e.code); if (Object.values(KEYS).some((codes) => (codes as readonly string[]).includes(e.code))) { e.preventDefault(); } }; const onUp = (e: KeyboardEvent) => down.delete(e.code); - const onBlur = () => down.clear(); + const onBlur = () => { + down.clear(); + pendingSelect = null; + }; window.addEventListener('keydown', onDown); window.addEventListener('keyup', onUp); window.addEventListener('blur', onBlur); return { - read: () => ({ - throttle: (held(KEYS.forward) ? 1 : 0) - (held(KEYS.back) ? 1 : 0), - steer: (held(KEYS.left) ? 1 : 0) - (held(KEYS.right) ? 1 : 0), - handbrake: held(KEYS.handbrake), - respawn: held(KEYS.respawn), - }), + read: () => { + const select = pendingSelect; + pendingSelect = null; + return { + throttle: (held(KEYS.forward) ? 1 : 0) - (held(KEYS.back) ? 1 : 0), + steer: (held(KEYS.left) ? 1 : 0) - (held(KEYS.right) ? 1 : 0), + handbrake: held(KEYS.handbrake), + respawn: held(KEYS.respawn), + select, + }; + }, dispose() { window.removeEventListener('keydown', onDown); window.removeEventListener('keyup', onUp); diff --git a/src/main.ts b/src/main.ts index 820b50a..5cefbf4 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,7 +1,11 @@ import { generateWorld } from './sim/world'; -import { applyWear, deriveHandling, freshCondition } from './sim/car'; +import { applyWear, deriveHandling, freshCondition, repair } from './sim/car'; import { createHeat, stepHeat } from './sim/heat'; import { routeAt, segmentAt } from './sim/roads'; +import { baseAt, placeBases } from './sim/bases'; +import { buildGraph } from './sim/routing'; +import { createIntel, observe, survey } from './sim/intel'; +import { accept, createQuests, offersAt, stepQuest, type Offer } from './sim/quests'; import { createHeatProps } from './heatProps'; import { seedFromString } from './core/rng'; import { startLoop } from './core/loop'; @@ -9,7 +13,9 @@ import { createInput } from './core/input'; import { createPhysics } from './physics/physics'; import { createDriveState, drive } from './physics/drive'; import { createScene, updateCamera } from './render/scene'; +import { createMarkers } from './render/markers'; import { createHud } from './ui/hud'; +import { createBoard } from './ui/board'; import { CAR, WHEELS } from './carSpec'; function resolveSeed(): number { @@ -22,14 +28,20 @@ function resolveSeed(): number { async function boot() { const seed = resolveSeed(); const model = generateWorld(seed); + const bases = placeBases(model.roads, model.spawn); + const graph = buildGraph(model.roads); const physics = await createPhysics(model); const view = createScene(model); + const markers = createMarkers(view.scene, bases); const input = createInput(); const hud = createHud(seed); + const board = createBoard(); const driveState = createDriveState(); const heat = createHeat(model.roads); const heatProps = createHeatProps(model.roads, physics, view.scene); + const intel = createIntel(model.roads); + const quests = createQuests(); let condition = freshCondition(); let elapsed = 0; @@ -37,6 +49,18 @@ async function boot() { /** The road being travelled along — includes the verge, not just the tarmac. */ let currentSegment: number | null = null; let onTarmac = false; + /** Offers are generated once per arrival at a base, not every frame. */ + let openBase: number | null = null; + let offers: Offer[] = []; + let notice = ''; + let noticeUntil = 0; + + const say = (text: string, seconds = 4) => { + notice = text; + noticeUntil = elapsed + seconds; + }; + + const nodeOf = (id: number) => model.roads.nodes[id]!; document.getElementById('boot')?.remove(); @@ -65,7 +89,81 @@ async function boot() { const at = physics.chassis.translation(); currentSegment = routeAt(model.roads, at.x, at.z)?.id ?? null; onTarmac = segmentAt(model.roads, at.x, at.z) !== null; - heatProps.sync(stepHeat(heat, { dt, segmentId: currentSegment, distance }), heat, currentSegment); + heatProps.sync( + stepHeat(heat, { dt, segmentId: currentSegment, distance }), + heat, + currentSegment, + ); + // Driving a road is how you learn what is on it. + if (currentSegment !== null) observe(intel, heat, currentSegment, elapsed); + + const base = baseAt(bases, at.x, at.z); + const stopped = Math.abs(speed) < 2.5; + + // --- Quest board: only when parked at a base with nothing in hand --- + if (base && stopped && !quests.active) { + if (openBase !== base.nodeId) { + openBase = base.nodeId; + // Seeded by base and runs completed, not by time: leaving and coming + // straight back must not reroll a board you did not like. + offers = offersAt( + quests, + model.roads, + graph, + intel, + base, + elapsed, + seed ^ (base.nodeId * 31) ^ (quests.completed * 7919), + ); + board.show(base, offers); + } + const chosen = cmd.select === null ? undefined : offers[cmd.select - 1]; + if (chosen) { + accept(quests, chosen, base); + board.hide(); + openBase = null; + say(`${chosen.type === 'recon' ? 'Survey' : 'Supply run'} accepted.`); + } + } else if (openBase !== null) { + openBase = null; + board.hide(); + } + + // --- Active mission --- + const quest = quests.active; + if (quest) { + const target = nodeOf(quest.targetNode); + const event = stepQuest(quests, { + dt, + distanceToTarget: Math.hypot(target.x - at.x, target.z - at.z), + atBase: base, + speed: Math.abs(speed), + }); + + if (event?.kind === 'arrived') { + intel.visitedNodes.add(quest.targetNode); + say('At the target. Stop the car and hold position.'); + } else if (event?.kind === 'worked') { + if (event.type === 'recon') { + const count = survey(intel, heat, model.roads, target.x, target.z, elapsed); + say(`Survey complete — ${count} roads mapped. Report back to any base.`); + } else { + say('Cargo delivered. Report back to any base.'); + } + } else if (event?.kind === 'completed') { + const outcome = repair(condition, quests.parts); + const spent = quests.parts - outcome.unused; + condition = outcome.condition; + quests.parts = outcome.unused; + say( + spent > 0.001 + ? `Handed in. Repairs used ${spent.toFixed(2)} parts.` + : 'Handed in. Nothing left worth repairing.', + ); + } + } + + markers.setObjective(quests.active ? nodeOf(quests.active.targetNode) : null); }, render(_alpha, frameDt) { @@ -97,14 +195,33 @@ async function boot() { } view.followSun(); + markers.update(elapsed); updateCamera(view, frameDt, physics.vehicle.currentVehicleSpeed()); view.renderer.render(view.scene, view.camera); + + const quest = quests.active; + const target = quest ? nodeOf(quest.targetNode) : null; hud.update(physics.vehicle.currentVehicleSpeed(), condition, elapsed, { - segmentId: currentSegment, - onTarmac, - value: currentSegment === null ? 0 : heat.value[currentSegment]!, - level: currentSegment === null ? null : heat.level[currentSegment]!, - hottest: Math.max(...heat.value), + heat: { + segmentId: currentSegment, + onTarmac, + value: currentSegment === null ? 0 : heat.value[currentSegment]!, + level: currentSegment === null ? null : heat.level[currentSegment]!, + }, + quest: quest + ? { + type: quest.type, + stage: quest.stage, + novel: quest.novel, + worked: quest.worked, + distance: target + ? Math.hypot(target.x - p.x, target.z - p.z) + : 0, + } + : null, + completed: quests.completed, + parts: quests.parts, + notice: elapsed < noticeUntil ? notice : '', }); }, }); diff --git a/src/physics/physics.test.ts b/src/physics/physics.test.ts index e0cc0f1..1449bb2 100644 --- a/src/physics/physics.test.ts +++ b/src/physics/physics.test.ts @@ -6,7 +6,13 @@ import type { DriverInput } from '../core/input'; import { generateWorld } from '../sim/world'; const STEP = 1 / 60; -const IDLE: DriverInput = { throttle: 0, steer: 0, handbrake: false, respawn: false }; +const IDLE: DriverInput = { + throttle: 0, + steer: 0, + handbrake: false, + respawn: false, + select: null, +}; /** Rapier runs headless, so vehicle tuning is checkable without a browser. */ async function run(input: Partial, seconds: number) { diff --git a/src/render/markers.ts b/src/render/markers.ts new file mode 100644 index 0000000..5e78d4d --- /dev/null +++ b/src/render/markers.ts @@ -0,0 +1,55 @@ +import * as THREE from 'three'; +import type { Base } from '../sim/bases'; + +/** + * Bases and the active objective, as things you can see from a distance. + * The player navigates by looking, so both need to be visible over scenery. + */ +const BEACON_HEIGHT = 26; + +function beacon(colour: number, radius: number): THREE.Mesh { + return new THREE.Mesh( + new THREE.CylinderGeometry(radius, radius, BEACON_HEIGHT, 12, 1, true), + new THREE.MeshBasicMaterial({ + color: colour, + transparent: true, + opacity: 0.18, + side: THREE.DoubleSide, + depthWrite: false, + }), + ); +} + +export function createMarkers(scene: THREE.Scene, bases: Base[]) { + for (const base of bases) { + const hut = new THREE.Mesh( + new THREE.BoxGeometry(7, 3.4, 7), + new THREE.MeshStandardMaterial({ color: 0x3f5a46, roughness: 0.85 }), + ); + hut.position.set(base.x, 1.7, base.z); + hut.castShadow = true; + hut.receiveShadow = true; + scene.add(hut); + + const light = beacon(0x5fd08a, 5); + light.position.set(base.x, BEACON_HEIGHT / 2, base.z); + scene.add(light); + } + + const objective = beacon(0xffc247, 6); + objective.position.y = BEACON_HEIGHT / 2; + objective.visible = false; + scene.add(objective); + + return { + /** Point the objective beacon at a target, or hide it when idle. */ + setObjective(target: { x: number; z: number } | null) { + objective.visible = target !== null; + if (target) objective.position.set(target.x, BEACON_HEIGHT / 2, target.z); + }, + /** Slow spin, so a beacon reads as a marker rather than scenery. */ + update(elapsed: number) { + objective.rotation.y = elapsed * 0.6; + }, + }; +} diff --git a/src/sim/bases.ts b/src/sim/bases.ts new file mode 100644 index 0000000..2bc065c --- /dev/null +++ b/src/sim/bases.ts @@ -0,0 +1,69 @@ +/** + * Allied bases — where missions come from. Pure, no engine imports. + * + * There is deliberately more than one. The brief is explicit that no single + * choke point may strand the player: if the roads around your nearest base turn + * hostile, another base further out has to still offer a way back into the loop. + * That only works if they are spread out, hence farthest-point selection rather + * than random placement. + */ +import type { RoadNetwork } from './roads'; + +export interface Base { + nodeId: number; + x: number; + z: number; + name: string; +} + +const NAMES = ['Anvil', 'Birch', 'Cinder', 'Dovetail', 'Ember', 'Foxglove']; + +export function placeBases(roads: RoadNetwork, spawn: { x: number; z: number }, count = 4): Base[] { + const nodes = roads.nodes; + + // The first base is the one the player starts at, so the loop is available + // immediately rather than after a hunt. + const first = nodes.reduce((best, n) => + Math.hypot(n.x - spawn.x, n.z - spawn.z) < Math.hypot(best.x - spawn.x, best.z - spawn.z) + ? n + : best, + ); + + const chosen = [first]; + while (chosen.length < Math.min(count, nodes.length)) { + // Greedy farthest-point: repeatedly take whichever node is furthest from + // everything already chosen. Spreads bases across the map without clumping. + let best = nodes[0]!; + let bestDistance = -1; + for (const n of nodes) { + if (chosen.some((c) => c.id === n.id)) continue; + const nearest = Math.min(...chosen.map((c) => Math.hypot(c.x - n.x, c.z - n.z))); + if (nearest > bestDistance) { + bestDistance = nearest; + best = n; + } + } + chosen.push(best); + } + + return chosen.map((n, i) => ({ + nodeId: n.id, + x: n.x, + z: n.z, + name: NAMES[i % NAMES.length]!, + })); +} + +/** Nearest base within `radius` metres, or null. */ +export function baseAt(bases: Base[], x: number, z: number, radius = 18): Base | null { + let best: Base | null = null; + let bestDistance = radius; + for (const b of bases) { + const d = Math.hypot(b.x - x, b.z - z); + if (d < bestDistance) { + bestDistance = d; + best = b; + } + } + return best; +} diff --git a/src/sim/car.ts b/src/sim/car.ts index 16b886c..f6d7239 100644 --- a/src/sim/car.ts +++ b/src/sim/car.ts @@ -1,18 +1,25 @@ /** * Car condition and what it does to handling. Pure — no engine imports. * - * Design pillar this serves: "Decline, not reset." Condition only ever falls. - * Phase 0's whole job is to make that decline *felt* through the wheel, so the - * handling numbers the physics layer uses are derived here rather than constants. + * Design pillar this serves: "Decline, not reset." Repairs exist, but every + * subsystem carries a *ceiling* that only ever falls. Patching a car brings it + * back up to what it is still capable of, which is never quite what it was. */ -export interface CarCondition { +export interface Subsystems { /** 1 = factory fresh, 0 = ruined. */ engine: number; tires: number; chassis: number; } +export interface CarCondition { + /** Current state of each subsystem. */ + level: Subsystems; + /** The best each subsystem can be restored to. Falls permanently with damage. */ + ceiling: Subsystems; +} + export interface Handling { /** Newtons of drive force available per driven wheel at full throttle. */ engineForce: number; @@ -27,23 +34,29 @@ export interface Handling { steeringPull: number; } -export const freshCondition = (): CarCondition => ({ engine: 1, tires: 1, chassis: 1 }); +export const SUBSYSTEMS = ['engine', 'tires', 'chassis'] as const; + +export const freshCondition = (): CarCondition => ({ + level: { engine: 1, tires: 1, chassis: 1 }, + ceiling: { engine: 1, tires: 1, chassis: 1 }, +}); const lerp = (a: number, b: number, t: number) => a + (b - a) * t; const clamp01 = (v: number) => Math.min(1, Math.max(0, v)); export function deriveHandling(c: CarCondition): Handling { + const { engine, tires, chassis } = c.level; return { // A tired engine simply cannot push as hard. - engineForce: lerp(900, 2600, c.engine), + engineForce: lerp(900, 2600, engine), // Worn pads take longer to haul the car down. - brakeForce: lerp(4, 14, c.tires), - maxSteer: lerp(0.35, 0.55, c.chassis), + brakeForce: lerp(4, 14, tires), + maxSteer: lerp(0.35, 0.55, chassis), // Bald tyres are the most legible failure: the back end starts to leave. - frictionSlip: lerp(1.6, 5, c.tires), - sideFrictionStiffness: lerp(0.5, 1, c.tires), + frictionSlip: lerp(1.6, 5, tires), + sideFrictionStiffness: lerp(0.5, 1, tires), // A bent chassis pulls to one side. Sign is stable for a given car. - steeringPull: (1 - c.chassis) * 0.06, + steeringPull: (1 - chassis) * 0.06, }; } @@ -58,15 +71,58 @@ export interface WearInput { impactForce: number; } +/** + * Share of any damage that can never be repaired out. This is the pillar in a + * single number: at 0 the car is a rental, at 1 repairs do nothing at all. + */ +const PERMANENT_SHARE = 0.3; + /** * Returns a new condition. Numbers here are deliberately aggressive so a * 15-minute session shows visible decline — tune down once the feel is right. */ export function applyWear(c: CarCondition, w: WearInput): CarCondition { const impact = w.impactForce / 1e5; - return { - engine: clamp01(c.engine - w.throttle * w.dt * 6e-4 - impact * 0.01), - tires: clamp01(c.tires - w.distance * 8e-5 - impact * 0.02), - chassis: clamp01(c.chassis - impact * 0.05), + const damage: Subsystems = { + engine: w.throttle * w.dt * 6e-4 + impact * 0.01, + tires: w.distance * 8e-5 + impact * 0.02, + chassis: impact * 0.05, }; + + const level = {} as Subsystems; + const ceiling = {} as Subsystems; + for (const part of SUBSYSTEMS) { + ceiling[part] = clamp01(c.ceiling[part] - damage[part] * PERMANENT_SHARE); + level[part] = Math.min(clamp01(c.level[part] - damage[part]), ceiling[part]); + } + return { level, ceiling }; } + +/** + * Spends parts on the worst subsystem first, never above its ceiling. + * Returns the new condition and whatever could not be used. + */ +export function repair(c: CarCondition, parts: number): { condition: CarCondition; unused: number } { + const level = { ...c.level }; + let remaining = parts; + + // Worst-first: a car with one ruined subsystem drives worse than one that is + // evenly tired, so that is where a scarce part belongs. + for (let pass = 0; pass < SUBSYSTEMS.length && remaining > 1e-9; pass++) { + const worst = [...SUBSYSTEMS] + .filter((p) => level[p] < c.ceiling[p] - 1e-9) + .sort((a, b) => level[a] - level[b])[0]; + if (!worst) break; + + const room = c.ceiling[worst] - level[worst]; + const spend = Math.min(room, remaining); + level[worst] += spend; + remaining -= spend; + } + + return { condition: { level, ceiling: c.ceiling }, unused: remaining }; +} + +/** Rough single number for the HUD and for deciding when a car is finished. */ +export const overallCondition = (c: CarCondition): number => + (c.level.engine + c.level.tires + c.level.chassis) / 3; diff --git a/src/sim/intel.ts b/src/sim/intel.ts new file mode 100644 index 0000000..e96e397 --- /dev/null +++ b/src/sim/intel.ts @@ -0,0 +1,104 @@ +/** + * What the player knows about the map, as distinct from what is true. + * + * The brief insists the player can never be certain a place is as they left it. + * So heat is never read directly by the UI — the board reads *this*, a snapshot + * of what was last observed and how long ago. Roads you have driven are known + * but ageing; roads you have not are blank. + */ +import { levelFor, type HeatLevel, type HeatState } from './heat'; +import type { RoadNetwork } from './roads'; + +export interface Intel { + /** Heat value at the moment each segment was last observed. */ + rememberedHeat: number[]; + /** Level at the moment each segment was last observed. */ + rememberedLevel: HeatLevel[]; + /** Elapsed time when each segment was last observed; -1 for never. */ + seenAt: number[]; + /** Target nodes the player has actually reached. */ + visitedNodes: Set; +} + +export function createIntel(roads: RoadNetwork): Intel { + return { + rememberedHeat: new Array(roads.segments.length).fill(0), + rememberedLevel: new Array(roads.segments.length).fill('clear'), + seenAt: new Array(roads.segments.length).fill(-1), + visitedNodes: new Set(), + }; +} + +export const hasSeen = (intel: Intel, segmentId: number): boolean => intel.seenAt[segmentId]! >= 0; + +/** Record what a segment looks like right now. */ +export function observe(intel: Intel, heat: HeatState, segmentId: number, now: number): void { + intel.rememberedHeat[segmentId] = heat.value[segmentId]!; + intel.rememberedLevel[segmentId] = heat.level[segmentId]!; + intel.seenAt[segmentId] = now; +} + +/** + * A recon sweep: everything within `radius` of the target is written down. + * This is the mechanism the brief describes for turning a new target into a + * known one without having to drive every road there first. + */ +export function survey( + intel: Intel, + heat: HeatState, + roads: RoadNetwork, + x: number, + z: number, + now: number, + radius = 130, +): number { + let count = 0; + for (const s of roads.segments) { + const near = + Math.hypot(s.ax - x, s.az - z) < radius || Math.hypot(s.bx - x, s.bz - z) < radius; + if (!near) continue; + observe(intel, heat, s.id, now); + count++; + } + return count; +} + +export interface RouteIntel { + /** Worst level remembered anywhere on the route. */ + worst: HeatLevel; + /** Segments on the route never observed at all. */ + unknownCount: number; + /** Seconds since the freshest observation went stale, or null if all unknown. */ + stalest: number | null; +} + +/** + * Summarises a route the way a briefing would: the worst thing anyone has seen + * on it, how much of it nobody has looked at, and how old that word is. + */ +export function summariseRoute(intel: Intel, segments: number[], now: number): RouteIntel { + let worstRank = 0; + let unknownCount = 0; + let stalest: number | null = null; + + for (const id of segments) { + if (!hasSeen(intel, id)) { + unknownCount++; + continue; + } + // Judge by the remembered value, not the remembered label, so a road left + // just under a threshold does not read as safer than one just over it. + const rank = ['clear', 'patrol', 'barricade', 'turret'].indexOf( + levelFor(intel.rememberedHeat[id]!, intel.rememberedLevel[id]!), + ); + worstRank = Math.max(worstRank, rank); + const age = now - intel.seenAt[id]!; + stalest = stalest === null ? age : Math.max(stalest, age); + } + + return { + worst: (['clear', 'patrol', 'barricade', 'turret'] as const)[worstRank]!, + unknownCount, + stalest, + }; +} diff --git a/src/sim/quests.test.ts b/src/sim/quests.test.ts new file mode 100644 index 0000000..93e3a87 --- /dev/null +++ b/src/sim/quests.test.ts @@ -0,0 +1,231 @@ +import { describe, expect, it } from 'vitest'; +import { generateWorld } from './world'; +import { placeBases, baseAt } from './bases'; +import { buildGraph, findRoute } from './routing'; +import { createIntel, observe, summariseRoute, survey, hasSeen } from './intel'; +import { createHeat, stepHeat } from './heat'; +import { accept, createQuests, offersAt, stepQuest, WORK_SECONDS } from './quests'; + +const world = generateWorld(1337); +const graph = buildGraph(world.roads); +const bases = placeBases(world.roads, world.spawn); + +describe('bases', () => { + it('puts one where the player starts, so the loop is available at once', () => { + expect(baseAt(bases, world.spawn.x, world.spawn.z)).not.toBeNull(); + }); + + it('spreads the rest out, so no single choke point can strand the player', () => { + const spacing: number[] = []; + for (let i = 0; i < bases.length; i++) { + for (let j = i + 1; j < bases.length; j++) { + spacing.push(Math.hypot(bases[i]!.x - bases[j]!.x, bases[i]!.z - bases[j]!.z)); + } + } + expect(Math.min(...spacing)).toBeGreaterThan(100); + }); + + it('reaches every base by road from every other', () => { + for (const from of bases) { + for (const to of bases) { + if (from === to) continue; + expect(findRoute(graph, from.nodeId, to.nodeId)).not.toBeNull(); + } + } + }); +}); + +describe('routing', () => { + it('finds a route whose segments actually chain together', () => { + const route = findRoute(graph, bases[0]!.nodeId, bases[2]!.nodeId)!; + expect(route.nodes[0]).toBe(bases[0]!.nodeId); + expect(route.nodes.at(-1)).toBe(bases[2]!.nodeId); + expect(route.segments).toHaveLength(route.nodes.length - 1); + for (let i = 0; i < route.segments.length; i++) { + const s = world.roads.segments[route.segments[i]!]!; + const pair = [s.a, s.b]; + expect(pair).toContain(route.nodes[i]); + expect(pair).toContain(route.nodes[i + 1]); + } + }); + + it('reports true metres, and no longer than the straight line is short', () => { + const route = findRoute(graph, bases[0]!.nodeId, bases[1]!.nodeId)!; + const straight = Math.hypot(bases[0]!.x - bases[1]!.x, bases[0]!.z - bases[1]!.z); + expect(route.length).toBeGreaterThanOrEqual(straight - 1e-6); + const summed = route.segments.reduce((t, id) => t + world.roads.segments[id]!.length, 0); + expect(route.length).toBeCloseTo(summed, 6); + }); + + it('takes a detour when the direct road is weighted as expensive', () => { + const direct = findRoute(graph, bases[0]!.nodeId, bases[1]!.nodeId)!; + const avoided = new Set(direct.segments); + const detour = findRoute(graph, bases[0]!.nodeId, bases[1]!.nodeId, (id, length) => + avoided.has(id) ? length * 100 : length, + )!; + // Loops in the network mean an alternative exists and costs more. + expect(detour.segments).not.toEqual(direct.segments); + expect(detour.length).toBeGreaterThan(direct.length); + }); +}); + +describe('intel', () => { + it('starts blank and only records what was observed', () => { + const intel = createIntel(world.roads); + const heat = createHeat(world.roads); + expect(hasSeen(intel, 4)).toBe(false); + observe(intel, heat, 4, 10); + expect(hasSeen(intel, 4)).toBe(true); + expect(hasSeen(intel, 5)).toBe(false); + }); + + it('remembers what a road was, not what it has since become', () => { + const intel = createIntel(world.roads); + const heat = createHeat(world.roads); + observe(intel, heat, 0, 0); + expect(intel.rememberedLevel[0]).toBe('clear'); + + for (let m = 0; m < 400; m++) stepHeat(heat, { dt: 1 / 60, segmentId: 0, distance: 1 }); + expect(heat.level[0]).toBe('turret'); + // The player was not there to see it escalate. + expect(intel.rememberedLevel[0]).toBe('clear'); + }); + + it('surveys a whole area at once, which is what recon is for', () => { + const intel = createIntel(world.roads); + const heat = createHeat(world.roads); + const node = world.roads.nodes[24]!; + const mapped = survey(intel, heat, world.roads, node.x, node.z, 5); + expect(mapped).toBeGreaterThan(1); + expect(intel.seenAt.filter((t) => t >= 0)).toHaveLength(mapped); + }); + + it('summarises a route by its worst road and how much is unscouted', () => { + const intel = createIntel(world.roads); + const heat = createHeat(world.roads); + const route = findRoute(graph, bases[0]!.nodeId, bases[1]!.nodeId)!; + + expect(summariseRoute(intel, route.segments, 0).unknownCount).toBe(route.segments.length); + + for (let m = 0; m < 400; m++) { + stepHeat(heat, { dt: 1 / 60, segmentId: route.segments[0]!, distance: 1 }); + } + for (const id of route.segments) observe(intel, heat, id, 100); + + const summary = summariseRoute(intel, route.segments, 400); + expect(summary.unknownCount).toBe(0); + expect(summary.worst).toBe('turret'); + expect(summary.stalest).toBeCloseTo(300, 6); + }); +}); + +describe('quest board', () => { + it('always offers something new to go and look at', () => { + const state = createQuests(); + const intel = createIntel(world.roads); + const offers = offersAt(state, world.roads, graph, intel, bases[0]!, 0, 1); + expect(offers.length).toBeGreaterThan(0); + expect(offers.some((o) => o.novel)).toBe(true); + }); + + it('offers both a known and an unknown target once somewhere has been visited', () => { + const state = createQuests(); + const intel = createIntel(world.roads); + intel.visitedNodes.add(world.roads.nodes[10]!.id); + intel.visitedNodes.add(world.roads.nodes[30]!.id); + const offers = offersAt(state, world.roads, graph, intel, bases[0]!, 0, 7); + expect(offers.some((o) => o.novel)).toBe(true); + expect(offers.some((o) => !o.novel)).toBe(true); + }); + + it('pays more for an unknown target, or nobody would ever take one', () => { + // Same target, same route — the only difference is whether it is known. + const state = createQuests(); + const blank = createIntel(world.roads); + const known = createIntel(world.roads); + for (const n of world.roads.nodes) known.visitedNodes.add(n.id); + + const rewardPerKm = (intel: typeof blank) => { + const offers = offersAt(state, world.roads, graph, intel, bases[0]!, 0, 3); + return offers.map((o) => o.reward / (o.route.length / 1000)); + }; + const novelRates = rewardPerKm(blank); + const knownRates = rewardPerKm(known); + expect(Math.min(...novelRates)).toBeGreaterThan(Math.max(...knownRates)); + }); + + it('never offers the base you are standing in as a target', () => { + const state = createQuests(); + const intel = createIntel(world.roads); + for (const base of bases) { + for (const offer of offersAt(state, world.roads, graph, intel, base, 0, 11)) { + expect(offer.targetNode).not.toBe(base.nodeId); + } + } + }); +}); + +describe('mission lifecycle', () => { + const start = () => { + const state = createQuests(); + const intel = createIntel(world.roads); + const offer = offersAt(state, world.roads, graph, intel, bases[0]!, 0, 5)[0]!; + accept(state, offer, bases[0]!); + return state; + }; + + it('runs travel → work → return → hand in', () => { + const state = start(); + expect(state.active!.stage).toBe('travel'); + + expect(stepQuest(state, { dt: 1 / 60, distanceToTarget: 400, atBase: null, speed: 20 })).toBeNull(); + expect(stepQuest(state, { dt: 1 / 60, distanceToTarget: 5, atBase: null, speed: 20 })).toEqual({ + kind: 'arrived', + }); + + // Still rolling: no work gets done. + stepQuest(state, { dt: 1, distanceToTarget: 5, atBase: null, speed: 20 }); + expect(state.active!.worked).toBe(0); + + let worked = null; + for (let i = 0; i < WORK_SECONDS * 60 + 2 && !worked; i++) { + worked = stepQuest(state, { dt: 1 / 60, distanceToTarget: 5, atBase: null, speed: 0 }); + } + expect(worked).toEqual({ kind: 'worked', type: state.active!.type }); + expect(state.active!.stage).toBe('return'); + + const done = stepQuest(state, { + dt: 1 / 60, + distanceToTarget: 900, + atBase: bases[2]!, + speed: 0, + }); + expect(done?.kind).toBe('completed'); + expect(state.active).toBeNull(); + expect(state.completed).toBe(1); + expect(state.parts).toBeGreaterThan(0); + }); + + it('accepts a hand-in at any base, so a blocked road home cannot strand you', () => { + const state = start(); + stepQuest(state, { dt: 1 / 60, distanceToTarget: 5, atBase: null, speed: 0 }); + for (let i = 0; i < WORK_SECONDS * 60 + 2; i++) { + stepQuest(state, { dt: 1 / 60, distanceToTarget: 5, atBase: null, speed: 0 }); + } + const elsewhere = bases.find((b) => b.nodeId !== state.active!.originBase)!; + expect( + stepQuest(state, { dt: 1 / 60, distanceToTarget: 999, atBase: elsewhere, speed: 0 })?.kind, + ).toBe('completed'); + }); + + it('abandons half-finished work if you drive away', () => { + const state = start(); + stepQuest(state, { dt: 1 / 60, distanceToTarget: 5, atBase: null, speed: 0 }); + stepQuest(state, { dt: 1, distanceToTarget: 5, atBase: null, speed: 0 }); + expect(state.active!.worked).toBeGreaterThan(0); + + stepQuest(state, { dt: 1 / 60, distanceToTarget: 200, atBase: null, speed: 25 }); + expect(state.active!.stage).toBe('travel'); + expect(state.active!.worked).toBe(0); + }); +}); diff --git a/src/sim/quests.ts b/src/sim/quests.ts new file mode 100644 index 0000000..69a9a0e --- /dev/null +++ b/src/sim/quests.ts @@ -0,0 +1,184 @@ +/** + * Missions and target selection. Pure — no engine imports. + * + * Phase 2's job is to turn heat into a decision at the point of choice: a known + * target whose route you can assess against a new one you cannot. The board + * therefore always offers both, and pays more for the unknown — otherwise the + * known option is strictly better and the choice is not a choice. + */ +import { makeRng, randRange, type Rng } from '../core/rng'; +import type { Base } from './bases'; +import { summariseRoute, type Intel, type RouteIntel } from './intel'; +import { findRoute, type Graph, type Route } from './routing'; +import type { RoadNetwork } from './roads'; + +export type MissionType = 'delivery' | 'recon'; + +export interface Offer { + id: number; + type: MissionType; + /** Node the player has to reach. */ + targetNode: number; + /** True when the player has never been to this target. */ + novel: boolean; + route: Route; + intel: RouteIntel; + /** Parts awarded on hand-in. */ + reward: number; +} + +export interface ActiveQuest extends Offer { + originBase: number; + stage: 'travel' | 'working' | 'return'; + /** Seconds of work completed at the target. */ + worked: number; +} + +export interface QuestState { + active: ActiveQuest | null; + completed: number; + /** Unspent repair parts. */ + parts: number; + nextId: number; +} + +export const createQuests = (): QuestState => ({ + active: null, + completed: 0, + parts: 0, + nextId: 1, +}); + +/** Seconds the car must be stopped at the target to finish the job. */ +export const WORK_SECONDS = 3; +/** How close counts as "there", for both targets and bases. */ +export const ARRIVAL_RADIUS = 18; + +/** + * Novel targets pay this much more. This multiplier *is* the phase's tuning + * dial: if the board's "new target" option is never taken, it is too low; if + * known targets are never taken, too high. + */ +const NOVELTY_BONUS = 1.8; +const PARTS_PER_KM = 0.1; +const RECON_BONUS = 1.15; + +function rewardFor(type: MissionType, route: Route, novel: boolean): number { + const base = 0.08 + (route.length / 1000) * PARTS_PER_KM; + return base * (novel ? NOVELTY_BONUS : 1) * (type === 'recon' ? RECON_BONUS : 1); +} + +/** + * Builds the board for a base: always at least one previously visited target + * and one the player has never been to, so the comparison the phase is testing + * is on screen every time. + */ +export function offersAt( + state: QuestState, + roads: RoadNetwork, + graph: Graph, + intel: Intel, + base: Base, + now: number, + seed: number, +): Offer[] { + const rng: Rng = makeRng(seed); + const candidates = roads.nodes.filter((n) => n.id !== base.nodeId); + const visited = candidates.filter((n) => intel.visitedNodes.has(n.id)); + const fresh = candidates.filter((n) => !intel.visitedNodes.has(n.id)); + + const pick = (pool: typeof candidates): number | null => + pool.length === 0 ? null : pool[Math.floor(rng() * pool.length)]!.id; + + const chosen: Array<{ node: number; novel: boolean }> = []; + const known = pick(visited); + if (known !== null) chosen.push({ node: known, novel: false }); + for (const _ of [0, 1]) { + const novel = pick(fresh.filter((n) => !chosen.some((c) => c.node === n.id))); + if (novel !== null) chosen.push({ node: novel, novel: true }); + } + // Early on there is nothing visited yet; top up so the board is never thin. + while (chosen.length < 3) { + const any = pick(candidates.filter((n) => !chosen.some((c) => c.node === n.id))); + if (any === null) break; + chosen.push({ node: any, novel: !intel.visitedNodes.has(any) }); + } + + const offers: Offer[] = []; + for (const { node, novel } of chosen) { + const route = findRoute(graph, base.nodeId, node); + if (!route) continue; + const type: MissionType = rng() < 0.5 ? 'recon' : 'delivery'; + offers.push({ + id: state.nextId++, + type, + targetNode: node, + novel, + route, + intel: summariseRoute(intel, route.segments, now), + reward: rewardFor(type, route, novel) * randRange(rng, 0.9, 1.1), + }); + } + return offers; +} + +export function accept(state: QuestState, offer: Offer, base: Base): void { + state.active = { ...offer, originBase: base.nodeId, stage: 'travel', worked: 0 }; +} + +export interface QuestStep { + dt: number; + /** Distance from the car to the active target, metres. */ + distanceToTarget: number; + /** Base the car is currently sitting at, or null. */ + atBase: Base | null; + /** Metres per second, so "stopped" can be required for work. */ + speed: number; +} + +export type QuestEvent = + | { kind: 'arrived' } + | { kind: 'worked'; type: MissionType } + | { kind: 'completed'; parts: number }; + +/** Advances the active mission. Returns whatever just happened, if anything. */ +export function stepQuest(state: QuestState, step: QuestStep): QuestEvent | null { + const quest = state.active; + if (!quest) return null; + + const stopped = step.speed < 2.5; + + if (quest.stage === 'travel') { + if (step.distanceToTarget < ARRIVAL_RADIUS) { + quest.stage = 'working'; + return { kind: 'arrived' }; + } + return null; + } + + if (quest.stage === 'working') { + // Wandering off resets the job — you have to actually stop and do it. + if (step.distanceToTarget > ARRIVAL_RADIUS * 1.5) { + quest.stage = 'travel'; + quest.worked = 0; + return null; + } + if (!stopped) return null; + quest.worked += step.dt; + if (quest.worked >= WORK_SECONDS) { + quest.stage = 'return'; + return { kind: 'worked', type: quest.type }; + } + return null; + } + + // Hand in at *any* base, not the one that issued it. The brief is explicit + // that a blocked road home must never strand the player. + if (step.atBase && stopped) { + state.active = null; + state.completed++; + state.parts += quest.reward; + return { kind: 'completed', parts: quest.reward }; + } + return null; +} diff --git a/src/sim/routing.ts b/src/sim/routing.ts new file mode 100644 index 0000000..d174e76 --- /dev/null +++ b/src/sim/routing.ts @@ -0,0 +1,95 @@ +/** + * Shortest paths over the road graph. Pure, no engine imports. + * + * This exists so the quest board can say something concrete about a route + * *before* the player commits to it — which is the whole point of Phase 2. A + * target is not a decision until you can weigh what getting there costs. + */ +import type { RoadNetwork } from './roads'; + +export interface Route { + /** Node ids from start to goal, inclusive. */ + nodes: number[]; + /** Segment ids traversed, in order. */ + segments: number[]; + /** Total length in metres. */ + length: number; +} + +interface Link { + to: number; + segment: number; + length: number; +} + +export type Graph = Map; + +export function buildGraph(roads: RoadNetwork): Graph { + const graph: Graph = new Map(roads.nodes.map((n) => [n.id, []])); + for (const s of roads.segments) { + graph.get(s.a)!.push({ to: s.b, segment: s.id, length: s.length }); + graph.get(s.b)!.push({ to: s.a, segment: s.id, length: s.length }); + } + return graph; +} + +/** + * Dijkstra with a plain linear scan for the frontier. The graph is ~50 nodes; + * a heap would be more code than it is worth and this never runs per-frame. + * + * `cost` lets a caller weight segments by something other than distance — a + * "safest route" preview would pass remembered heat here. + */ +export function findRoute( + graph: Graph, + from: number, + to: number, + cost: (segmentId: number, length: number) => number = (_, length) => length, +): Route | null { + const distance = new Map([[from, 0]]); + const previous = new Map(); + const settled = new Set(); + + while (settled.size < graph.size) { + let current: number | null = null; + let best = Infinity; + for (const [node, d] of distance) { + if (!settled.has(node) && d < best) { + best = d; + current = node; + } + } + if (current === null) break; + if (current === to) break; + settled.add(current); + + for (const link of graph.get(current) ?? []) { + if (settled.has(link.to)) continue; + const next = best + cost(link.segment, link.length); + if (next < (distance.get(link.to) ?? Infinity)) { + distance.set(link.to, next); + previous.set(link.to, { node: current, segment: link.segment }); + } + } + } + + if (!distance.has(to)) return null; + + const nodes = [to]; + const segments: number[] = []; + let length = 0; + let cursor = to; + while (cursor !== from) { + const step = previous.get(cursor); + if (!step) return null; + // Accumulate true metres here, not the search cost — a caller weighting by + // heat still wants the distance reported honestly. + const link = graph.get(step.node)!.find((l) => l.segment === step.segment && l.to === cursor); + length += link?.length ?? 0; + segments.unshift(step.segment); + nodes.unshift(step.node); + cursor = step.node; + } + + return { nodes, segments, length }; +} diff --git a/src/sim/sim.test.ts b/src/sim/sim.test.ts index 25f8457..fcab6de 100644 --- a/src/sim/sim.test.ts +++ b/src/sim/sim.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import { generateWorld } from './world'; -import { applyWear, deriveHandling, freshCondition } from './car'; +import { applyWear, deriveHandling, freshCondition, repair, SUBSYSTEMS, type CarCondition } from './car'; describe('world generation', () => { it('is reproducible from a seed', () => { @@ -20,12 +20,12 @@ describe('world generation', () => { }); describe('car condition', () => { - it('never recovers', () => { + it('never recovers on its own', () => { let c = freshCondition(); for (let i = 0; i < 600; i++) { const next = applyWear(c, { dt: 1 / 60, distance: 0.4, throttle: 1, impactForce: 0 }); - expect(next.engine).toBeLessThanOrEqual(c.engine); - expect(next.tires).toBeLessThanOrEqual(c.tires); + expect(next.level.engine).toBeLessThanOrEqual(c.level.engine); + expect(next.level.tires).toBeLessThanOrEqual(c.level.tires); c = next; } }); @@ -35,15 +35,76 @@ describe('car condition', () => { for (let i = 0; i < 200; i++) { c = applyWear(c, { dt: 1 / 60, distance: 5, throttle: 1, impactForce: 5e5 }); } - expect(c.chassis).toBe(0); - expect(c.tires).toBe(0); + expect(c.level.chassis).toBe(0); + expect(c.level.tires).toBe(0); }); it('makes a worn car measurably worse to drive', () => { const fresh = deriveHandling(freshCondition()); - const worn = deriveHandling({ engine: 0.3, tires: 0.3, chassis: 0.3 }); + const worn = deriveHandling({ + level: { engine: 0.3, tires: 0.3, chassis: 0.3 }, + ceiling: { engine: 1, tires: 1, chassis: 1 }, + }); expect(worn.engineForce).toBeLessThan(fresh.engineForce); expect(worn.frictionSlip).toBeLessThan(fresh.frictionSlip); expect(worn.steeringPull).toBeGreaterThan(fresh.steeringPull); }); }); + +describe('repair', () => { + const damaged = () => { + let c = freshCondition(); + for (let i = 0; i < 40; i++) { + c = applyWear(c, { dt: 1 / 60, distance: 3, throttle: 1, impactForce: 2e5 }); + } + return c; + }; + + it('lowers the ceiling permanently, so a car never comes back whole', () => { + const c = damaged(); + expect(c.ceiling.chassis).toBeLessThan(1); + // No amount of parts can undo it. + const restored = repair(c, 99).condition; + expect(restored.level.chassis).toBeLessThan(1); + expect(restored.level.chassis).toBeCloseTo(c.ceiling.chassis, 6); + }); + + it('never lifts a subsystem above its ceiling', () => { + const c = damaged(); + const restored = repair(c, 99).condition; + for (const part of SUBSYSTEMS) { + expect(restored.level[part]).toBeLessThanOrEqual(restored.ceiling[part] + 1e-9); + } + }); + + it('hands back parts it could not use', () => { + const { unused } = repair(freshCondition(), 5); + expect(unused).toBeCloseTo(5, 6); + }); + + it('spends a scarce part on the worst subsystem', () => { + const c: CarCondition = { + level: { engine: 0.9, tires: 0.2, chassis: 0.8 }, + ceiling: { engine: 1, tires: 1, chassis: 1 }, + }; + const restored = repair(c, 0.1).condition; + expect(restored.level.tires).toBeCloseTo(0.3, 6); + expect(restored.level.engine).toBe(0.9); + }); + + it('ratchets down over repeated damage-and-repair cycles', () => { + let c = freshCondition(); + let previousCeiling = 1; + for (let cycle = 0; cycle < 5; cycle++) { + for (let i = 0; i < 30; i++) { + c = applyWear(c, { dt: 1 / 60, distance: 3, throttle: 1, impactForce: 2e4 }); + } + c = repair(c, 99).condition; + // Fully repaired every cycle, and still worse off every cycle. + expect(c.level.chassis).toBeCloseTo(c.ceiling.chassis, 6); + expect(c.ceiling.chassis).toBeLessThan(previousCeiling); + previousCeiling = c.ceiling.chassis; + } + expect(previousCeiling).toBeGreaterThan(0); + }); +}); diff --git a/src/ui/board.ts b/src/ui/board.ts new file mode 100644 index 0000000..f3c547f --- /dev/null +++ b/src/ui/board.ts @@ -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 = { + 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 = ` +

${base.name} — quest board

+
    ${offers + .map( + (offer, i) => ` +
  1. + [${i + 1}] + ${offer.type === 'recon' ? 'Survey' : 'Supply run'} — + ${offer.novel ? 'new target' : 'known target'} + · ${(offer.route.length / 1000).toFixed(1)} km + · ${offer.reward.toFixed(2)} parts +
    ${describe(offer)}
    +
  2. `, + ) + .join('')}
+
press 1–${offers.length} to accept
`; + }, + hide() { + el.classList.remove('open'); + }, + get isOpen() { + return el.classList.contains('open'); + }, + }; +} diff --git a/src/ui/hud.ts b/src/ui/hud.ts index bf24ef2..fb6154e 100644 --- a/src/ui/hud.ts +++ b/src/ui/hud.ts @@ -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): 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'); }, }; }