diff --git a/README.md b/README.md index 563f3b8..b5d1c0c 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ Endless driving survival game. - **Phase 2** — bases, a quest board, and known-vs-new target selection. - **Phase 3** — regional control: behind your own lines, nobody is watching. - **Phase 4** — the front line moves, from your work and on its own. +- **Phase 5** — four distinct jobs, radio texture, and work found in the field. ## Running @@ -25,6 +26,9 @@ seeds may be numbers or words. | `R` | respawn the car — **does not** repair it | | `1`–`3` | accept a mission, when parked at a base | +Progress saves itself every 20 seconds, and immediately whenever you take or +finish a job. Saves are per seed; `?seed=x` is a separate campaign. + 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 — the amber beacon goes out once the job at the target is done, since the target @@ -50,25 +54,52 @@ Roughly four traversals take a road from clear to turret; an untouched road cools off in about four minutes. Both numbers are in `sim/heat.ts` and both are guesses meant to be tuned. +### The road hierarchy + +Roads are not all the same road: + +| Class | Tarmac | Speed | Attention | +|---|---|---|---| +| Track (single car) | 4.5 m | ×0.55 | ×0.55 | +| Road (two lanes) | 9 m | ×1 | ×1 | +| Trunk (four lanes) | 15 m | ×1.35 | ×1.35 | + +That trade is the network doing real work rather than being the floor you drive +on: a trunk is fast and direct and exactly where anyone looking for you would +look; a lane is slow and awkward and nobody is watching it. Trunks run the whole +width of the map and are never thinned away, so there is a shape to learn — get +onto the trunk, then work down the lanes — instead of a uniform mesh where every +road is as good as every other. Route previews are costed by **travel time**, not +distance, so the board's estimate matches the way a driver would actually go. + ### The route corridor -Heat is credited to a road for anything within `ROUTE_CATCHMENT` (12 m) of its -centreline, not just the 9 m of tarmac. Otherwise the dominant strategy is to -drive *next to* every road and never accrue heat at all. +Heat is credited to a road for anything within its corridor — half the tarmac +plus a 7.5 m verge, so it scales with the class — not just the tarmac itself. +Otherwise the dominant strategy is to drive *next to* every road and never +accrue heat at all. -Two consequences follow from that, and both are deliberate: +Two consequences follow, and both are deliberate: - **The cutoff is hard, not a taper.** A taper leaves a gradient to optimise - along — sit at 80% of the catchment, take 20% of the heat. A cliff edge means + along — sit at 80% of the corridor, take 20% of the heat. A cliff edge means you either use the route or you actually leave it. - **Barricades span the full corridor**, well past the kerb. A checkpoint that stopped at the tarmac would be free to round on the verge at speed. -12 m is chosen, not arbitrary: it puts ~7.5 m of verge beyond the kerb (about -two car widths, so hugging the shoulder gains nothing) while leaving 57% of the -map genuinely off-route. Widening it backfires — at 20 m two thirds of the map -counts as on-route, heat becomes unavoidable, and "take a different route" stops -being a choice at all. +The 7.5 m verge is about two car widths, so hugging the shoulder gains nothing, +while leaving most of the map genuinely off-route. Widening it backfires: make +the corridor too generous and heat becomes unavoidable everywhere, and "take a +different route" stops being a choice at all. + +### Buildings, and why they are dense + +Off-road used to be open country, which made every barricade optional — you just +swung wide around it. Buildings are now laid out on a jittered lattice (random +scatter cannot pack boxes and saturated at a third of the target), which takes +bypass lanes around a checkpoint from none obstructed to about two thirds. You +can still get around one; it means threading a maze at walking pace instead of a +free detour at speed. ### Other deliberate choices @@ -104,10 +135,37 @@ which is the tension the brief asks for. 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. +Four jobs run end to end, and they differ in **pacing and risk**, not just in +the noun on the board — otherwise four mission types is one mission type with +four labels: + +| Job | At the target | The risk | +|---|---|---| +| **Supply run** | 3s unloading | the drive out | +| **Retrieval** | 5s loading | the drive *home*, with goods that break | +| **Intel drop** | 9s transmitting | a long wait, stationary, in the open | +| **Survey** | 2s | cheapest on site; the payoff is the map | + +Retrieval is the one that carries something home, and impacts damage it. It pays +the most, but it pays for what *arrives*, not what you set off with. 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. + +### Work you find rather than work you are given + +Past the line, and only when you have nothing in hand, someone occasionally +turns up: a wounded stranger, a contact flashing headlights, a wreck worth +stripping. They are deliberately **not** on a board — you cannot plan a route to +one, compare it against anything, or shop between them. You take the detour in +front of you or you drive on. + +### Radio + +Chatter that frames who you are — an undercover driver being talked at by people +busy elsewhere. Every line is triggered by something actually happening in the +sim: crossing a border, a road you have made notorious, a car past saving, or +the front genuinely drifting your way, which is the brief's "the enemy has other +priorities" said out loud without ever showing a number. ### Rewards, and why repairs exist @@ -223,6 +281,8 @@ ever opening a browser. src/ sim/ pure model, no engine imports: world, roads, heat — the map and its memory + radio, opportunities — texture: chatter and field work + save — the campaign as JSON regions — the front line and who holds what intel, routing, quests — what the player knows and is asked to do bases, car — where missions come from, and decline @@ -230,6 +290,7 @@ src/ render/ three.js scene, roads, markers, chase camera core/ fixed-timestep loop, seeded RNG, keyboard ui/ debug HUD, quest board, sketch minimap + persistence.ts integration layer: saves in localStorage carSpec.ts shared car dimensions, so body and mesh cannot drift apart heatProps.ts integration layer: heat levels → colliders + meshes ``` @@ -300,6 +361,12 @@ one within a couple of minutes, from tone alone, without reading the HUD line that names it. If you cannot, the palettes in `render/scene.ts` are too close together — push them apart before trusting the band boundaries. +**Phase 5:** someone coming to this cold can describe what kind of game it is +and who they are playing after one session, without the premise being explained +first. If the four jobs still feel like one job with four names, the dial is +`MISSION_SHAPE` — push the work times and the cargo risk further apart before +adding anything new. + **Phase 4:** returning to a region after time away, you are genuinely unsure what you will find until you are back on the ground — and when it has changed, it reads as the world having moved rather than as a notification you were given. @@ -307,11 +374,14 @@ If the world instead feels arbitrary, the drift is too fast: lengthen the wave periods in `sim/regions.ts` before shrinking their amplitude, since it is the *rate* of change that reads as noise, not the size of it. -Next is **Phase 5** — narrative and mission texture, all four mission types made -to feel distinct, and signals that the enemy has other priorities. Before that, -two things are worth doing regardless: persistence (nothing survives a reload) -and taking the debug lines out of the HUD, since several gates now depend on -reading the world rather than a readout. +Phases 0–5 are all in. **Phase 6** is the deferred pile: a deeper repair economy +with scavenged parts and on-foot work, car identity and disguise feeding into +heat, and expanded combat. Nothing there should be pulled forward until the loop +below it is proven. + +The most useful thing before any of that is taking the debug lines out of the +HUD. Several gates now depend on reading the world rather than a readout, and +the scaffolding actively works against you when you sit down to judge them. 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 diff --git a/src/main.ts b/src/main.ts index 635473e..c0f5cc2 100644 --- a/src/main.ts +++ b/src/main.ts @@ -15,11 +15,20 @@ import { HEAT_MULTIPLIER, } from './sim/regions'; import { createMinimap } from './ui/minimap'; -import { accept, createQuests, offersAt, stepQuest, type Offer } from './sim/quests'; +import { + accept, + createQuests, + MISSION_SHAPE, + offersAt, + stepQuest, + type Offer, +} from './sim/quests'; +import { createRadio, pollRadio } from './sim/radio'; +import { createOpportunities, stepOpportunities } from './sim/opportunities'; import { createHeatProps } from './heatProps'; import { createPersistence } from './persistence'; import { apply, type Snapshot } from './sim/save'; -import { seedFromString } from './core/rng'; +import { makeRng, seedFromString } from './core/rng'; import { startLoop } from './core/loop'; import { createInput } from './core/input'; import { createPhysics } from './physics/physics'; @@ -73,6 +82,9 @@ async function boot() { const heatProps = createHeatProps(model.roads, physics, view.scene); const intel = createIntel(model.roads, model.extent); const quests = createQuests(); + const radio = createRadio(); + const opportunities = createOpportunities(); + const chatterRng = makeRng(seed ^ 0x2c1b3c6d); let condition = freshCondition(); let elapsed = 0; @@ -95,6 +107,9 @@ async function boot() { let notice = ''; let noticeUntil = 0; let lastMinimapDraw = -1; + let lastFrontPosition = front.boundaries.liberated; + /** Smoothed, because a single step's movement is far too noisy to react to. */ + let driftAverage = 0; const say = (text: string, seconds = 4) => { notice = text; @@ -201,11 +216,14 @@ async function boot() { // Wear is applied from what actually happened this step, not from intent. const speed = physics.vehicle.currentVehicleSpeed(); const distance = Math.abs(speed) * dt; + // Read once: both the car's wear and any cargo aboard are damaged by the + // same knock, and draining the queue twice would lose one of them. + const lastImpact = physics.drainImpactForce(); condition = applyWear(condition, { dt, distance, throttle: Math.abs(cmd.throttle), - impactForce: physics.drainImpactForce(), + impactForce: lastImpact, }); // Heat: the road remembers being used — including being driven alongside. @@ -268,7 +286,7 @@ async function boot() { accept(quests, chosen, base); board.hide(); openBase = null; - say(`${chosen.type === 'recon' ? 'Survey' : 'Supply run'} accepted.`); + say(`${MISSION_SHAPE[chosen.type].label} accepted.`); // Checkpoint: taking a job is a decision worth not having to make twice. persistence.checkpoint(elapsed, snapshot()); } @@ -286,11 +304,14 @@ async function boot() { distanceToTarget: Math.hypot(target.x - at.x, target.z - at.z), atBase: base, speed: Math.abs(speed), + impactForce: lastImpact, }); if (event?.kind === 'arrived') { intel.visitedNodes.add(quest.targetNode); - say('At the target. Stop the car and hold position.'); + say(`At the target. Stop the car — ${MISSION_SHAPE[quest.type].working}.`); + } else if (event?.kind === 'cargoDamaged') { + if (event.cargo < 0.5) say('That was the cargo taking the hit, not you.', 3); } else if (event?.kind === 'worked') { if (event.type === 'recon') { const count = survey( @@ -304,6 +325,10 @@ async function boot() { controlOf, ); say(`Survey complete — ${count} roads mapped. Report back to any base.`); + } else if (event.type === 'retrieval') { + say('Loaded. Get it back in one piece — damaged goods pay less.'); + } else if (event.type === 'intel') { + say('Transmission sent. Move before anyone triangulates it.'); } else { say('Cargo delivered. Report back to any base.'); } @@ -325,6 +350,53 @@ async function boot() { } } + // --- Work found rather than given --- + const found = stepOpportunities( + opportunities, + { + dt, + now: elapsed, + control, + carX: at.x, + carZ: at.z, + onMission: quests.active !== null, + }, + model.roads, + chatterRng, + stopped, + ); + if (found?.kind === 'appeared') say(found.opportunity.hail, 7); + else if (found?.kind === 'helped') { + quests.parts += found.opportunity.reward; + if (found.opportunity.impact > 0) applyMissionImpact(front, found.opportunity.impact); + say(found.opportunity.done, 7); + persistence.checkpoint(elapsed, snapshot()); + } + + // --- Radio --- + // Front drift is measured, not narrated: the chatter about the enemy + // being busy elsewhere fires because the line really is moving. + const drift = (front.boundaries.liberated - lastFrontPosition) / dt; + lastFrontPosition = front.boundaries.liberated; + driftAverage += (drift - driftAverage) * Math.min(1, dt / 3); + if (!notice || elapsed > noticeUntil) { + const line = pollRadio( + radio, + { + now: elapsed, + control, + roadClass: currentSegment === null ? null : model.roads.segments[currentSegment]!.cls, + heatLevel: currentSegment === null ? null : heat.level[currentSegment]!, + condition, + frontDrift: driftAverage, + cargo: quests.active?.cargo ?? null, + carrying: quests.active?.stage === 'return', + }, + chatterRng, + ); + if (line) say(line, 8); + } + persistence.tick(elapsed, snapshot); // Once the cargo is dropped or the survey is done, the target is no longer @@ -383,6 +455,7 @@ async function boot() { // Yaw about +Y, from world +Z, which is the car's local forward. heading: Math.atan2(2 * (r.w * r.y + r.x * r.z), 1 - 2 * (r.y * r.y + r.x * r.x)), objective: quest && quest.stage !== 'return' ? target : null, + opportunity: opportunities.current, }); } hud.update(physics.vehicle.currentVehicleSpeed(), condition, elapsed, { @@ -399,6 +472,7 @@ async function boot() { stage: quest.stage, novel: quest.novel, worked: quest.worked, + cargo: quest.cargo, distance: Math.hypot(target.x - p.x, target.z - p.z), bearing: Math.atan2(target.x - p.x, target.z - p.z) - diff --git a/src/sim/opportunities.ts b/src/sim/opportunities.ts new file mode 100644 index 0000000..49c6819 --- /dev/null +++ b/src/sim/opportunities.ts @@ -0,0 +1,159 @@ +/** + * Work you find rather than work you are given. Pure — no engine imports. + * + * The brief wants quest sources beyond allied bases: injured people, other + * contacts, chances spotted in the field. They are deliberately *not* offered on + * a board — you cannot plan around them, weigh a route to them, or shop between + * them. You either take the detour that is in front of you or you drive on. + * + * They only exist past the line. Behind your own, nobody needs a stranger with + * a car, and a safe errand would defeat the point of the detour being a risk. + */ +import type { Rng } from '../core/rng'; +import type { Control } from './regions'; +import type { RoadNetwork } from './roads'; + +export type OpportunityKind = 'wounded' | 'contact' | 'wreck'; + +export interface Opportunity { + kind: OpportunityKind; + x: number; + z: number; + /** Parts paid for stopping. */ + reward: number; + /** Whether helping shifts the front at all. */ + impact: number; + hail: string; + done: string; +} + +export interface OpportunityState { + current: Opportunity | null; + /** Time the last one was resolved or expired, for pacing. */ + lastAt: number; + taken: number; +} + +export const createOpportunities = (): OpportunityState => ({ + current: null, + lastAt: -999, + taken: 0, +}); + +/** Minimum seconds between field jobs, so they stay events rather than traffic. */ +const SPACING = 150; +/** Chance per second of one appearing, once spacing allows. */ +const CHANCE_PER_SECOND = 0.06; +/** How close counts as having stopped to help. */ +export const OPPORTUNITY_RADIUS = 16; +/** Beyond this the person gives up on you and it lapses. */ +const ABANDON_DISTANCE = 320; + +const FLAVOUR: Record = { + wounded: { + hail: [ + 'Someone is waving you down. They are hurt, and they are not armed.', + 'A figure at the roadside, holding an arm wrong. No uniform.', + ], + done: [ + 'They can walk from here. They did not ask your name, which was kind of them.', + 'Patched up as far as anything in your boot allows. They point you at a road they say is quiet.', + ], + reward: 0.06, + impact: 3, + }, + contact: { + hail: [ + 'A car flashes its lights twice, then once. One of ours, or someone who knows the signal.', + 'Someone is standing where nobody stands, and they are looking at your car specifically.', + ], + done: [ + 'A package changes hands. Neither of you says anything worth repeating.', + 'They pass you a name and a road number, then walk off before you can ask.', + ], + reward: 0.14, + impact: 9, + }, + wreck: { + hail: [ + 'A burned-out vehicle off the verge. Something in it is still worth having.', + 'Someone left in a hurry and left the useful parts behind.', + ], + done: [ + 'You strip what will fit in the boot and leave the rest to rust.', + 'Two good parts and a lot of scrap. It will do.', + ], + reward: 0.11, + impact: 0, + }, +}; + +export interface OpportunityStep { + dt: number; + now: number; + control: Control; + carX: number; + carZ: number; + /** True while a mission is in hand — field work is a detour, not a queue. */ + onMission: boolean; +} + +export type OpportunityEvent = + | { kind: 'appeared'; opportunity: Opportunity } + | { kind: 'helped'; opportunity: Opportunity } + | { kind: 'lapsed' }; + +export function stepOpportunities( + state: OpportunityState, + step: OpportunityStep, + roads: RoadNetwork, + rng: Rng, + stopped: boolean, +): OpportunityEvent | null { + if (state.current) { + const distance = Math.hypot(state.current.x - step.carX, state.current.z - step.carZ); + if (distance < OPPORTUNITY_RADIUS && stopped) { + const opportunity = state.current; + state.current = null; + state.lastAt = step.now; + state.taken++; + return { kind: 'helped', opportunity }; + } + // Drive far enough past and whoever it was stops waiting. + if (distance > ABANDON_DISTANCE) { + state.current = null; + state.lastAt = step.now; + return { kind: 'lapsed' }; + } + return null; + } + + // Only past the line, only when not already committed to a job. + if (step.onMission) return null; + if (step.control === 'liberated') return null; + if (step.now - state.lastAt < SPACING) return null; + if (rng() > CHANCE_PER_SECOND * step.dt) return null; + + // Put it on a junction a short way off, so it reads as somewhere you could + // plausibly have spotted rather than something conjured onto your bumper. + const reachable = roads.nodes.filter((n) => { + const d = Math.hypot(n.x - step.carX, n.z - step.carZ); + return d > 60 && d < 220; + }); + if (reachable.length === 0) return null; + const node = reachable[Math.floor(rng() * reachable.length)]!; + + const kind = (['wounded', 'contact', 'wreck'] as const)[Math.floor(rng() * 3)]!; + const flavour = FLAVOUR[kind]; + const opportunity: Opportunity = { + kind, + x: node.x, + z: node.z, + reward: flavour.reward, + impact: flavour.impact, + hail: flavour.hail[Math.floor(rng() * flavour.hail.length)]!, + done: flavour.done[Math.floor(rng() * flavour.done.length)]!, + }; + state.current = opportunity; + return { kind: 'appeared', opportunity }; +} diff --git a/src/sim/quests.test.ts b/src/sim/quests.test.ts index 7a5a90c..a0ca3d8 100644 --- a/src/sim/quests.test.ts +++ b/src/sim/quests.test.ts @@ -4,7 +4,7 @@ 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'; +import { accept, createQuests, MISSION_SHAPE, offersAt, stepQuest } from './quests'; const world = generateWorld(1337); const graph = buildGraph(world.roads); @@ -188,7 +188,7 @@ describe('mission lifecycle', () => { expect(state.active!.worked).toBe(0); let worked = null; - for (let i = 0; i < WORK_SECONDS * 60 + 2 && !worked; i++) { + for (let i = 0; i < MISSION_SHAPE[state.active!.type].work * 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 }); @@ -209,7 +209,7 @@ describe('mission lifecycle', () => { 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++) { + for (let i = 0; i < MISSION_SHAPE[state.active!.type].work * 60 + 2; i++) { stepQuest(state, { dt: 1 / 60, distanceToTarget: 5, atBase: null, speed: 0 }); } const elsewhere = bases.find((b) => b.nodeId !== state.active!.originBase)!; diff --git a/src/sim/quests.ts b/src/sim/quests.ts index ff2f09d..da42315 100644 --- a/src/sim/quests.ts +++ b/src/sim/quests.ts @@ -12,7 +12,57 @@ import { summariseRoute, type Intel, type RouteIntel } from './intel'; import { findRoute, travelTime, type Graph, type Route } from './routing'; import type { RoadNetwork } from './roads'; -export type MissionType = 'delivery' | 'recon'; +/** + * The four jobs the insurgency actually needs doing. + * + * They are meant to differ in *pacing and risk*, not just in the noun on the + * board — otherwise "four mission types" is one mission type with four labels. + * The shape of each is set by MISSION_SHAPE below. + */ +export type MissionType = 'supply' | 'retrieval' | 'intel' | 'recon'; + +export interface MissionShape { + label: string; + /** Seconds stopped at the target. */ + work: number; + /** Words for what you are doing while parked there. */ + working: string; + /** True if you are carrying something home that can be lost. */ + cargoHome: boolean; + /** Multiplier on parts paid. */ + reward: number; + /** Multiplier on how far the job shifts the front. */ + impact: number; +} + +export const MISSION_SHAPE: Record = { + // Quick drop, paid on the way back. The baseline. + supply: { label: 'Supply run', work: 3, working: 'unloading', cargoHome: false, reward: 1, impact: 1.2 }, + // The dangerous one: the risk is all on the return leg, with the goods aboard + // and a road you have already made hot behind you. + retrieval: { + label: 'Retrieval', + work: 5, + working: 'loading', + cargoHome: true, + reward: 1.35, + impact: 1.1, + }, + // A long stationary wait in hostile ground. Nothing to carry, but the sitting + // still is the risk, and it is the one job that pays in front-line movement. + intel: { + label: 'Intel drop', + work: 9, + working: 'transmitting', + cargoHome: false, + reward: 1.1, + impact: 1.5, + }, + // Cheap and quick on site; the payoff is the map, not the money. + recon: { label: 'Survey', work: 2, working: 'surveying', cargoHome: false, reward: 1.15, impact: 0.8 }, +}; + +export const MISSION_TYPES = Object.keys(MISSION_SHAPE) as MissionType[]; export interface Offer { id: number; @@ -38,6 +88,8 @@ export interface ActiveQuest extends Offer { stage: 'travel' | 'working' | 'return'; /** Seconds of work completed at the target. */ worked: number; + /** Condition of the goods being carried home, 1..0. Retrieval jobs only. */ + cargo: number; } export interface QuestState { @@ -55,10 +107,13 @@ export const createQuests = (): QuestState => ({ 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; +/** + * Share of the cargo's condition lost per newton-ish of impact on the way home. + * Retrieval pays more, but only if what you are carrying survives the trip. + */ +const CARGO_FRAGILITY = 1.4e-6; /** * Novel targets pay this much more. This multiplier *is* the phase's tuning @@ -67,11 +122,10 @@ export const ARRIVAL_RADIUS = 18; */ 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); + return base * (novel ? NOVELTY_BONUS : 1) * MISSION_SHAPE[type].reward; } /** Metres of front-line movement a job is worth. */ @@ -79,10 +133,8 @@ const IMPACT_BASE = 5; const IMPACT_PER_KM = 11; function impactFor(type: MissionType, route: Route, novel: boolean): number { - // Supply runs move the line more than surveys: intelligence is worth having, - // but it is materiel that holds ground. const base = IMPACT_BASE + (route.length / 1000) * IMPACT_PER_KM; - return base * (novel ? 1.4 : 1) * (type === 'delivery' ? 1.2 : 0.8); + return base * (novel ? 1.4 : 1) * MISSION_SHAPE[type].impact; } /** @@ -127,7 +179,7 @@ export function offersAt( // driver would actually go: trunk where there is one, lanes where there is not. const route = findRoute(graph, base.nodeId, node, travelTime(roads)); if (!route) continue; - const type: MissionType = rng() < 0.5 ? 'recon' : 'delivery'; + const type = MISSION_TYPES[Math.floor(rng() * MISSION_TYPES.length)]!; offers.push({ id: state.nextId++, type, @@ -143,7 +195,7 @@ export function offersAt( } export function accept(state: QuestState, offer: Offer, base: Base): void { - state.active = { ...offer, originBase: base.nodeId, stage: 'travel', worked: 0 }; + state.active = { ...offer, originBase: base.nodeId, stage: 'travel', worked: 0, cargo: 1 }; } export interface QuestStep { @@ -154,12 +206,15 @@ export interface QuestStep { atBase: Base | null; /** Metres per second, so "stopped" can be required for work. */ speed: number; + /** Impact force this step, which damages anything being carried. */ + impactForce?: number; } export type QuestEvent = | { kind: 'arrived' } | { kind: 'worked'; type: MissionType } - | { kind: 'completed'; parts: number; impact: number }; + | { kind: 'cargoDamaged'; cargo: number } + | { kind: 'completed'; parts: number; impact: number; cargo: number }; /** Advances the active mission. Returns whatever just happened, if anything. */ export function stepQuest(state: QuestState, step: QuestStep): QuestEvent | null { @@ -185,20 +240,32 @@ export function stepQuest(state: QuestState, step: QuestStep): QuestEvent | null } if (!stopped) return null; quest.worked += step.dt; - if (quest.worked >= WORK_SECONDS) { + if (quest.worked >= MISSION_SHAPE[quest.type].work) { quest.stage = 'return'; return { kind: 'worked', type: quest.type }; } return null; } + // On the way home. Anything being carried is now at risk — which is what + // makes retrieval a different job rather than a supply run in reverse. + let damaged = false; + if (MISSION_SHAPE[quest.type].cargoHome && step.impactForce) { + const before = quest.cargo; + quest.cargo = Math.max(0, quest.cargo - step.impactForce * CARGO_FRAGILITY); + damaged = quest.cargo < before - 1e-6; + } + // 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) { + // Damaged goods are worth less. The job still counts; it just paid badly. + const paid = quest.reward * (MISSION_SHAPE[quest.type].cargoHome ? quest.cargo : 1); state.active = null; state.completed++; - state.parts += quest.reward; - return { kind: 'completed', parts: quest.reward, impact: quest.impact }; + state.parts += paid; + return { kind: 'completed', parts: paid, impact: quest.impact * quest.cargo, cargo: quest.cargo }; } - return null; + + return damaged ? { kind: 'cargoDamaged', cargo: quest.cargo } : null; } diff --git a/src/sim/radio.ts b/src/sim/radio.ts new file mode 100644 index 0000000..154a29d --- /dev/null +++ b/src/sim/radio.ts @@ -0,0 +1,179 @@ +/** + * Radio chatter. Pure — no engine imports. + * + * Phase 5's job is texture on systems that already work, so nothing here + * changes any outcome. What it does is tell the player *who they are*: an + * undercover driver being talked at by people who are busy elsewhere. + * + * Two rules keep it from becoming noise: + * - every line is triggered by something actually happening in the sim + * - anything already obvious from the windscreen is not worth saying + */ +import type { Rng } from '../core/rng'; +import type { CarCondition } from './car'; +import type { HeatLevel } from './heat'; +import type { Control } from './regions'; +import type { RoadClass } from './roads'; + +export interface RadioState { + /** Last time each topic was aired, so nothing repeats itself into wallpaper. */ + lastAt: Record; + /** Last line used per topic, so a topic never says exactly the same thing twice running. */ + lastLine: Record; + lastAny: number; + lastControl: Control | null; +} + +export const createRadio = (): RadioState => ({ + lastAt: {}, + lastLine: {}, + lastAny: -99, + lastControl: null, +}); + +export interface RadioContext { + now: number; + control: Control; + /** Road under the wheels, if any. */ + roadClass: RoadClass | null; + heatLevel: HeatLevel | null; + condition: CarCondition; + /** Metres per second the front line is moving. Negative means losing ground. */ + frontDrift: number; + /** Cargo condition, when carrying something home. */ + cargo: number | null; + carrying: boolean; + /** Filled in by pollRadio from the previous call; callers leave it unset. */ + lastControlWasHostile?: boolean; +} + +/** Minimum gap between any two lines, so the radio is never chattering. */ +const QUIET = 22; + +interface Topic { + id: string; + /** Seconds before this topic may come round again. */ + cooldown: number; + when: (c: RadioContext) => boolean; + lines: string[]; +} + +const TOPICS: Topic[] = [ + { + id: 'crossing-out', + cooldown: 90, + when: (c) => c.control === 'occupied' || c.control === 'frontier', + lines: [ + 'You are past the line now. Papers in the door pocket, and slow down for anyone in uniform.', + 'From here you are a haulier with a bad map. Nothing else.', + 'Out here nobody is coming for you if it goes wrong. Drive like you know that.', + ], + }, + { + id: 'crossing-home', + cooldown: 90, + when: (c) => c.control === 'liberated' && c.lastControlWasHostile === true, + lines: [ + 'Ours from here. You can stop pretending to be a haulier.', + 'Back inside the line. Nobody is writing down your plates on this stretch.', + ], + }, + { + id: 'enemy-elsewhere', + cooldown: 120, + // The brief is explicit that ambient drift *is* the enemy's attention being + // pulled elsewhere. This says so out loud without ever giving a number. + when: (c) => c.frontDrift > 0.06, + lines: [ + 'Something has their attention somewhere else today. Make use of it.', + 'Their line is thinner than it was. Nobody here knows why.', + 'Word is they have moved a column off this sector. Word is often wrong.', + ], + }, + { + id: 'enemy-pressing', + cooldown: 120, + when: (c) => c.frontDrift < -0.06, + lines: [ + 'They are pushing on this sector. Whatever you knew about it, know it less.', + 'We are giving ground north of you. Do not count on the way you came in.', + ], + }, + { + id: 'hot-road', + cooldown: 60, + when: (c) => c.heatLevel === 'barricade' || c.heatLevel === 'turret', + lines: [ + 'This stretch has a reputation now. That is your doing.', + 'They have put money into this road. They only do that for roads people use.', + ], + }, + { + id: 'track', + cooldown: 150, + when: (c) => c.roadClass === 'track' && c.control !== 'liberated', + lines: [ + 'Nobody watches the farm lanes. Slower, but nobody watches them.', + 'Stay off the big roads out here if you have the time to spare.', + ], + }, + { + id: 'car-failing', + cooldown: 100, + when: (c) => c.condition.level.engine < 0.45 || c.condition.level.tires < 0.45, + lines: [ + 'That engine sounds like it has opinions. Bring it back while it still runs.', + 'You are not getting another one of those. Nurse it.', + ], + }, + { + id: 'car-permanent', + cooldown: 240, + when: (c) => c.condition.ceiling.chassis < 0.7, + lines: [ + 'We can patch it. We cannot make it what it was.', + 'Some of that damage is in the frame now. It stays there.', + ], + }, + { + id: 'cargo', + cooldown: 45, + when: (c) => c.carrying && c.cargo !== null && c.cargo < 0.75, + lines: [ + 'Whatever is in the back is taking a beating. Ease off.', + 'They will pay for what arrives intact, not for what you set off with.', + ], + }, +]; + +/** + * Returns a line to air, or null. Call it freely; it decides when to speak. + */ +export function pollRadio(state: RadioState, context: RadioContext, rng: Rng): string | null { + const ctx: RadioContext = { + ...context, + lastControlWasHostile: + state.lastControl !== null && state.lastControl !== 'liberated', + }; + const crossed = state.lastControl !== ctx.control; + state.lastControl = ctx.control; + + // Crossing a border is worth interrupting for; nothing else is. + if (!crossed && ctx.now - state.lastAny < QUIET) return null; + + for (const topic of TOPICS) { + if (ctx.now - (state.lastAt[topic.id] ?? -99) < topic.cooldown) continue; + if (!topic.when(ctx)) continue; + // Never the same sentence twice running from one topic. Hearing a line + // repeat verbatim is what turns radio texture into wallpaper. + const fresh = topic.lines.filter((l) => l !== state.lastLine[topic.id]); + const pool = fresh.length > 0 ? fresh : topic.lines; + const line = pool[Math.floor(rng() * pool.length)]!; + + state.lastAt[topic.id] = ctx.now; + state.lastLine[topic.id] = line; + state.lastAny = ctx.now; + return line; + } + return null; +} diff --git a/src/sim/save.test.ts b/src/sim/save.test.ts index cdec218..d62920b 100644 --- a/src/sim/save.test.ts +++ b/src/sim/save.test.ts @@ -113,7 +113,7 @@ describe('save round trip', () => { const source = played(); source.quests.active = { id: 4, - type: 'delivery', + type: 'supply', targetNode: 11, novel: true, route: { nodes: [1, 2], segments: [0], length: 120 }, @@ -123,6 +123,7 @@ describe('save round trip', () => { originBase: 1, stage: 'return', worked: 3, + cargo: 1, }; const restored = blank(); apply(JSON.parse(JSON.stringify(serialise(source))), restored); diff --git a/src/sim/texture.test.ts b/src/sim/texture.test.ts new file mode 100644 index 0000000..5639e87 --- /dev/null +++ b/src/sim/texture.test.ts @@ -0,0 +1,264 @@ +import { describe, expect, it } from 'vitest'; +import { makeRng } from '../core/rng'; +import { generateWorld } from './world'; +import { placeBases } from './bases'; +import { buildGraph } from './routing'; +import { createIntel } from './intel'; +import { freshCondition, applyWear } from './car'; +import { + accept, + createQuests, + MISSION_SHAPE, + MISSION_TYPES, + offersAt, + stepQuest, + type MissionType, +} from './quests'; +import { createRadio, pollRadio, type RadioContext } from './radio'; +import { createOpportunities, stepOpportunities, OPPORTUNITY_RADIUS } from './opportunities'; + +const world = generateWorld(1337); +const graph = buildGraph(world.roads); +const bases = placeBases(world.roads, world.spawn); + +describe('the four mission types', () => { + it('are actually four, and all reachable from the board', () => { + const seen = new Set(); + const state = createQuests(); + const intel = createIntel(world.roads, world.extent); + for (let seed = 0; seed < 60; seed++) { + for (const offer of offersAt(state, world.roads, graph, intel, bases[0]!, 0, seed)) { + seen.add(offer.type); + } + } + expect([...seen].sort()).toEqual([...MISSION_TYPES].sort()); + }); + + it('differ in pacing, not just in name', () => { + const durations = MISSION_TYPES.map((t) => MISSION_SHAPE[t].work); + // If every job took the same time at the far end, they would be one job. + expect(new Set(durations).size).toBe(MISSION_TYPES.length); + expect(MISSION_SHAPE.intel.work).toBeGreaterThan(MISSION_SHAPE.recon.work * 3); + }); + + it('only puts cargo at risk on the job that carries something home', () => { + const carrying = MISSION_TYPES.filter((t) => MISSION_SHAPE[t].cargoHome); + expect(carrying).toEqual(['retrieval']); + }); + + it('pays more for retrieval, which is the one that can go wrong on the way back', () => { + expect(MISSION_SHAPE.retrieval.reward).toBeGreaterThan(MISSION_SHAPE.supply.reward); + }); +}); + +/** Runs a mission through to hand-in, optionally taking knocks on the way home. */ +function runMission(type: MissionType, impactPerStep = 0) { + const state = createQuests(); + const intel = createIntel(world.roads, world.extent); + const offer = offersAt(state, world.roads, graph, intel, bases[0]!, 0, 1)[0]!; + accept(state, { ...offer, type }, bases[0]!); + + stepQuest(state, { dt: 1 / 60, distanceToTarget: 5, atBase: null, speed: 0 }); + for (let i = 0; i < MISSION_SHAPE[type].work * 60 + 4; i++) { + stepQuest(state, { dt: 1 / 60, distanceToTarget: 5, atBase: null, speed: 0 }); + } + expect(state.active!.stage).toBe('return'); + + // The drive home, with or without incident. + for (let i = 0; i < 600; i++) { + stepQuest(state, { + dt: 1 / 60, + distanceToTarget: 500, + atBase: null, + speed: 20, + impactForce: impactPerStep, + }); + } + const cargo = state.active!.cargo; + const done = stepQuest(state, { + dt: 1 / 60, + distanceToTarget: 900, + atBase: bases[1]!, + speed: 0, + impactForce: 0, + }); + return { done, cargo, parts: state.parts }; +} + +describe('carrying cargo home', () => { + it('arrives intact if the drive home is clean', () => { + const { cargo, done } = runMission('retrieval'); + expect(cargo).toBe(1); + expect(done?.kind).toBe('completed'); + }); + + it('pays less for goods you smashed on the way back', () => { + const clean = runMission('retrieval'); + const rough = runMission('retrieval', 4e4); + expect(rough.cargo).toBeLessThan(clean.cargo); + expect(rough.parts).toBeLessThan(clean.parts); + }); + + it('leaves other mission types alone, however hard you crash', () => { + const rough = runMission('supply', 8e4); + expect(rough.cargo).toBe(1); + expect(rough.parts).toBeCloseTo(runMission('supply').parts, 9); + }); +}); + +const baseContext = (): RadioContext => ({ + now: 1000, + control: 'occupied', + roadClass: 'road', + heatLevel: 'clear', + condition: freshCondition(), + frontDrift: 0, + cargo: null, + carrying: false, +}); + +describe('radio', () => { + const rng = makeRng(4); + + it('says something on crossing into hostile ground', () => { + const state = createRadio(); + expect(pollRadio(state, { ...baseContext(), control: 'liberated' }, rng)).toBeNull(); + expect(pollRadio(state, baseContext(), rng)).not.toBeNull(); + }); + + it('does not chatter continuously', () => { + const state = createRadio(); + pollRadio(state, baseContext(), rng); + // Same second, same situation: nothing more to say. + expect(pollRadio(state, baseContext(), rng)).toBeNull(); + }); + + it('never repeats a topic back to back', () => { + const state = createRadio(); + const lines: string[] = []; + for (let t = 0; t < 40; t++) { + const line = pollRadio(state, { ...baseContext(), now: t * 30 }, rng); + if (line) lines.push(line); + } + expect(lines.length).toBeGreaterThan(2); + for (let i = 1; i < lines.length; i++) expect(lines[i]).not.toBe(lines[i - 1]); + }); + + it('mentions the enemy being busy elsewhere only when the line is really moving', () => { + const quiet = createRadio(); + const lines: string[] = []; + for (let t = 0; t < 30; t++) { + const line = pollRadio(quiet, { ...baseContext(), now: t * 40, frontDrift: 0.2 }, rng); + if (line) lines.push(line); + } + expect(lines.some((l) => /elsewhere|thinner|column/i.test(l))).toBe(true); + }); + + it('comments on a car that is permanently damaged, not just dirty', () => { + let condition = freshCondition(); + for (let i = 0; i < 400; i++) { + condition = applyWear(condition, { dt: 1 / 60, distance: 3, throttle: 1, impactForce: 9e4 }); + } + const state = createRadio(); + const lines: string[] = []; + for (let t = 0; t < 40; t++) { + const line = pollRadio(state, { ...baseContext(), now: t * 40, condition }, rng); + if (line) lines.push(line); + } + expect(lines.some((l) => /patch|frame/i.test(l))).toBe(true); + }); +}); + +describe('work found in the field', () => { + const rng = makeRng(11); + const step = (overrides: Partial[1]> = {}) => ({ + dt: 1, + now: 9999, + control: 'occupied' as const, + carX: world.spawn.x, + carZ: world.spawn.z, + onMission: false, + ...overrides, + }); + + it('never turns up behind your own lines', () => { + const state = createOpportunities(); + for (let i = 0; i < 500; i++) { + stepOpportunities(state, step({ control: 'liberated', now: 9999 + i }), world.roads, rng, false); + } + expect(state.current).toBeNull(); + }); + + it('never interrupts a job you already took', () => { + const state = createOpportunities(); + for (let i = 0; i < 500; i++) { + stepOpportunities(state, step({ onMission: true, now: 9999 + i }), world.roads, rng, false); + } + expect(state.current).toBeNull(); + }); + + it('turns up past the line, at a distance you have to choose to drive', () => { + const state = createOpportunities(); + let appeared = null; + for (let i = 0; i < 500 && !appeared; i++) { + appeared = stepOpportunities(state, step({ now: 9999 + i }), world.roads, rng, false); + } + expect(appeared?.kind).toBe('appeared'); + const distance = Math.hypot(state.current!.x - world.spawn.x, state.current!.z - world.spawn.z); + // Far enough to be a detour, close enough to be worth considering. + expect(distance).toBeGreaterThan(60); + expect(distance).toBeLessThan(220); + expect(state.current!.hail.length).toBeGreaterThan(10); + }); + + it('pays out when you stop for it', () => { + const state = createOpportunities(); + for (let i = 0; i < 500 && !state.current; i++) { + stepOpportunities(state, step({ now: 9999 + i }), world.roads, rng, false); + } + const spot = state.current!; + const event = stepOpportunities( + state, + step({ carX: spot.x, carZ: spot.z, now: 12000 }), + world.roads, + rng, + true, + ); + expect(event?.kind).toBe('helped'); + expect(state.taken).toBe(1); + expect(state.current).toBeNull(); + }); + + it('does not pay out if you only drive past without stopping', () => { + const state = createOpportunities(); + for (let i = 0; i < 500 && !state.current; i++) { + stepOpportunities(state, step({ now: 9999 + i }), world.roads, rng, false); + } + const spot = state.current!; + const event = stepOpportunities( + state, + step({ carX: spot.x + OPPORTUNITY_RADIUS - 2, carZ: spot.z, now: 12000 }), + world.roads, + rng, + false, + ); + expect(event).toBeNull(); + expect(state.taken).toBe(0); + }); + + it('lapses if you drive far enough away', () => { + const state = createOpportunities(); + for (let i = 0; i < 500 && !state.current; i++) { + stepOpportunities(state, step({ now: 9999 + i }), world.roads, rng, false); + } + const event = stepOpportunities( + state, + step({ carX: 99999, carZ: 99999, now: 12000 }), + world.roads, + rng, + false, + ); + expect(event?.kind).toBe('lapsed'); + expect(state.current).toBeNull(); + }); +}); diff --git a/src/ui/board.ts b/src/ui/board.ts index f3c547f..862c85a 100644 --- a/src/ui/board.ts +++ b/src/ui/board.ts @@ -1,6 +1,17 @@ import type { Base } from '../sim/bases'; import type { HeatLevel } from '../sim/heat'; -import type { Offer } from '../sim/quests'; +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 = { + 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 @@ -67,10 +78,11 @@ export function createBoard() { (offer, i) => `
  • [${i + 1}] - ${offer.type === 'recon' ? 'Survey' : 'Supply run'} — + ${MISSION_SHAPE[offer.type].label} — ${offer.novel ? 'new target' : 'known target'} · ${(offer.route.length / 1000).toFixed(1)} km · ${offer.reward.toFixed(2)} parts +
    ${BRIEFING[offer.type]}
    ${describe(offer)}
  • `, ) diff --git a/src/ui/hud.ts b/src/ui/hud.ts index 12bf57e..21099c8 100644 --- a/src/ui/hud.ts +++ b/src/ui/hud.ts @@ -1,8 +1,8 @@ import { SUBSYSTEMS, type CarCondition } from '../sim/car'; import type { HeatLevel } from '../sim/heat'; -import type { MissionType } from '../sim/quests'; +import { MISSION_SHAPE, type MissionType } from '../sim/quests'; import type { Control } from '../sim/regions'; -import { WORK_SECONDS } from '../sim/quests'; + const BAR_WIDTH = 12; @@ -31,6 +31,7 @@ export interface HudModel { stage: 'travel' | 'working' | 'return'; novel: boolean; worked: number; + cargo: number; distance: number; /** Radians from the car's nose to the target, positive to the left. */ bearing: number; @@ -57,13 +58,16 @@ const CONTROL_WORDS: Record = { }; function questLines(quest: NonNullable): string[] { - const label = quest.type === 'recon' ? 'Survey' : 'Supply run'; + const shape = MISSION_SHAPE[quest.type]; + const label = shape.label; const kind = quest.novel ? 'new target' : 'known target'; if (quest.stage === 'return') { - return [`${label} done — return to any base`]; + // Only retrieval has anything aboard that can be spoiled on the way home. + const cargo = shape.cargoHome ? ` · cargo ${(quest.cargo * 100).toFixed(0)}%` : ''; + return [`${label} done — return to any base${cargo}`]; } if (quest.stage === 'working') { - return [`${label} — working ${quest.worked.toFixed(1)}/${WORK_SECONDS}s (hold still)`]; + return [`${label} — ${shape.working} ${quest.worked.toFixed(1)}/${shape.work}s (hold still)`]; } return [ `${label} (${kind})`, diff --git a/src/ui/minimap.ts b/src/ui/minimap.ts index 1a32a2d..3fc7021 100644 --- a/src/ui/minimap.ts +++ b/src/ui/minimap.ts @@ -45,6 +45,8 @@ export interface MinimapView { /** Car heading in radians, matching the world's yaw convention. */ heading: number; objective: { x: number; z: number } | null; + /** A field contact waiting somewhere, if one has turned up. */ + opportunity: { x: number; z: number } | null; /** Elapsed time, for ageing observations. */ now: number; } @@ -133,6 +135,14 @@ export function createMinimap(roads: RoadNetwork, bases: Base[], worldExtent: nu ctx.stroke(); } + // --- A field contact: marked, but not the same as an assigned target --- + if (view.opportunity) { + ctx.beginPath(); + ctx.arc(px(view.opportunity.x), px(view.opportunity.z), 3.5, 0, Math.PI * 2); + ctx.fillStyle = '#cf6bd6'; + ctx.fill(); + } + // --- The car, as an arrow, so heading is readable at a glance --- const cx = px(view.x); const cz = px(view.z);