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:
parent
90dc18fd17
commit
06c7555188
108
README.md
108
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
|
||||
|
||||
84
src/main.ts
84
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) -
|
||||
|
||||
159
src/sim/opportunities.ts
Normal file
159
src/sim/opportunities.ts
Normal 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 };
|
||||
}
|
||||
@ -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)!;
|
||||
|
||||
@ -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
179
src/sim/radio.ts
Normal 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;
|
||||
}
|
||||
@ -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
264
src/sim/texture.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@ -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>`,
|
||||
)
|
||||
|
||||
@ -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})`,
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user