Phase 5: four real jobs, radio, and work found in the field

Mission types. Four of them, differing in pacing and risk rather than in the
noun on the board: a quick supply drop, a retrieval whose danger is all on the
return leg, a long stationary intel transmission in the open, and a cheap survey
whose payoff is the map. Retrieval carries cargo that impacts damage, and pays
for what arrives rather than what you set off with, which makes the drive home a
different problem from the drive out.

Radio. Chatter framing the player as 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 personally made notorious, a car past
saving, or the front genuinely drifting. The enemy-has-other-priorities line
fires off measured drift, so the brief's ambient-drift-is-the-mechanism idea is
said out loud without ever showing a number. Topics carry cooldowns and never
repeat a sentence back to back; a test caught that they could.

Field work. Past the line, with nothing in hand, a wounded stranger or a contact
or a strippable wreck occasionally turns up. Deliberately not on a board: no
route preview, no comparison, no shopping between them. Take the detour in front
of you or drive on. They never appear behind your own lines, where a safe errand
would defeat the point.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
dejvino 2026-08-07 13:35:37 +02:00
parent 90dc18fd17
commit 06c7555188
11 changed files with 890 additions and 50 deletions

108
README.md
View File

@ -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 05 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

View File

@ -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) -

159
src/sim/opportunities.ts Normal file
View File

@ -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<OpportunityKind, { hail: string[]; done: string[]; reward: number; impact: number }> = {
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 };
}

View File

@ -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)!;

View File

@ -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<MissionType, MissionShape> = {
// 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;
}

179
src/sim/radio.ts Normal file
View File

@ -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<string, number>;
/** Last line used per topic, so a topic never says exactly the same thing twice running. */
lastLine: Record<string, string>;
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;
}

View File

@ -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);

264
src/sim/texture.test.ts Normal file
View File

@ -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<MissionType>();
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<Parameters<typeof stepOpportunities>[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();
});
});

View File

@ -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<MissionType, string> = {
supply: 'drop the load and come back — quick work at the far end',
retrieval: 'collect and carry it home; it pays for what arrives intact',
intel: 'sit still and transmit, a long minute in the open',
recon: 'eyes on the area — you will come back knowing the roads',
};
/**
* The quest board. Deliberately words, not numbers: the player is reading a
@ -67,10 +78,11 @@ export function createBoard() {
(offer, i) => `
<li>
<span class="key">[${i + 1}]</span>
${offer.type === 'recon' ? 'Survey' : 'Supply run'}
${MISSION_SHAPE[offer.type].label}
${offer.novel ? '<span class="novel">new target</span>' : 'known target'}
· ${(offer.route.length / 1000).toFixed(1)} km
· ${offer.reward.toFixed(2)} parts
<div class="meta">${BRIEFING[offer.type]}</div>
<div class="meta">${describe(offer)}</div>
</li>`,
)

View File

@ -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<Control, string> = {
};
function questLines(quest: NonNullable<HudModel['quest']>): 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})`,

View File

@ -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);