Compare commits

...

10 Commits

Author SHA1 Message Date
dejvino
b4f8fd6c99 Document the second pass
Adds the harsher crash calibration, the overhaul, the death cam, the
currency, the traffic rules and why each of them is narrower than it
looks, crewed vehicles, allied convoys, and the building-grid bug that
was letting bullets, line of sight and people through three quarters of
every wall.

Two new gates under Next: whether the crash cost reads as punishing or
as a reason to slow down, and whether the overhaul becomes something
you save for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 08:28:05 +02:00
dejvino
5c2778785c Allied vehicles, with men in them
Your own side had people on foot and nothing on wheels, so home ground
had civilian traffic on it and nothing else moving that was yours. A
road with only civilians on it reads the same behind the lines as past
them.

Convoys route across the network like traffic - which means they
inherit lane discipline and following distance for nothing - but only
between junctions your side holds, and they leave when the line moves
over them. Fewer than the militia on foot, because a truck is scarcer
than a man with a rifle and because friendly traffic that outnumbered
the civilian kind would turn every road into a convoy. Across the map:
convoys 4, 2, 0, 0 and militia 14, 10, 3, 0 against patrols 0, 0, 3, 5.

They are insurgent faction, so they shoot and get shot at, and the
render already crews any armed vehicle - they carry the same visible
gunner the enemy cars got.

Two things this shook out. Lane keeping was building its axis from the
car-to-node direction, which is nearly right mid-leg and degenerates
completely near either end, where the direction swings ninety degrees
over a few metres; on a grid map cars spend most of their time near a
junction. Units now remember the junction they last left and drive the
leg they are actually on. And arrival was still judged on the node
itself while the car deliberately passes three metres to one side of
it, so on trunks dispatched units drove straight past their
destination - no patrol worked a road, no engineer poured any concrete.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 08:26:46 +02:00
dejvino
7004f2bd66 Put a man in the enemy cars, and let him raise his weapon
A patrol car was an empty box that occasionally emitted tracers.

It now carries a visible crewman, standing up out of the roof so he
clears the roofline at any useful distance. He turns toward whatever he
is watching, and the weapon comes up only once they have decided about
you: across his lap and pointing at the floor otherwise, levelled and
out in front when the car is hunting or when there is something of the
other army in range.

That difference is the point of the whole thing. Being recognised was a
meter filling up in the corner of the screen; it is now something you
can see happen across a street. The gun is bigger and lighter than it
ought to be for the same reason the people are - at a driver's eye
height a rifle is three pixels against a dark road - and the pose
change is exaggerated, because a subtle change of angle is a detail
rather than a signal.

Rounds now leave the man rather than the bonnet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 08:18:45 +02:00
dejvino
baa7bba95f Traffic that drives like traffic
Three things, all of which the world was visibly not doing.

Cars drove down the middle of every road in both directions, so two
meeting head-on occupied the same metre of tarmac and passed through
each other. They now hold a line parallel to the centreline, offset
right by a share of the road's own half-width, so a track gets a nudge
and a trunk gets a lane. Offsetting the destination instead was the
obvious fix and the wrong one - it only bends the path at the very end
of a leg, leaving cars straddling the middle of every straight. They
track the lane line at a lookahead instead.

Heading snapped straight to the next junction, so a right-angle turn
was one frame of instant pivot. It is now rate-limited to what a
driver could actually steer.

And nothing avoided anything. Civilian cars lift off for the car in
front - only civilians, and only for cars going roughly the same way,
or a patrol queues politely behind a bus and never reaches the road it
was dispatched to, and two cars meeting on a single track each wait
forever for the other. Following distance does not cover a merge,
where two cars converge on a junction more than ninety degrees apart
right up until they both turn onto the same heading and are already
touching, so overlapping vehicles are pushed apart as well. A give-way
rule would also fix that, and can deadlock; a separation always
resolves.

Closest pair of same-direction cars over four minutes: 0.98m before,
4.3m after.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 08:15:13 +02:00
dejvino
6c1132b133 Parts become money, and the debug panel can hand you some
"0.34 parts" read as a fraction of something unnamed. It is a payment,
so it now looks like one: ¤34. The symbol is Unicode's generic currency
sign - it looks like money and is nobody's actual money, which is right
for a place whose banks are somebody else's problem.

The scale moves with it. FUNDS_PER_CONDITION fixes the exchange rate in
one place, so every balance number stays in the unit it is actually
about and how large the currency reads stays a presentational decision.
A supply run pays ¤34, an overhaul runs ¤200 to undo a crash's
permanent cost, and a full rebuild after a bad one is about a dozen
missions.

The debug panel gets ±¤ next to the existing condition levers, so the
workshop and the board can be exercised without first driving eight
missions to afford looking at them.

Save format bumped: quests.parts is now quests.funds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 08:06:57 +02:00
dejvino
1019c3aec3 Walls that are actually there
Three separate holes, reported as one bug.

The building lookup grid under-registered. It stepped the world
coordinate by the cell size from x-reach to x+reach, but a building's
reach is about 7m and a cell is 24m, so the loop always took exactly
one step: every building registered one cell and silently vanished from
the other three it straddled. Points in those cells read as open
ground - bullets flew through the wall, line of sight saw through it,
and people stood inside it. Walk cell indices instead.

Nothing checked line of sight before pulling a trigger. Rounds always
died against the building; the decision to fire did not know the
building was there, so units emptied magazines into walls and what you
saw from the car was tracers coming out of solid concrete.

And every kind of free movement wrote straight into x and z, so
pedestrians, militia, fighters and fleeing civilians all walked through
walls. They now share the detour the hunters already used, and spawns
retry rather than dropping someone inside a building on the first
frame.

Six units in ninety were standing inside buildings. Now none are.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 08:02:08 +02:00
dejvino
563ee66dcd Hold on the wreck before moving the player
Cutting straight from the crash to a base three hundred metres away
read as a bug rather than as a death, and the one moment in a campaign
worth actually looking at was the one moment the player was not allowed
to see it.

The run now ends five seconds after the car does. Control goes; the
camera leaves the bumper and climbs away to sixty metres, turning
slowly, until the street, whatever was hit and whoever is standing
around watching are all in frame. The point of the shot is not the car
- by then the car is a fact - it is *where* this happened, which is the
thing you have to drive back out to.

The world keeps running underneath it. A death cam over a frozen world
is a screenshot, not a moment. What stops is the driver: no throttle,
no board, no overhaul, and no quietly finishing the job by virtue of
having stopped moving on top of the target.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 07:57:11 +02:00
dejvino
d9cdca70d5 Overhaul: something you can do about the ceiling
The ceiling was the only number in the game that moved one way, with no
counterplay at any price. In practice that reads less as a design
pillar than as a punishment you cannot answer - you watch the car get
permanently worse and there is nothing to *do* about it.

Press C at any base to strip and rebuild, worst subsystem first. It
buys ceiling and deliberately nothing else: an overhaul raises what the
car could be, and ordinary repair still has to fill it in. Early on
every part goes to repairs and there is never a surplus, so this is
invisible; it only becomes the thing to spend on once the ceiling is
what is actually holding you back.

The rate is the design, not the impossibility. 0.12 of ceiling per
part, against a full point of condition per part for a repair, so
undoing one crash's permanent cost runs about seven missions. Decline
is now something you can fight rather than only absorb.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 07:52:27 +02:00
dejvino
764ea12a3f Make a serious crash nearly the end of the car
The first calibration left an 85 km/h head-on at 65% chassis and
perfectly driveable, which made hitting a building at ninety an
inconvenience rather than a disaster. The reference crash now lands the
chassis at 23%, the tyres at 61% and the engine at 75% - ordered by
what a collision actually ruins.

That makes one crash survivable and two in a row the end of the car,
which is the shape the risk wants: a mistake you drive home from, and a
second mistake you do not.

The per-step cap moves up with it. It exists for the pathological case
- a car wedged between two colliders reporting forces in the billions -
and once it sits below a bad head-on it stops being a backstop and
becomes the number deciding every impact.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 07:48:37 +02:00
dejvino
1a7a4776c1 Document the pass, and correct the notes it made stale
The README is this project's design document, so the reasoning behind
each change belongs in it rather than only in commit messages. Adds
sections on what a crash costs and why the coefficients are anchored to
a measured figure, killed in action, giving up a job, districts and
landmarks, militia, and how a hunter drives.

Also removes three notes that had gone stale - persistence not
existing, wear rates awaiting tuning, and the heat lines wanting to
come out of the HUD - and replaces the "take the debug lines out first"
instruction under Next with three Phase 9 gates, since all of those are
now done.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 07:32:12 +02:00
22 changed files with 1618 additions and 235 deletions

296
README.md
View File

@ -11,6 +11,10 @@ Endless driving survival game.
- **Phase 6** — an inhabited world: traffic, people, patrols, and a live war.
- **Phase 7** — sound, synthesised from the simulation.
- **Phase 8** — recognition: being noticed, being hunted, and getting away.
- **Phase 9** — stakes: a crash that costs something, a death that costs
something, a chase you can lose, and a town you can navigate.
- **Phase 10** — a world that behaves: traffic with lanes, crews you can read,
walls that are actually there, and money you can spend.
## Running
@ -28,9 +32,17 @@ seeds may be numbers or words.
| `Space` | handbrake (rear wheels only) |
| `R` | respawn the car — **does not** repair it |
| `1``3` | accept a mission, when parked at a base |
| `X` | give up the mission in hand, when parked at a base |
| `C` | overhaul the car, when parked at a base |
| `M` | sound on/off |
| hold `R` | wipe the campaign and start over (5 seconds) |
Add `?debug=1` for the tuning readouts — heat, district standing, which band
you are in — and the debug panel. Without it the HUD tells you the car, the job
and your cover, and nothing else: several of the design gates below are about
reading the world rather than a number, and they cannot be judged with the
number on screen.
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.
@ -150,6 +162,30 @@ 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.
### Districts, and the buildings that ignore them
Every building used to be the same grey box between four and twelve metres tall.
From the air the world was one texture; from the driver's seat every junction
looked like the junction before it, and "learn the map" was not something the
map supported.
The world is cut into districts, and a district agrees with itself: its
buildings share a palette, a rough height and a silhouette, and vary only
slightly around them. That agreement is the point twice over — it makes crossing
into the next district a visible event, and it is the only reason the exceptions
read at all.
Because about one plot in fifty ignores its district completely: much taller,
lighter, a different shape. Those are the things you actually navigate by, and
they are floored at a height that clears every ordinary roofline on the map,
since a landmark you cannot see from outside its own district is no use in the
only place you need it.
Colour rides on the instance rather than the material, so a district costs no
extra draw calls. Colliders stay boxes whatever the silhouette says — both the
physics and the line-of-sight grid already assume a building is solid to its own
footprint.
### Other deliberate choices
- **Concrete never closes around the car standing in the gap.** A block spawning
@ -196,6 +232,15 @@ 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.
### Giving one up
Park at any base with a job you have not started bringing home and `X` takes it
off you, for 12 m of front line. A target can end up behind concrete there is no
way round, and the only exit used to be holding `R` — which does not drop the
job, it destroys the campaign. It cannot be free either, or the board is a
reroll button: park, dislike the route, drop it, take another. Dropped jobs are
counted apart from finished ones. They are not runs.
### Work you find rather than work you are given
Past the line, and only when you have nothing in hand, someone occasionally
@ -212,23 +257,106 @@ 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
### Rewards, repairs, and the overhaul
New targets pay `NOVELTY_BONUS` (1.8×) more than known ones. That multiplier is
New targets pay `NOVELTY_BONUS` (1.8×) more than known ones. The currency sign
is `¤` — Unicode's generic one, which looks like money and is nobody's actual
money, for a place whose banks are somebody else's problem. That multiplier is
the phase's tuning dial: if you never take the new target it is too low, and if
you never take the known one it is too high.
Payment is in **parts**, which repair the car — the only thing in the game that
pushes condition back up. Repairs go to the worst subsystem first and stop at
Payment is money, and money repairs the car. Repairs go to the worst subsystem first and stop at
its **ceiling**, which drops permanently with every bit of damage taken
(`PERMANENT_SHARE`, currently 30%). So a car can be patched back to what it is
still capable of, but never to what it was. The HUD bars show this directly: `█`
is current condition, `·` is what repairs could still reach, `×` is gone.
Repairs happen automatically on hand-in, and they stop at the ceiling. Pressing
`C` at a base does the other thing: an **overhaul**, which buys the *ceiling*
itself back at 0.12 a point per ¤1, worst subsystem first. It deliberately does
not touch the current level — it raises what the car could be, and ordinary
repair still has to fill it in.
The ceiling used to fall and nothing else: the only number in the game that
moved one way, with no counterplay at any price. That reads less as a design
pillar than as a punishment you cannot answer. Now decline is something you can
fight, and what keeps it meaningful is the exchange rate rather than the
impossibility — putting a car back to factory after one bad crash is about a
dozen missions. Early on there is never a surplus to spend, so the option is
invisible; it only becomes the thing to save for once the ceiling is what is
actually holding you back.
This is what gives the board's reward a reason to matter. Without an economy the
choice between a safe route and a risky one has no stakes; with one, the risky
job is what keeps the car alive a while longer.
### What a crash actually costs
The impact coefficients were originally written against an imagined scale.
Measured on the running game, a head-on into a building at 85 km/h comes back
from Rapier as a single contact event of about **2.5e6 N** — twenty-five units
of "impact" against the old 1e5 reference. Through the old coefficients that was
123% of the chassis and 37% of its ceiling, gone, in one step, in a game whose
missions pay about ¤30. One crash ended the campaign, and the campaign had
no way to end.
Everything is now anchored to that measured figure, and damage is capped per
step — a crash is a pile of contacts across several steps plus whatever the car
scrapes on the way to a standstill, and that sum has no natural bound.
What it costs is deliberately brutal. That reference head-on leaves the chassis
at **23%**, the tyres at 61% and the engine at 75%, ordered by what a collision
actually ruins. One is a disaster you drive home from; two in a row is the end
of the car. An earlier calibration left it at 65% and perfectly driveable, which
made hitting a building at ninety an inconvenience rather than a decision.
Baseline wear came down with it. Tyres shed 8e-5 per metre, so twelve clean
kilometres took them from new to ruined while a two-kilometre round trip paid
¤30 against 0.24 of condition spent. Driving perfectly barely broke even.
At a quarter of that, a typical mission spends about 0.06, so roughly two clean
runs pay for one bad crash — enough slack to gamble with, not enough to ignore.
## Killed in action
A car could be driven to zero in every subsystem and would keep going forever at
a wheezing thirty kilometres an hour. Nothing was at stake, so nothing was worth
protecting, so there was never a reason to take the quiet road. "Decline, not
reset" only reads as a stake if the decline can run out.
When the **chassis** is gone, so are you. Somebody pulls you out; you come round
at the nearest base; the job in hand is written off with the car; and 25 m of
front line goes back to the enemy, through the same path a finished mission uses
so the war moves for one reason only.
The replacement is a real car — patched to its ceilings, because one that still
needed money you do not have would just kill you again on the way out of the
yard — and every ceiling is 8% down on the one you wrote off. That is the
ratchet applied at the one moment repairs cannot reach.
It stops at a floor, and that is worth being honest about: strictly it breaks
"the ceiling only ever falls". Without one, enough deaths issue a car that is
written off the instant you are handed it, and the only way out of the campaign
is wiping it. A genuinely miserable car you can still drive is the interesting
version of that ending rather than the broken one.
### The five seconds before you wake up
The run does not end the instant the car does. Control goes, the camera leaves
the bumper and climbs away to sixty metres — turning slowly — until the street,
whatever you hit and whoever is standing around watching are all in frame. The
point of the shot is not the car; by then the car is a fact. It is *where* this
happened, which is the thing you have to drive back out to.
The world keeps running underneath it, because a death cam over a frozen world
is a screenshot rather than a moment. What stops is the driver: no throttle, no
board, no overhaul, and no quietly finishing the job by virtue of having stopped
moving on top of the target. Anyone shooting stops a quarter of the way in —
the burst that killed you should finish, but standing over a wreck emptying
magazines into it reads as the enemy beating a dead horse.
Nothing about any of it is announced. The front moving against you is something
you find out by driving back out there.
## A world with people in it
Up to Phase 5 the world was scenery. Heat poured concrete on a road and that was
@ -256,6 +384,53 @@ Let the road cool and the whole thing is abandoned.
**Traffic and people.** Civilian cars route across the network with somewhere to
be; people wander on foot near the buildings. Both scatter when shooting starts.
**And they drive like traffic.** Cars hold a line parallel to the centreline,
offset right by a share of the road's own half-width — a track gets a nudge, a
trunk gets a lane — so two meeting head-on are on opposite sides of the road
instead of in the same metre of tarmac. Steering is rate-limited, so a
right-angle junction is a turn rather than one frame of instant pivot. They lift
off for the car in front, and overlapping vehicles are pushed apart, because
following distance does not cover a merge: two cars converging on a junction are
more than ninety degrees apart right up until they both turn onto the same
heading and are already touching.
Each of those rules is narrower than it first looks, and the narrowness is the
point. Only civilians defer, or a patrol queues politely behind a bus and never
reaches the road it was dispatched to. Only for cars going roughly the same way,
or two meeting on a single track each wait forever for the other. And separation
is same-direction only, since oncoming cars are already a lane apart — which on
a narrow road is less than the separation radius, so pushing them apart undid
the lane discipline that put them there.
**Armed vehicles carry a visible crew.** A patrol car used to be an empty box
that occasionally emitted tracers. There is now a man standing up out of it, and
his weapon comes up only once they have decided about you: across his lap
otherwise, levelled and tracking when the car is hunting or when something of
the other army is in range. That difference is the point — being recognised was
a meter filling up in the corner of the screen, and it is now something you can
watch happen across a street.
**Your own side is standing in your own streets.** Liberated territory read as
empty — no patrols by design, and nobody else either — so the safest part of the
map was also the deadest, and coming home felt like arriving nowhere rather than
arriving somewhere held. Militia mirror the ambient patrols the other way up:
fourteen at home, five in contested ground, none past that, because a friendly
face on the frontier would be a safety net the setting is not supposed to have.
Crossing the map reads 14, 14, 11, 5, 0 against enemies 0, 0, 3, 4, 4 — who is
standing in the street is a more legible signal of whose ground you are on than
the palette is.
They are placed by checking who holds the ground they would *stand* on rather
than the band the player is in, since near a border those are different answers,
and they leave when the line moves over them.
**Your own side has vehicles too.** Convoys route the network like traffic, so
they inherit lane discipline and following distance for nothing, but only
between junctions your side holds: 4 at home, 2 in contested ground, none past
it. Fewer than the men on foot, because a truck is scarcer than a rifle and
because friendly traffic outnumbering the civilian kind would turn every road
into a convoy. They carry the same crew the enemy cars do.
### The war you are driving through
Skirmishes break out between the two armies on contested and occupied ground,
@ -272,6 +447,23 @@ Buildings stop bullets, which is what makes cover worth anything. Hits are route
through the same wear model as a crash, so being shot costs you ceiling too — it
is permanent in the same way everything else is.
**Nothing shoots or walks through a wall.** Rounds always died against
buildings, but nothing checked before pulling a trigger, so units emptied
magazines into the concrete between them and a target they could not see — what
you saw from the car was tracers coming out of a solid wall. Firing now needs
the same line of sight the round does. Free movement wrote straight into x and z
too, so pedestrians, militia, fighters and fleeing civilians all walked through
buildings; they now take the same detour the hunters do, and spawns retry rather
than dropping somebody inside a wall on the first frame.
Underneath both was one bug worth recording. The building lookup grid stepped
the world coordinate by the cell size from `x-reach` to `x+reach`, but a
building's reach is about 7 m and a cell is 24 m — so the loop always took
exactly one step, every building registered a single cell, and it silently
vanished from the other three it straddled. Points in those cells read as open
ground for bullets, for line of sight and for people. Six units in ninety were
standing inside buildings; now none are.
**The enemy does not shoot at you by default.** You are an undercover driver, not
a target. They open up only past the line *and* on a road they have already
checkpointed — that is, a road you personally made notorious.
@ -294,6 +486,20 @@ patrols — a chase you could outrun by passing more of them would be no chase.
This is also the *only* thing that makes the enemy shoot at you: being a target
is a state, not a property of the road you are on.
**They drive differently when they are chasing.** A patrol on its rounds does
14 m/s and a healthy car does 29, so being hunted used to mean holding `W` until
the 95 m sight radius did the rest. Chasing cars wind up to 21 m/s, which sits
deliberately between the two: faster than anyone averages through a grid of
junctions and buildings, slower than the car's top end on a clear run. So a long
trunk straight is an escape and the lanes are not — which puts the road
hierarchy under real pressure for the first time. The fast conspicuous road is
the one you want when they are behind you, and it is the one they will be
looking on next time.
They also go **round** buildings rather than through them. Line of sight was
already blocked by them and movement was not, which made "break line of sight
and change direction" advice the world did not honour.
**Getting away** takes one of two things:
- **Break line of sight for fourteen seconds.** They drive to where they last
@ -301,7 +507,10 @@ is a state, not a property of the road you are on.
the move. Any glimpse resets the clock.
- **Let your own side occupy them.** Hunters are patrols, not fanatics: an
insurgent squad on their doorstep is a better use of their time. Enough of
them peeling off ends the chase outright.
them peeling off ends the chase outright. This is why the militia matter
mechanically as well as atmospherically — running for your own lines works
because the closer you get to home the more likely they peel off. The rule
always existed; there was simply nobody around to trigger it.
Escaping does not hand your cover straight back. Suspicion drops to 0.6 and
decays slowly from there — they know there is a car worth looking for, and you
@ -314,6 +523,9 @@ corner. The panel has `` / `+` buttons for the car's condition, because
"drive a wrecked car for a minute" is something you want to do *while driving*
rather than while typing into a console.
The panel also has `−¤` / `+¤`, so the workshop and the board can be exercised
without first driving eight missions to afford looking at them.
The handle also steps the simulation by hand, renders on demand, and writes
frames to `shots/` via a dev-only endpoint — which is how this build gets looked
at at all when the tab is not compositing. `__dbtl.state` exposes the live sim.
@ -407,9 +619,11 @@ running diagonally, so area is nowhere near linear in depth.
**You are never told which band you are in by the world itself.** Crossing a
border eases the fog, the light and the palette: your own ground is open and
green, contested goes flat and hazy, occupied turns dusty and closes in, the
frontier is cold and short-sighted. The HUD names the band for now, but that
line is debug scaffolding for the same reason the heat numbers are — Phase 3's
gate is telling them apart *without* being told.
frontier is cold and short-sighted. On top of the light, the population: home
ground is thick with your own people and thins to nobody past the line. The HUD
names the band only under `?debug`, for the same reason the heat numbers are
behind it — Phase 3's gate is telling them apart *without* being told, and you
cannot judge that with the answer on screen.
## The map
@ -417,6 +631,15 @@ A sketch map, top right, drawn from `Intel` rather than from the world:
- **The road network's shape is always visible**, as faint dashed lines. You are
an operative with a map, not an amnesiac.
- **Your own ground starts filled in.** A fresh campaign knows the pocket its
side holds — the cells, the roads through them, and the junctions as places
you have been. You are a local with a car; the war is the part you would not
know. It also fixes the board, which splits its offers into targets you have
visited and targets you have not: with nothing visited every offer came up
"new", so the known-against-new comparison the mission design turns on was not
on screen until you had wandered around for a while. None of it survives the
line moving — the map still only remembers who held a place when you last
stood in it.
- **Roads you have actually seen** are drawn solid and coloured by the heat you
remember on them — which is what you last saw, not what is there now.
- **Ground you have driven through** is filled in, tinted by who held it *when
@ -445,13 +668,14 @@ ever opening a browser.
```
src/
sim/ pure model, no engine imports:
world, roads, heat — the map and its memory
world, roads, heat — the map, its districts and its memory
radio, opportunities — texture: chatter and field work
units, combat — traffic, patrols, soldiers, rounds
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
kia — what happens when the decline runs out
physics/ Rapier world, raycast vehicle, input → wheel forces
render/ three.js scene, roads, markers, chase camera
core/ fixed-timestep loop, seeded RNG, keyboard
@ -477,16 +701,15 @@ one place where "how broken the car is" turns into "how it drives".
- **The car handles like a placeholder.** Suspension, grip, and engine numbers in
`physics/physics.ts` and `sim/car.ts` are first guesses that pass a smoke test,
not something tuned by feel. That tuning *is* Phase 0's verification gate.
- **Wear rates are deliberately aggressive** so decline is visible in minutes
rather than hours. Turn them down in `applyWear` once the curve reads right.
- **Wear rates are tuned against the economy** rather than by feel: a
clean mission spends about a fifth of what it pays, and a hard crash costs
roughly two clean missions. Those ratios are the dial, not the raw numbers.
- **No interpolation** between physics steps. Fine at 60 Hz; revisit if the step
rate changes.
- **Nothing is persisted** across reloads — condition, heat, intel and completed
runs all reset. IndexedDB comes with the campaign layer, and `sim/intel.ts`
plus `sim/heat.ts` are the two things that most need it.
- **Repairs are automatic on hand-in.** Parts get spent the moment you report
back. The brief wants repair to be a physical, on-foot act with scavenged
parts; that is Phase 6.
- **Repairs are automatic on hand-in.** Money gets spent the moment you report
back; the overhaul under `C` is the only deliberate spend. The brief wants
repair to be a physical, on-foot act with scavenged parts; that is still
deferred.
- **The board's route preview assumes the shortest path.** If you drive a
different way, the intel you were shown described a route you did not take.
`findRoute` already accepts a cost function, so a "safest route" preview is a
@ -499,11 +722,12 @@ one place where "how broken the car is" turns into "how it drives".
gameplay shape is right; the presentation wants berms, wire, or wreckage on the
outer sections. They can also clip scenery placed on the verge — both are
static bodies, so physics is unaffected, but it looks wrong up close.
- **Hunters steer round buildings by trial rather than by pathfinding.** They
try seven headings, widening either side of straight-on, and take the first
that is clear. Good enough that corners work; it will not solve a cul-de-sac.
- **Heat has no diegetic signal at a distance.** You learn a road is hot by
arriving at the checkpoint. The brief wants it readable from patrol density and
wreckage before you commit — that needs the enemy presence Phase 5 brings.
- **The heat HUD lines are debug scaffolding.** The brief is explicit that no
numeric heat meter ships; they exist to tune the curve and should come out.
- **Bundle:** ~2.7 MB raw / ~945 KB gzipped, dominated by Rapier's WASM, which
`rapier3d-compat` inlines as base64. Switching to the non-compat `@dimforge/rapier3d`
package serves the WASM as a separate file (~570 KB gzipped, compiled in
@ -547,15 +771,39 @@ and people you have no reason to interact with, and a war that is obviously not
about you. If firefights read as encounters staged for the player, the spacing
and spawn rules in `sim/units.ts` are the dial.
**Phase 10:** the world should hold up to being *looked at* rather than only
driven through. Park somewhere busy and watch for a minute: traffic should
queue and pass rather than interpenetrate, crews should be relaxed until they
are not, and nothing should be standing in a wall. If any of that fails it is
visible immediately, which is the point of putting it in the list.
**Phase 9:** three gates, and all of them are about whether anything is at
stake.
- You slow down for a corner you would previously have taken flat, because the
car is worth protecting. One serious crash should be a disaster you drive home
from and two should end the car; if that reads as too punishing, the dial is
the coefficients in `applyWear` rather than `IMPACT_REFERENCE`, which is
measured rather than chosen.
- Money accumulates faster than you can spend it on repairs, and the overhaul
becomes the thing you save for. If you never press `C`, `CEILING_PER_FUND` is
too mean; if the ceiling stops mattering, it is too generous.
- Being hunted makes you think about *where* rather than only about the
throttle. If you still escape by holding `W` on any road at all, raise
`HUNT_SPEED`; if the chase is unloseable, the answer is more militia near home
rather than slower hunters, since being saved by your own side is the more
interesting exit.
- You can say where you are without the HUD, from the roofline and from who is
standing in the street. Districts and militia are both aimed squarely at this;
if it still takes reading a line of text, push `PALETTES` apart before
touching anything else.
Still deferred: 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
heat, expanded combat, and something happening when the front line reaches a
landmark — missions move it and nothing ever acknowledges that it moved. 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
and the phase has not landed, whatever the reward numbers say.

View File

@ -11,6 +11,8 @@ export interface DriverInput {
toggleMute: boolean;
/** True on the frame X was pressed: give up the job in hand. Consumed on read. */
abandon: boolean;
/** True on the frame C was pressed: overhaul the car. Consumed on read. */
overhaul: boolean;
}
const KEYS = {
@ -22,6 +24,7 @@ const KEYS = {
respawn: ['KeyR'],
mute: ['KeyM'],
abandon: ['KeyX'],
overhaul: ['KeyC'],
} as const;
const SELECT_KEYS = ['Digit1', 'Digit2', 'Digit3', 'Digit4'];
@ -38,6 +41,7 @@ export function createInput(): {
let pendingSelect: number | null = null;
let pendingMute = false;
let pendingAbandon = false;
let pendingOverhaul = false;
/** Called on the first real interaction, to satisfy autoplay policy. */
let onFirstGesture: (() => void) | null = null;
@ -49,6 +53,7 @@ export function createInput(): {
if (!down.has(e.code)) {
if (e.code === 'KeyM') pendingMute = true;
if (e.code === 'KeyX') pendingAbandon = true;
if (e.code === 'KeyC') pendingOverhaul = true;
const index = SELECT_KEYS.indexOf(e.code);
if (index >= 0) pendingSelect = index + 1;
}
@ -63,6 +68,7 @@ export function createInput(): {
pendingSelect = null;
pendingMute = false;
pendingAbandon = false;
pendingOverhaul = false;
};
window.addEventListener('keydown', onDown);
@ -74,9 +80,11 @@ export function createInput(): {
const select = pendingSelect;
const toggleMute = pendingMute;
const abandon = pendingAbandon;
const overhaul = pendingOverhaul;
pendingSelect = null;
pendingMute = false;
pendingAbandon = false;
pendingOverhaul = false;
return {
throttle: (held(KEYS.forward) ? 1 : 0) - (held(KEYS.back) ? 1 : 0),
steer: (held(KEYS.left) ? 1 : 0) - (held(KEYS.right) ? 1 : 0),
@ -85,6 +93,7 @@ export function createInput(): {
select,
toggleMute,
abandon,
overhaul,
};
},

View File

@ -16,6 +16,7 @@ import type { SceneView } from './render/scene';
import type { PhysicsWorld } from './physics/physics';
import type { LoopHandlers } from './core/loop';
import { STEP } from './core/loop';
import { CURRENCY } from './ui/currency';
export interface CaptureOptions {
/** Output width in pixels. Height follows 16:9. */
@ -38,6 +39,9 @@ export interface DebugApi {
teleport: (x: number, z: number) => void;
condition: () => { level: { engine: number; tires: number; chassis: number } };
setCondition: (level: number) => unknown;
/** Reads and writes the money in hand, for trying the workshop out. */
funds: () => number;
setFunds: (amount: number) => number;
}
/**
@ -47,7 +51,10 @@ export interface DebugApi {
* is a thing you want to do *while driving*, not while typing so condition
* gets buttons. Only ever mounted under `?debug=1`.
*/
function mountPanel(debug: { nudgeCondition: (by: number) => unknown }, api: DebugApi): void {
function mountPanel(
debug: { nudgeCondition: (by: number) => unknown; nudgeFunds: (by: number) => number },
api: DebugApi,
): void {
const panel = document.createElement('div');
panel.id = 'debug-panel';
panel.style.cssText = `
@ -58,14 +65,20 @@ function mountPanel(debug: { nudgeCondition: (by: number) => unknown }, api: Deb
border: 1px solid #2f3944; border-radius: 4px; padding: 8px 10px;
`;
const readout = document.createElement('span');
readout.style.cssText = 'min-width: 74px; color: #8fa3b5;';
const reading = (width: number) => {
const span = document.createElement('span');
span.style.cssText = `min-width: ${width}px; color: #8fa3b5;`;
return span;
};
const carReadout = reading(74);
const fundsReadout = reading(66);
const refresh = () => {
const { engine, tires, chassis } = api.condition().level;
readout.textContent = `car ${(((engine + tires + chassis) / 3) * 100).toFixed(0)}%`;
carReadout.textContent = `car ${(((engine + tires + chassis) / 3) * 100).toFixed(0)}%`;
fundsReadout.textContent = `${CURRENCY}${Math.round(api.funds())}`;
};
const button = (label: string, by: number) => {
const button = (label: string, apply: () => void) => {
const element = document.createElement('button');
element.textContent = label;
element.style.cssText = `
@ -74,7 +87,7 @@ function mountPanel(debug: { nudgeCondition: (by: number) => unknown }, api: Deb
padding: 3px 9px;
`;
element.addEventListener('click', () => {
debug.nudgeCondition(by);
apply();
refresh();
// Keep the keyboard on the game, or space starts pressing this button.
element.blur();
@ -83,9 +96,14 @@ function mountPanel(debug: { nudgeCondition: (by: number) => unknown }, api: Deb
};
panel.append(document.createTextNode('debug'));
button('', -0.15);
button('+', +0.15);
panel.append(readout);
button('', () => debug.nudgeCondition(-0.15));
button('+', () => debug.nudgeCondition(+0.15));
panel.append(carReadout);
// Money, so the workshop and the board can be exercised without first
// driving eight missions to afford looking at them.
button('−¤', () => debug.nudgeFunds(-50));
button('+¤', () => debug.nudgeFunds(+50));
panel.append(fundsReadout);
document.body.append(panel);
refresh();
@ -168,6 +186,12 @@ export function exposeDebug(api: DebugApi): void {
const now = api.condition().level;
return api.setCondition((now.engine + now.tires + now.chassis) / 3 + by);
},
/** Money in hand. */
funds: api.funds,
setFunds: api.setFunds,
nudgeFunds(by: number) {
return api.setFunds(api.funds() + by);
},
info: api.info,
state: api.state,
three: THREE,

View File

@ -1,6 +1,14 @@
import * as THREE from 'three';
import { generateWorld } from './sim/world';
import { applyWear, deriveHandling, freshCondition, repair, surfaceFor } from './sim/car';
import {
applyWear,
canOverhaul,
deriveHandling,
freshCondition,
overhaul,
repair,
surfaceFor,
} from './sim/car';
import { areaAt, createAreas, createHeat, effectiveHeat, speedAttention, stepHeat } from './sim/heat';
import { ROAD_ATTENTION, routeAt, segmentAt } from './sim/roads';
import { baseAt, placeBases } from './sim/bases';
@ -53,6 +61,19 @@ import { createHud } from './ui/hud';
import { createBoard } from './ui/board';
import { createAudio } from './audio/audio';
import { CAR, WHEELS } from './carSpec';
import { CURRENCY } from './ui/currency';
/** What the wheel reads while nobody is holding it. */
const DEAD_HANDS = {
throttle: 0,
steer: 0,
handbrake: true,
respawn: false,
select: null,
toggleMute: false,
abandon: false,
overhaul: false,
} as const;
/** Debug handle and frame capture, for inspecting a build that cannot be seen. */
export const DEBUG = new URLSearchParams(location.search).has('debug');
@ -69,6 +90,27 @@ const RESET_HOLD_SECONDS = 5;
* becomes a reroll button: park, dislike the route, drop it, take another.
*/
const ABANDON_LAND_LOSS = 12;
/**
* Seconds spent watching the wreck before you come round somewhere else.
*
* Long enough to register what happened and where it happened, short enough
* that it does not become the thing you dread about dying.
*/
const KIA_HOLD = 5;
/**
* Radians per second the view turns while the camera climbs away from a wreck.
* Slow: this is a drift across the scene, not an orbit around the car.
*/
const WAKE_ORBIT_RATE = 0.22;
/**
* Share of the death cam anyone keeps shooting for.
*
* The burst that killed you should finish cutting the gunfire on the exact
* frame the car dies looks like the sound broke. After that they stop, because
* standing over a wreck emptying magazines into it reads as the enemy beating a
* dead horse rather than as a firefight ending.
*/
const KIA_CEASE_FIRE = 0.25;
function resolveSeed(): number {
const raw = new URLSearchParams(location.search).get('seed');
@ -126,14 +168,20 @@ async function boot() {
// lookup. A grid built once at boot beats scanning a thousand obstacles.
const BUILDING_CELL = 24;
const buildingGrid = new Map<string, typeof model.obstacles>();
const cellKey = (x: number, z: number) =>
`${Math.floor(x / BUILDING_CELL)},${Math.floor(z / BUILDING_CELL)}`;
const cellOf = (v: number) => Math.floor(v / BUILDING_CELL);
const cellKey = (x: number, z: number) => `${cellOf(x)},${cellOf(z)}`;
for (const o of model.obstacles) {
if (o.kind !== 'block') continue;
const reach = Math.hypot(o.width, o.depth) / 2;
for (let x = o.x - reach; x <= o.x + reach; x += BUILDING_CELL) {
for (let z = o.z - reach; z <= o.z + reach; z += BUILDING_CELL) {
const key = cellKey(x, z);
// Walk cell indices, not metres. Stepping the world coordinate by
// BUILDING_CELL only ever took one step, because a building's reach (~7m)
// is smaller than a cell (24m) — so every building registered exactly one
// cell and silently vanished from the other three it straddled. Points in
// those cells read as open ground: bullets flew through the wall, line of
// sight saw through it, and people stood inside it.
for (let cx = cellOf(o.x - reach); cx <= cellOf(o.x + reach); cx++) {
for (let cz = cellOf(o.z - reach); cz <= cellOf(o.z + reach); cz++) {
const key = `${cx},${cz}`;
const list = buildingGrid.get(key) ?? [];
list.push(o);
buildingGrid.set(key, list);
@ -206,6 +254,10 @@ async function boot() {
let provocation = 0;
/** Times the car has been written off underneath the player. */
let kia = 0;
/** Seconds left of watching the wreck, or 0 when alive. */
let dying = 0;
/** Where the camera has got to in its drift around it. */
let deathAngle = 0;
const say = (text: string, seconds = 4) => {
notice = text;
@ -316,7 +368,32 @@ async function boot() {
captureChassis();
/**
* The car came apart around you.
* The moment the car came apart around you.
*
* The run does not end here it ends `KIA_HOLD` seconds later. What happens
* in between is nothing: you have no control, the camera pulls off the bumper
* and drifts round the wreck, and the world carries on around it. Cutting
* straight from the crash to a base three hundred metres away read as a bug
* rather than as a death, and the one moment in the campaign worth actually
* looking at was the one moment you were not allowed to see.
*/
const beginDeath = () => {
dying = KIA_HOLD;
deathAngle = 0;
// Nobody is chasing a car that is no longer going anywhere, and being shot
// at while you watch your own wreck is not a thing anyone needs to see.
for (const unit of units.units) unit.hunting = false;
pursuit.hunters.clear();
pursuit.suspicion = 0;
pursuit.alert = 'clear';
pursuit.lastSeen = null;
pursuit.unseenFor = 0;
audio.impact(26000);
say('That is the car finished.', KIA_HOLD);
};
/**
* Coming round somewhere else.
*
* You wake up at whichever base was nearest, in a worse car than the one you
* wrote off, with the job you were carrying written off along with it and the
@ -324,7 +401,7 @@ async function boot() {
* number the front moving against you is something you find out about by
* driving back out there.
*/
const killedInAction = () => {
const finishDeath = () => {
kia++;
const at = physics.chassis.translation();
const home = bases.reduce((best, b) =>
@ -337,14 +414,6 @@ async function boot() {
applyMissionImpact(front, -KIA_LAND_LOSS);
condition = replacementCar(condition);
// Whoever was chasing is chasing a car that no longer exists.
for (const unit of units.units) unit.hunting = false;
pursuit.hunters.clear();
pursuit.suspicion = 0;
pursuit.alert = 'clear';
pursuit.lastSeen = null;
pursuit.unseenFor = 0;
physics.chassis.setTranslation({ x: home.x, y: CAR.spawn.y, z: home.z }, true);
physics.chassis.setRotation({ x: 0, y: 0, z: 0, w: 1 }, true);
physics.chassis.setLinvel({ x: 0, y: 0, z: 0 }, true);
@ -360,7 +429,6 @@ async function boot() {
: `You woke up at ${home.name}. Somebody else is driving what is left of the car.`,
9,
);
audio.impact(26000);
persistence.checkpoint(elapsed, snapshot());
};
@ -369,7 +437,23 @@ async function boot() {
const handlers: LoopHandlers = {
fixedUpdate(dt) {
elapsed += dt;
const cmd = input.read();
const raw = input.read();
// While the wreck is being looked at, the world carries on and the player
// does not. Everything below still runs — traffic, the war, the front —
// because a death cam over a frozen world is a screenshot, not a moment.
// What stops is the driver.
const dead = dying > 0;
const deathProgress = dead ? 1 - dying / KIA_HOLD : 0;
if (dead) {
dying -= dt;
deathAngle += WAKE_ORBIT_RATE * dt;
if (dying <= 0) {
dying = 0;
finishDeath();
}
}
const cmd = dead ? DEAD_HANDS : raw;
// Tap R to get unstuck; hold it to wipe the campaign and start over.
// A reset has to be hard to do by accident — it throws away every mile
@ -462,9 +546,48 @@ async function boot() {
if (currentSegment !== null) observe(intel, heat, currentSegment, elapsed);
reveal(intel, at.x, at.z, SIGHT_RADIUS, controlOf);
const base = baseAt(bases, at.x, at.z);
// A wreck is not parked at a base, whatever the coordinates say.
const base = dead ? null : baseAt(bases, at.x, at.z);
const stopped = Math.abs(speed) < 2.5;
// --- Overhaul: buying back what the car is capable of ---
// Offered whether or not there is a job in hand, since a workshop does
// not care what you are carrying. Deliberately a bad rate next to an
// ordinary repair — this rebuilds what the car *could* be, which is the
// expensive thing you do once patching it up has stopped helping.
if (base && stopped && cmd.overhaul) {
if (!canOverhaul(condition)) {
say('Nothing to rebuild. She is as good as she ever was.', 4);
} else if (quests.funds < 1) {
say(`Nothing to pay with. Bring something back first.`, 4);
} else {
const before = condition.ceiling;
const done = overhaul(condition, quests.funds);
const spent = quests.funds - done.unused;
condition = done.condition;
quests.funds = done.unused;
const gained =
condition.ceiling.engine +
condition.ceiling.tires +
condition.ceiling.chassis -
(before.engine + before.tires + before.chassis);
// A car that has turned a wheel is never *exactly* at factory, so the
// sim will happily sell you a hundredth of a percent. Judge by what
// the player can actually see rather than by what is left to buy.
if (gained < 0.005) {
say('Nothing worth stripping her down for. She is near enough new.', 4);
} else {
say(
`Stripped and rebuilt. ${CURRENCY}${spent.toFixed(0)}, and she will take ` +
`${(gained * 100).toFixed(0)}% more than she would have.`,
7,
);
audio.ui('accept');
persistence.checkpoint(elapsed, snapshot());
}
}
}
// --- Quest board: only when parked at a base with nothing in hand ---
if (base && stopped && !quests.active) {
if (openBase !== base.nodeId) {
@ -526,7 +649,9 @@ async function boot() {
}
// --- Active mission ---
const quest = quests.active;
// Not while you are unconscious: a wreck sitting on the target must not
// quietly finish the job, and stepQuest only asks whether you are stopped.
const quest = dead ? null : quests.active;
if (quest) {
const target = nodeOf(quest.targetNode);
const event = stepQuest(quests, {
@ -566,13 +691,13 @@ async function boot() {
// The line moves because of what the player did. They are not told by
// how much — they find out by going back and looking.
applyMissionImpact(front, event.impact);
const outcome = repair(condition, quests.parts);
const spent = quests.parts - outcome.unused;
const outcome = repair(condition, quests.funds);
const spent = quests.funds - outcome.unused;
condition = outcome.condition;
quests.parts = outcome.unused;
quests.funds = outcome.unused;
say(
spent > 0.001
? `Handed in. Repairs used ${spent.toFixed(2)} parts.`
spent > 0.5
? `Handed in. Repairs took ${CURRENCY}${spent.toFixed(0)}.`
: 'Handed in. Nothing left worth repairing.',
);
// Checkpoint: a finished run is the most expensive thing to lose.
@ -589,15 +714,17 @@ async function boot() {
control,
carX: at.x,
carZ: at.z,
onMission: quests.active !== null,
// Nothing turns up for a man who is not going anywhere, and a wreck
// must not collect one by virtue of having stopped moving.
onMission: dead || quests.active !== null,
},
model.roads,
chatterRng,
stopped,
stopped && !dead,
);
if (found?.kind === 'appeared') say(found.opportunity.hail, 7);
else if (found?.kind === 'helped') {
quests.parts += found.opportunity.reward;
quests.funds += found.opportunity.reward;
if (found.opportunity.impact > 0) applyMissionImpact(front, found.opportunity.impact);
say(found.opportunity.done, 7);
persistence.checkpoint(elapsed, snapshot());
@ -684,14 +811,21 @@ async function boot() {
}
// --- Being noticed ---
for (const event of stepPursuit(pursuit, {
dt,
player: { x: at.x, z: at.z },
control,
units,
canSee,
provocation,
})) {
// Not while you are dead. Enemies standing over the wreck would otherwise
// build suspicion on it all over again and re-recognise a car that is not
// going anywhere, which is how they ended up shooting at it for five
// solid seconds.
const noticed = dead
? []
: stepPursuit(pursuit, {
dt,
player: { x: at.x, z: at.z },
control,
units,
canSee,
provocation,
});
for (const event of noticed) {
if (event.kind === 'recognised') say('They have made you. Drive.', 6);
if (event.kind === 'lost') say('You lost them.', 5);
if (event.kind === 'distracted' && event.remaining > 0) {
@ -703,7 +837,9 @@ async function boot() {
// --- Shooting ---
// They shoot at you because they have recognised you, not because of the
// road you happen to be on. Being hunted *is* the state of being a target.
const exposed = pursuit.alert === 'hunted';
// While dying, exposure runs off the clock rather than off the meter: the
// shooting tails off shortly after the car does.
const exposed = dead ? deathProgress < KIA_CEASE_FIRE : pursuit.alert === 'hunted';
const shooting = stepCombat(
combat,
@ -713,6 +849,9 @@ async function boot() {
player: { x: at.x, z: at.z },
playerExposed: exposed,
blocked: insideBuilding,
// Nobody fires at what they cannot see. Rounds already stopped at
// walls; without this the muzzle flashes still came from inside them.
canSee,
},
condition,
chatterRng,
@ -729,7 +868,7 @@ async function boot() {
// --- The end of a car ---
// Checked here rather than beside `applyWear`, because being shot at
// damages the chassis too and either route has to be able to finish you.
if (isWrittenOff(condition)) killedInAction();
if (!dead && isWrittenOff(condition)) beginDeath();
// --- Radio ---
// Front drift is measured, not narrated: the chatter about the enemy
@ -806,11 +945,16 @@ async function boot() {
mesh.quaternion.set(q.x, q.y, q.z, q.w);
}
unitView.update(units, combat.rounds, elapsed);
unitView.update(units, combat.rounds, elapsed, { x: p.x, z: p.z });
view.followSun();
view.setTone(control, frameDt);
markers.update(elapsed);
updateCamera(view, frameDt, physics.vehicle.currentVehicleSpeed());
updateCamera(
view,
frameDt,
physics.vehicle.currentVehicleSpeed(),
dying > 0 ? { angle: deathAngle, progress: 1 - dying / KIA_HOLD } : null,
);
view.renderer.render(view.scene, view.camera);
const quest = quests.active;
@ -864,7 +1008,7 @@ async function boot() {
completed: quests.completed,
failed: quests.failed,
kia,
parts: quests.parts,
funds: quests.funds,
notice: elapsed < noticeUntil ? notice : '',
resetProgress: resetHeld / RESET_HOLD_SECONDS,
muted: audio.muted,
@ -900,6 +1044,12 @@ async function boot() {
};
return condition;
},
funds: () => quests.funds,
/** Set the money in hand. Nothing in the game hands out money like this. */
setFunds(amount: number) {
quests.funds = Math.max(0, amount);
return quests.funds;
},
teleport(x, z) {
physics.chassis.setTranslation({ x, y: 1.4, z }, true);
physics.chassis.setLinvel({ x: 0, y: 0, z: 0 }, true);
@ -910,13 +1060,14 @@ async function boot() {
control,
segment: currentSegment,
onTarmac,
parts: quests.parts,
funds: quests.funds,
completed: quests.completed,
failed: quests.failed,
kia,
condition,
front: front.boundaries,
opportunity: opportunities.current,
dying: +dying.toFixed(2),
alert: pursuit.alert,
suspicion: +pursuit.suspicion.toFixed(2),
hunters: pursuit.hunters.size,

View File

@ -21,6 +21,7 @@ const IDLE: DriverInput = {
select: null,
toggleMute: false,
abandon: false,
overhaul: false,
};
/** Rapier runs headless, so vehicle tuning is checkable without a browser. */

View File

@ -286,10 +286,52 @@ const camTarget = new THREE.Vector3();
const camDesired = new THREE.Vector3();
const CHASE_OFFSET = new THREE.Vector3(0, 3.4, -8.5);
/** Smoothed chase camera. Frame-rate independent damping. */
export function updateCamera(view: SceneView, dt: number, speed: number): void {
/**
* Where the camera starts and ends up while pulling off a wreck.
*
* It leaves from just behind the car and climbs away until the whole scene is
* below it: the street, whatever was hit, and whoever happens to be standing
* around watching. The point is not the car by then the car is a fact it is
* *where* this happened, which is the thing you have to drive back out to.
*/
const WAKE_FROM = { radius: 9, height: 3.5 };
const WAKE_TO = { radius: 26, height: 62 };
/** Smoothstep. Eases both ends, so the pull-away neither jerks nor drifts. */
const ease = (t: number) => t * t * (3 - 2 * t);
/**
* Smoothed chase camera. Frame-rate independent damping.
*
* @param wake When set, the camera stops chasing and climbs slowly away from
* the car, looking down at where the run ended. Used after the car is written
* off: a hard cut from a crash to a base three hundred metres away reads as a
* bug rather than as a death. `angle` turns the view a little as it goes so
* the shot is not a straight retreat; `progress` runs 0 to 1 over the hold.
*/
export function updateCamera(
view: SceneView,
dt: number,
speed: number,
wake: { angle: number; progress: number } | null = null,
): void {
const { camera, car } = view;
if (wake) {
const t = ease(Math.min(1, Math.max(0, wake.progress)));
const radius = WAKE_FROM.radius + (WAKE_TO.radius - WAKE_FROM.radius) * t;
const height = WAKE_FROM.height + (WAKE_TO.height - WAKE_FROM.height) * t;
camDesired
.set(Math.sin(wake.angle) * radius, height, Math.cos(wake.angle) * radius)
.add(car.position);
// Barely damped: the easing above already shapes the move, and heavy
// smoothing on top of it turns the climb into a lag.
camera.position.lerp(camDesired, 1 - Math.exp(-6 * dt));
camTarget.copy(car.position);
camera.lookAt(camTarget);
return;
}
camDesired.copy(CHASE_OFFSET);
// Pull back a little at speed for a sense of pace.
camDesired.z -= Math.min(Math.abs(speed) * 0.09, 3);

View File

@ -1,6 +1,6 @@
import * as THREE from 'three';
import type { Round } from '../sim/combat';
import type { Unit, UnitState } from '../sim/units';
import { nearestHostile, type Unit, type UnitState } from '../sim/units';
/**
* Draws the inhabited world: traffic, people, patrols, tower gunners, tracers.
@ -28,6 +28,23 @@ const COLOURS = {
insurgent: 0x76c98a,
} as const;
/** How far a crewman can pick you out, for deciding whether to raise the gun. */
const CREW_SIGHT = 110;
/**
* Where the man sits: standing up out of the car, not sitting in it. He has to
* clear the roofline or he is a lump on a box at any useful distance.
*/
const CREW_HEIGHT = 1.75;
/**
* Where the weapon sits when it is up, and when it is across his lap.
*
* The difference between the two has to be readable from across the street,
* because that difference *is* the tell that they have decided about you. A
* subtle change of angle is not a signal, it is a detail.
*/
const GUN_READY = { height: 1.8, reach: 1.5, pitch: 0 };
const GUN_STOWED = { height: 1.15, reach: 0.4, pitch: 1.15 };
function colourOf(unit: Unit): number {
if (unit.faction === 'civilian') {
return unit.kind === 'car' ? COLOURS.civilianCar : COLOURS.civilianPerson;
@ -43,6 +60,9 @@ export function createUnitView(scene: THREE.Scene) {
const position = new THREE.Vector3();
const scale = new THREE.Vector3();
const colour = new THREE.Color();
const euler = new THREE.Euler();
const distanceTo = (a: { x: number; z: number }, b: { x: number; z: number }) =>
Math.hypot(a.x - b.x, a.z - b.z);
const cars = new THREE.InstancedMesh(
new THREE.BoxGeometry(1.8, 1.4, 4),
@ -68,6 +88,32 @@ export function createUnitView(scene: THREE.Scene) {
MAX_ROUNDS,
);
/**
* The man riding in an armed vehicle, and what he is holding.
*
* A patrol car used to be an empty box that occasionally emitted tracers.
* Putting a visible crewman in it does two jobs: it says at a glance that the
* car is a threat rather than traffic, and because the weapon comes up only
* when they have decided about you it makes the moment you are recognised
* something you can *see* happening across the street, rather than a meter
* filling up in the corner of the screen.
*/
const crew = new THREE.InstancedMesh(
new THREE.CapsuleGeometry(0.32, 0.7, 4, 8),
new THREE.MeshStandardMaterial({ roughness: 0.8 }),
MAX_UNITS,
);
crew.instanceColor = new THREE.InstancedBufferAttribute(new Float32Array(MAX_UNITS * 3), 3);
crew.castShadow = true;
const guns = new THREE.InstancedMesh(
new THREE.BoxGeometry(0.16, 0.16, 1.6),
// Lighter than it ought to be, for the same reason the people are: at a
// driver's eye height a rifle is three pixels against a dark street.
new THREE.MeshStandardMaterial({ color: 0x4a5158, roughness: 0.5 }),
MAX_UNITS,
);
// Towers, which only exist once someone has built them.
const towers = new THREE.InstancedMesh(
new THREE.BoxGeometry(3, 6, 3),
@ -76,13 +122,13 @@ export function createUnitView(scene: THREE.Scene) {
);
towers.castShadow = true;
// The gun on top, so a finished checkpoint reads as armed at a glance.
const guns = new THREE.InstancedMesh(
const towerGuns = new THREE.InstancedMesh(
new THREE.BoxGeometry(0.35, 0.35, 2.6),
new THREE.MeshStandardMaterial({ color: 0x23262a, roughness: 0.5 }),
32,
);
for (const mesh of [cars, people, tracers, towers, guns]) {
for (const mesh of [cars, people, crew, guns, tracers, towers, towerGuns]) {
mesh.frustumCulled = false;
scene.add(mesh);
}
@ -93,9 +139,10 @@ export function createUnitView(scene: THREE.Scene) {
};
return {
update(units: UnitState, rounds: Round[], elapsed: number) {
update(units: UnitState, rounds: Round[], elapsed: number, player: { x: number; z: number }) {
let carCount = 0;
let personCount = 0;
let crewCount = 0;
for (const unit of units.units) {
const mesh = unit.kind === 'car' ? cars : people;
@ -109,14 +156,61 @@ export function createUnitView(scene: THREE.Scene) {
scratch.compose(position, quaternion, scale.set(1, 1, 1));
mesh.setMatrixAt(index, scratch);
mesh.setColorAt(index, colour.setHex(colourOf(unit)));
// --- Whoever is riding in it ---
if (unit.kind !== 'car' || unit.faction === 'civilian') continue;
if (crewCount >= MAX_UNITS) continue;
// What he is watching. Hunting means the car itself; otherwise whatever
// of the other army is close enough to be worth pointing a gun at.
const hostile = nearestHostile(units, unit, CREW_SIGHT);
const watching = unit.hunting
? player
: hostile && distanceTo(unit, hostile) < CREW_SIGHT
? hostile
: null;
const facing = watching
? Math.atan2(watching.x - unit.x, watching.z - unit.z)
: unit.heading;
const gun = watching ? GUN_READY : GUN_STOWED;
// The man, turned toward whatever he is watching.
position.set(unit.x, CREW_HEIGHT, unit.z);
quaternion.setFromAxisAngle(up, facing);
scratch.compose(position, quaternion, scale.set(1, 1, 1));
crew.setMatrixAt(crewCount, scratch);
crew.setColorAt(
crewCount,
colour.setHex(
unit.faction === 'insurgent' ? COLOURS.insurgent : COLOURS.enemySoldier,
),
);
// The weapon: levelled and out in front when he has decided about you,
// across his lap and pointing at the floor when he has not.
position.set(
unit.x + Math.sin(facing) * gun.reach * 0.5,
gun.height,
unit.z + Math.cos(facing) * gun.reach * 0.5,
);
euler.set(gun.pitch, facing, 0, 'YXZ');
quaternion.setFromEuler(euler);
scratch.compose(position, quaternion, scale.set(1, 1, 1));
guns.setMatrixAt(crewCount, scratch);
crewCount++;
}
park(cars, carCount);
park(people, personCount);
park(crew, crewCount);
park(guns, crewCount);
cars.instanceMatrix.needsUpdate = true;
people.instanceMatrix.needsUpdate = true;
crew.instanceMatrix.needsUpdate = true;
guns.instanceMatrix.needsUpdate = true;
if (cars.instanceColor) cars.instanceColor.needsUpdate = true;
if (people.instanceColor) people.instanceColor.needsUpdate = true;
if (crew.instanceColor) crew.instanceColor.needsUpdate = true;
// --- Checkpoints under construction, and finished ones ---
let towerCount = 0;
@ -139,13 +233,13 @@ export function createUnitView(scene: THREE.Scene) {
} else {
scratch.compose(HIDDEN, quaternion.identity(), scale.set(1, 1, 1));
}
guns.setMatrixAt(towerCount, scratch);
towerGuns.setMatrixAt(towerCount, scratch);
towerCount++;
}
park(towers, towerCount);
park(guns, towerCount);
park(towerGuns, towerCount);
towers.instanceMatrix.needsUpdate = true;
guns.instanceMatrix.needsUpdate = true;
towerGuns.instanceMatrix.needsUpdate = true;
// --- Tracers ---
let roundCount = 0;

View File

@ -1,9 +1,15 @@
/**
* Car condition and what it does to handling. Pure no engine imports.
*
* Design pillar this serves: "Decline, not reset." Repairs exist, but every
* subsystem carries a *ceiling* that only ever falls. Patching a car brings it
* back up to what it is still capable of, which is never quite what it was.
* Design pillar this serves: "Decline, not reset." Every subsystem carries a
* *ceiling*, and ordinary repair brings a car back up to what it is still
* capable of, which is never quite what it was.
*
* The ceiling used to fall and nothing else the only number in the game that
* moved one way, with no counterplay at any price. In practice that read less
* as a pillar than as a punishment you could not answer. `overhaul` is the
* answer: decline is now something you can fight rather than only absorb, and
* what keeps it meaningful is the exchange rate rather than the impossibility.
*/
export interface Subsystems {
@ -36,6 +42,17 @@ export interface Handling {
steeringPull: number;
}
/**
* Money that buys one full point of a subsystem's condition.
*
* The sim thinks in condition, which runs 0..1; the player thinks in what is in
* their pocket. Fixing the exchange rate here lets every balance number stay in
* the units it is actually about, and keeps the scale of the currency a purely
* presentational decision ¤34 for a supply run reads as a payment, where 0.34
* read as a fraction of something unnamed.
*/
export const FUNDS_PER_CONDITION = 100;
export const SUBSYSTEMS = ['engine', 'tires', 'chassis'] as const;
export const freshCondition = (): CarCondition => ({
@ -123,11 +140,13 @@ const PERMANENT_SHARE = 0.3;
* back as a single event of ~2.5e6 N. Against the old 1e5 reference that was 25
* units of impact, which through the coefficients below wrote off the chassis
* and 37% of its ceiling in one hit, on a car whose whole economy pays about
* 0.3 parts a mission. One crash ended the campaign.
* ¤30 a mission. One crash ended the campaign.
*
* At 1.6e7 the same crash reads as 0.156 impact: about 15% of the chassis and a
* permanent 4.7% off its ceiling. Expensive, survivable, and still the most
* costly thing you can do to the car.
* At 1.6e7 the same crash reads as 0.154 impact. What that then costs is set by
* the coefficients below, and they are deliberately brutal: a proper head-on
* takes about four fifths of the chassis. Hitting a building at ninety should
* very nearly be the end of the car, and the first calibration here which
* left it at 65% and driveable made a serious crash into an inconvenience.
*/
const IMPACT_REFERENCE = 1.6e7;
@ -139,34 +158,38 @@ const IMPACT_REFERENCE = 1.6e7;
* has no natural bound. Without a cap the tail of a bad landing is worth more
* than the landing. This is what stops a single frame from writing off a part.
*
* Set well above the ordinary crash so it stays a backstop rather than the
* number that actually decides what a collision costs.
* Set above even a bad head-on so it stays a backstop rather than the number
* that actually decides what a collision costs. It exists for the pathological
* case a car wedged between two colliders reporting forces in the billions
* not for the crash the player just had.
*/
const MAX_DAMAGE_PER_STEP = 0.25;
const MAX_DAMAGE_PER_STEP = 0.85;
/**
* Returns a new condition.
*
* Baseline wear is deliberately quicker than real life so decline is legible
* within a session, but it has to stay inside what the parts economy can pay
* within a session, but it has to stay inside what the economy can pay
* for. The old figures did not: tyres shed 8e-5 per metre, so twelve clean
* kilometres no crashes at all took them from new to ruined, while a
* two-kilometre round trip paid about 0.3 parts against 0.24 of condition
* two-kilometre round trip paid about ¤30 against 0.24 of condition
* spent. Driving perfectly barely broke even and one knock put you permanently
* behind, which is a decline with no way to arrest it rather than a decision
* about how hard to push the car.
*
* At a quarter of that a typical mission spends about 0.06 condition against
* ~0.3 parts earned, so roughly two clean runs pay for one bad crash. That is
* ~¤30 earned, so roughly two clean runs pay for one bad crash. That is
* the ratio the whole risk/reward loop rests on: enough slack to gamble with,
* not enough to ignore.
*/
export function applyWear(c: CarCondition, w: WearInput): CarCondition {
const impact = w.impactForce / IMPACT_REFERENCE;
const damage: Subsystems = {
engine: w.throttle * w.dt * 1.5e-4 + impact * 0.32,
tires: w.distance * 2e-5 + impact * 0.5,
chassis: impact * 1.0,
// Ordered by what a collision actually ruins: the shell takes the worst of
// it, the tyres and suspension a good share, the engine least of all.
engine: w.throttle * w.dt * 1.5e-4 + impact * 1.6,
tires: w.distance * 2e-5 + impact * 2.5,
chassis: impact * 5.0,
};
const level = {} as Subsystems;
@ -180,12 +203,12 @@ export function applyWear(c: CarCondition, w: WearInput): CarCondition {
}
/**
* Spends parts on the worst subsystem first, never above its ceiling.
* Spends money on the worst subsystem first, never above its ceiling.
* Returns the new condition and whatever could not be used.
*/
export function repair(c: CarCondition, parts: number): { condition: CarCondition; unused: number } {
export function repair(c: CarCondition, funds: number): { condition: CarCondition; unused: number } {
const level = { ...c.level };
let remaining = parts;
let remaining = funds / FUNDS_PER_CONDITION;
// Worst-first: a car with one ruined subsystem drives worse than one that is
// evenly tired, so that is where a scarce part belongs.
@ -201,9 +224,71 @@ export function repair(c: CarCondition, parts: number): { condition: CarConditio
remaining -= spend;
}
return { condition: { level, ceiling: c.ceiling }, unused: remaining };
return { condition: { level, ceiling: c.ceiling }, unused: remaining * FUNDS_PER_CONDITION };
}
/**
* Ceiling bought back per unit of currency spent on an overhaul.
*
* Deliberately a bad rate next to an ordinary repair, which buys a full point
* of condition per part. Rebuilding what a subsystem is *capable* of is not
* patching it up; it is the expensive thing you do when patching has stopped
* helping.
*
* A crash's permanent cost (~0.24 of the chassis ceiling) takes about ¤200 to
* undo, or seven missions' pay.
*/
const CEILING_PER_FUND = 0.12 / FUNDS_PER_CONDITION;
/**
* Spends parts on raising the ceiling itself, worst subsystem first.
*
* The ceiling used to be a pure one-way ratchet: the only number in the game
* that could move in one direction, with no counterplay at any price. That
* reads less as a design pillar than as a punishment you cannot answer you
* watch the car get permanently worse and there is nothing to *do* about it.
*
* So decline is now something you can fight rather than only absorb, and what
* makes it still mean something is the exchange rate. This does not touch the
* current level at all: an overhaul raises what the car could be, and it is
* ordinary repair that then fills it in. Early on every part goes to repairs
* and there is never a surplus; it is only once the ceiling is what is actually
* holding you back that parts start piling up with nowhere else to go.
*/
export function overhaul(c: CarCondition, funds: number): { condition: CarCondition; unused: number } {
const ceiling = { ...c.ceiling };
let remaining = funds;
for (let pass = 0; pass < SUBSYSTEMS.length && remaining > OVERHAUL_EPSILON; pass++) {
const worst = [...SUBSYSTEMS]
.filter((p) => ceiling[p] < 1 - OVERHAUL_EPSILON)
.sort((a, b) => ceiling[a] - ceiling[b])[0];
if (!worst) break;
const room = 1 - ceiling[worst];
const needed = room / CEILING_PER_FUND;
// Snap when the spend covers the whole gap, rather than landing a
// rounding error short of factory and reporting the car as still rebuildable.
ceiling[worst] =
remaining >= needed ? 1 : clamp01(ceiling[worst] + remaining * CEILING_PER_FUND);
remaining -= Math.min(needed, remaining);
}
// Levels are untouched, and still cannot sit above their ceiling — which they
// now never do, since every ceiling here only went up.
return { condition: { level: { ...c.level }, ceiling }, unused: remaining };
}
/**
* Tolerance for "as good as new". Wide enough that accumulated float error
* never leaves a car reporting itself rebuildable when nothing is left to buy.
*/
const OVERHAUL_EPSILON = 1e-6;
/** True when there is nothing an overhaul could buy: the car is as good as new. */
export const canOverhaul = (c: CarCondition): boolean =>
SUBSYSTEMS.some((part) => c.ceiling[part] < 1 - OVERHAUL_EPSILON);
/** Rough single number for the HUD and for deciding when a car is finished. */
export const overallCondition = (c: CarCondition): number =>
(c.level.engine + c.level.tires + c.level.chassis) / 3;

View File

@ -73,6 +73,16 @@ export interface CombatStep {
playerExposed: boolean;
/** Blocks a round: buildings stop bullets. */
blocked: (x: number, z: number) => boolean;
/**
* Can one point see another? Buildings block it.
*
* Rounds already died against walls, but nothing checked before pulling the
* trigger, so units happily emptied magazines into the building between them
* and a target they could not possibly see. What you saw from the car was
* tracers appearing out of solid concrete. Firing is a decision, and it needs
* the same information the round does.
*/
canSee: (from: { x: number; z: number }, to: { x: number; z: number }) => boolean;
}
function fire(state: CombatState, from: Unit, at: { x: number; z: number }, rng: Rng): void {
@ -121,7 +131,7 @@ export function stepCombat(
if (unit.role === 'pedestrian' || unit.role === 'traffic') continue;
const target = nearestHostile(units, unit, RANGE[unit.kind]);
if (target) {
if (target && step.canSee(unit, target)) {
fire(state, unit, target, rng);
continue;
}
@ -132,7 +142,8 @@ export function stepCombat(
unit.faction === 'enemy' &&
step.playerExposed &&
(unit.role === 'garrison' || unit.role === 'patrol') &&
Math.hypot(step.player.x - unit.x, step.player.z - unit.z) < RANGE[unit.kind]
Math.hypot(step.player.x - unit.x, step.player.z - unit.z) < RANGE[unit.kind] &&
step.canSee(unit, step.player)
) {
fire(state, unit, step.player, rng);
}

View File

@ -90,7 +90,7 @@ describe('what a death costs', () => {
expect(quests.active).toBeNull();
expect(quests.completed).toBe(0);
expect(quests.failed).toBe(1);
expect(quests.parts).toBe(0);
expect(quests.funds).toBe(0);
});
it('is a setback, not a campaign reversal', () => {

View File

@ -19,7 +19,7 @@ export interface Opportunity {
kind: OpportunityKind;
x: number;
z: number;
/** Parts paid for stopping. */
/** Money paid for stopping. */
reward: number;
/** Whether helping shifts the front at all. */
impact: number;
@ -59,7 +59,7 @@ const FLAVOUR: Record<OpportunityKind, { hail: string[]; done: string[]; reward:
'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,
reward: 6,
impact: 3,
},
contact: {
@ -71,7 +71,7 @@ const FLAVOUR: Record<OpportunityKind, { hail: string[]; done: string[]; reward:
'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,
reward: 14,
impact: 9,
},
wreck: {
@ -83,7 +83,7 @@ const FLAVOUR: Record<OpportunityKind, { hail: string[]; done: string[]; reward:
'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,
reward: 11,
impact: 0,
},
};

View File

@ -208,7 +208,7 @@ describe('mission lifecycle', () => {
expect(done?.kind).toBe('completed');
expect(state.active).toBeNull();
expect(state.completed).toBe(1);
expect(state.parts).toBeGreaterThan(0);
expect(state.funds).toBeGreaterThan(0);
});
it('accepts a hand-in at any base, so a blocked road home cannot strand you', () => {

View File

@ -73,7 +73,7 @@ export interface Offer {
novel: boolean;
route: Route;
intel: RouteIntel;
/** Parts awarded on hand-in. */
/** Money paid on hand-in. */
reward: number;
/**
* How far this job pushes the front line, in metres. The brief asks for
@ -97,8 +97,8 @@ export interface QuestState {
completed: number;
/** Jobs dropped or lost with the car. Counted separately: they are not runs. */
failed: number;
/** Unspent repair parts. */
parts: number;
/** Money in hand, spent on repairs and overhauls. */
funds: number;
nextId: number;
}
@ -106,7 +106,7 @@ export const createQuests = (): QuestState => ({
active: null,
completed: 0,
failed: 0,
parts: 0,
funds: 0,
nextId: 1,
});
@ -138,11 +138,11 @@ const CARGO_FRAGILITY = 1.4e-6;
* known targets are never taken, too high.
*/
const NOVELTY_BONUS = 1.8;
const PARTS_PER_KM = 0.1;
const PAY_PER_KM = 10;
/** Exported so the novelty premium can be tested on like for like. */
export function rewardFor(type: MissionType, route: Route, novel: boolean): number {
const base = 0.08 + (route.length / 1000) * PARTS_PER_KM;
const base = 8 + (route.length / 1000) * PAY_PER_KM;
return base * (novel ? NOVELTY_BONUS : 1) * MISSION_SHAPE[type].reward;
}
@ -232,7 +232,7 @@ export type QuestEvent =
| { kind: 'arrived' }
| { kind: 'worked'; type: MissionType }
| { kind: 'cargoDamaged'; cargo: number }
| { kind: 'completed'; parts: number; impact: number; cargo: number };
| { kind: 'completed'; paid: number; impact: number; cargo: number };
/** Advances the active mission. Returns whatever just happened, if anything. */
export function stepQuest(state: QuestState, step: QuestStep): QuestEvent | null {
@ -281,8 +281,8 @@ export function stepQuest(state: QuestState, step: QuestStep): QuestEvent | null
const paid = quest.reward * (MISSION_SHAPE[quest.type].cargoHome ? quest.cargo : 1);
state.active = null;
state.completed++;
state.parts += paid;
return { kind: 'completed', parts: paid, impact: quest.impact * quest.cargo, cargo: quest.cargo };
state.funds += paid;
return { kind: 'completed', paid, impact: quest.impact * quest.cargo, cargo: quest.cargo };
}
return damaged ? { kind: 'cargoDamaged', cargo: quest.cargo } : null;

View File

@ -29,7 +29,7 @@ function played(): Snapshot {
applyMissionImpact(front, 18);
quests.completed = 3;
quests.failed = 1;
quests.parts = 0.42;
quests.funds = 42;
quests.nextId = 7;
return {
@ -78,7 +78,7 @@ describe('save round trip', () => {
expect(restored.heat.level).toEqual(source.heat.level);
expect(restored.quests.completed).toBe(3);
expect(restored.quests.failed).toBe(1);
expect(restored.quests.parts).toBeCloseTo(0.42, 9);
expect(restored.quests.funds).toBeCloseTo(42, 9);
// A campaign's history of written-off cars has to outlive the tab, or the
// ratchet the KIA rule exists to drive resets every time you close it.
expect(restored.kia).toBe(2);

View File

@ -16,7 +16,7 @@ import type { Intel } from './intel';
import type { Boundaries, Front } from './regions';
import type { ActiveQuest, QuestState } from './quests';
export const SAVE_VERSION = 3;
export const SAVE_VERSION = 4;
export interface CarSnapshot {
position: [number, number, number];
@ -47,7 +47,7 @@ export interface SaveData {
active: ActiveQuest | null;
completed: number;
failed: number;
parts: number;
funds: number;
nextId: number;
};
/** Times the car has been written off underneath the player. */
@ -89,7 +89,7 @@ export function serialise(s: Snapshot): SaveData {
active: s.quests.active,
completed: s.quests.completed,
failed: s.quests.failed,
parts: s.quests.parts,
funds: s.quests.funds,
nextId: s.quests.nextId,
},
kia: s.kia,
@ -146,7 +146,7 @@ export function apply(data: SaveData, into: Snapshot): void {
into.quests.active = data.quests.active;
into.quests.completed = data.quests.completed;
into.quests.failed = data.quests.failed;
into.quests.parts = data.quests.parts;
into.quests.funds = data.quests.funds;
into.quests.nextId = data.quests.nextId;
into.kia = data.kia;
}

View File

@ -1,6 +1,16 @@
import { describe, expect, it } from 'vitest';
import { generateWorld } from './world';
import { applyWear, deriveHandling, freshCondition, repair, SUBSYSTEMS, type CarCondition } from './car';
import {
applyWear,
canOverhaul,
FUNDS_PER_CONDITION,
deriveHandling,
freshCondition,
overhaul,
repair,
SUBSYSTEMS,
type CarCondition,
} from './car';
describe('world generation', () => {
it('is reproducible from a seed', () => {
@ -46,43 +56,61 @@ describe('car condition', () => {
*/
const HARD_CRASH = 2.47e6;
it('leaves a car driveable after one hard crash', () => {
it('very nearly ends the car in one head-on', () => {
const c = applyWear(freshCondition(), {
dt: 1 / 60,
distance: 0.4,
throttle: 0,
impactForce: HARD_CRASH,
});
// Expensive — the worst single thing that can happen to the car — but the
// campaign continues. The first pass wrote off the chassis in this one step.
expect(c.level.chassis).toBeGreaterThan(0.7);
expect(c.level.chassis).toBeLessThan(0.95);
expect(c.ceiling.chassis).toBeGreaterThan(0.9);
// Hitting a building at ninety is meant to be close to the end of it. The
// shell takes the worst, the tyres a good share, the engine least.
expect(c.level.chassis).toBeLessThan(0.3);
expect(c.level.chassis).toBeGreaterThan(0.1);
expect(c.level.tires).toBeLessThan(c.level.engine);
expect(c.level.engine).toBeLessThan(0.8);
});
it('is survivable exactly once, which is what makes it worth avoiding', () => {
// One is a disaster you drive home from. Two in a row is the end of the
// car, and that is the whole shape of the risk.
const one = applyWear(freshCondition(), {
dt: 1 / 60,
distance: 0.4,
throttle: 0,
impactForce: HARD_CRASH,
});
expect(one.level.chassis).toBeGreaterThan(0);
const two = applyWear(one, {
dt: 1 / 60,
distance: 0.4,
throttle: 0,
impactForce: HARD_CRASH,
});
expect(two.level.chassis).toBe(0);
});
it('caps what a single step can take, however violent the contact', () => {
// A pileup reports contact forces without any natural bound; without a cap
// one frame of a bad landing is worth more than the crash that caused it.
const c = applyWear(freshCondition(), {
// A car wedged between two colliders reports forces in the billions. The
// cap is for that, not for the crash the player just had — so it has to sit
// above even a bad head-on or it becomes the number deciding every impact.
const pathological = applyWear(freshCondition(), {
dt: 1 / 60,
distance: 0.4,
throttle: 1,
impactForce: 1e9,
});
for (const part of SUBSYSTEMS) {
expect(c.level[part]).toBeGreaterThanOrEqual(0.74);
expect(pathological.level[part]).toBeGreaterThanOrEqual(0.14);
}
});
it('takes a sustained battering to write the car off', () => {
let c = freshCondition();
let crashes = 0;
while (c.level.chassis > 0 && crashes < 100) {
c = applyWear(c, { dt: 1 / 60, distance: 0.4, throttle: 0, impactForce: HARD_CRASH });
crashes++;
}
// Enough hard crashes to be a story about the driver, not one bad corner.
expect(crashes).toBeGreaterThan(5);
// And it must not be what decides an ordinary head-on.
const crash = applyWear(freshCondition(), {
dt: 1 / 60,
distance: 0.4,
throttle: 0,
impactForce: HARD_CRASH,
});
expect(crash.level.chassis).toBeGreaterThan(pathological.level.chassis);
});
it('lets a careful driver come out ahead of the parts they are paid', () => {
@ -124,30 +152,31 @@ describe('repair', () => {
const c = damaged();
expect(c.ceiling.chassis).toBeLessThan(1);
// No amount of parts can undo it.
const restored = repair(c, 99).condition;
const restored = repair(c, 9999).condition;
expect(restored.level.chassis).toBeLessThan(1);
expect(restored.level.chassis).toBeCloseTo(c.ceiling.chassis, 6);
});
it('never lifts a subsystem above its ceiling', () => {
const c = damaged();
const restored = repair(c, 99).condition;
const restored = repair(c, 9999).condition;
for (const part of SUBSYSTEMS) {
expect(restored.level[part]).toBeLessThanOrEqual(restored.ceiling[part] + 1e-9);
}
});
it('hands back parts it could not use', () => {
const { unused } = repair(freshCondition(), 5);
expect(unused).toBeCloseTo(5, 6);
const { unused } = repair(freshCondition(), 500);
expect(unused).toBeCloseTo(500, 6);
});
it('spends a scarce part on the worst subsystem', () => {
it('spends scarce money on the worst subsystem', () => {
const c: CarCondition = {
level: { engine: 0.9, tires: 0.2, chassis: 0.8 },
ceiling: { engine: 1, tires: 1, chassis: 1 },
};
const restored = repair(c, 0.1).condition;
// A tenth of a point of condition, in what the player actually carries.
const restored = repair(c, 0.1 * FUNDS_PER_CONDITION).condition;
expect(restored.level.tires).toBeCloseTo(0.3, 6);
expect(restored.level.engine).toBe(0.9);
});
@ -159,7 +188,7 @@ describe('repair', () => {
for (let i = 0; i < 30; i++) {
c = applyWear(c, { dt: 1 / 60, distance: 3, throttle: 1, impactForce: 2e4 });
}
c = repair(c, 99).condition;
c = repair(c, 9999).condition;
// Fully repaired every cycle, and still worse off every cycle.
expect(c.level.chassis).toBeCloseTo(c.ceiling.chassis, 6);
expect(c.ceiling.chassis).toBeLessThan(previousCeiling);
@ -221,3 +250,83 @@ describe('districts and landmarks', () => {
);
});
});
describe('overhaul', () => {
/** A car that has been through it: low ceilings, levels patched up to them. */
const battered = () => {
let c = freshCondition();
for (let i = 0; i < 6; i++) {
c = applyWear(c, { dt: 1 / 60, distance: 0.4, throttle: 0, impactForce: 1.2e6 });
c = repair(c, 9999).condition;
}
return c;
};
it('is the one thing that can push a ceiling back up', () => {
const c = battered();
expect(c.ceiling.chassis).toBeLessThan(0.9);
const after = overhaul(c, 300).condition;
expect(after.ceiling.chassis).toBeGreaterThan(c.ceiling.chassis);
});
it('leaves the level alone — it buys capability, not condition', () => {
const c = battered();
const after = overhaul(c, 300).condition;
expect(after.level).toEqual(c.level);
// Which is what makes it a *second* spend: the room it opens up still has
// to be filled by ordinary repair.
expect(after.level.chassis).toBeLessThan(after.ceiling.chassis);
});
it('works on the worst subsystem first', () => {
const c: CarCondition = {
level: { engine: 0.5, tires: 0.5, chassis: 0.5 },
ceiling: { engine: 0.9, tires: 0.5, chassis: 0.8 },
};
const after = overhaul(c, 100).condition;
expect(after.ceiling.tires).toBeGreaterThan(0.5);
expect(after.ceiling.engine).toBe(0.9);
});
it('is a worse rate than patching the car up, and deliberately so', () => {
const c: CarCondition = {
level: { engine: 0.5, tires: 0.5, chassis: 0.5 },
ceiling: { engine: 0.5, tires: 0.5, chassis: 0.5 },
};
const repaired = repair({ ...c, ceiling: { engine: 1, tires: 1, chassis: 1 } }, 50).condition;
const rebuilt = overhaul(c, 50).condition;
const repairedGain = repaired.level.engine + repaired.level.tires + repaired.level.chassis - 1.5;
const rebuiltGain = rebuilt.ceiling.engine + rebuilt.ceiling.tires + rebuilt.ceiling.chassis - 1.5;
expect(rebuiltGain).toBeLessThan(repairedGain);
});
it('never goes past factory, and hands back what it could not use', () => {
const { condition, unused } = overhaul(battered(), 9999);
for (const part of SUBSYSTEMS) expect(condition.ceiling[part]).toBeCloseTo(1, 9);
expect(unused).toBeGreaterThan(0);
expect(canOverhaul(condition)).toBe(false);
});
it('costs a run of missions to undo one crash, not one and not fifty', () => {
// The exchange rate is the whole design. Free and the ceiling means
// nothing; unaffordable and it is the ratchet it was before.
const crashed = applyWear(freshCondition(), {
dt: 1 / 60,
distance: 0.4,
throttle: 0,
impactForce: 2.47e6,
});
const MISSION_PAY = 30;
let missions = 0;
let c = crashed;
while (canOverhaul(c) && missions < 200) {
c = overhaul(c, MISSION_PAY).condition;
missions++;
}
expect(1 - crashed.ceiling.chassis).toBeGreaterThan(0.15);
// Putting the whole car back to factory after one bad crash: an evening's
// work, not a weekend's and not a single job.
expect(missions).toBeGreaterThan(6);
expect(missions).toBeLessThan(20);
});
});

View File

@ -82,7 +82,7 @@ function runMission(type: MissionType, impactPerStep = 0) {
speed: 0,
impactForce: 0,
});
return { done, cargo, parts: state.parts };
return { done, cargo, paid: state.funds };
}
describe('carrying cargo home', () => {
@ -96,13 +96,13 @@ describe('carrying cargo home', () => {
const clean = runMission('retrieval');
const rough = runMission('retrieval', 4e4);
expect(rough.cargo).toBeLessThan(clean.cargo);
expect(rough.parts).toBeLessThan(clean.parts);
expect(rough.paid).toBeLessThan(clean.paid);
});
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);
expect(rough.paid).toBeCloseTo(runMission('supply').paid, 9);
});
});

View File

@ -7,6 +7,7 @@ import { createHeat, createAreas } from './heat';
import { freshCondition } from './car';
import {
AMBIENT_ALLIES,
AMBIENT_ALLY_CARS,
AMBIENT_PATROLS,
createUnits,
dispatchTo,
@ -289,8 +290,9 @@ describe('a war going on regardless', () => {
run(state, 300, { player: { x: 0, z: 0 } }, makeRng(21));
for (const fighter of state.units.filter((u) => u.role === 'fighter')) {
const control = controlAt(front, fighter.x, fighter.z);
// Spawned where the war is — they may drift, but not from behind a line.
expect(['contested', 'occupied']).toContain(control);
// Spawned where the war is, and far enough past your line that closing
// on each other cannot walk them back over it.
expect(control).not.toBe('liberated');
}
});
});
@ -335,7 +337,7 @@ describe('rounds in flight', () => {
stepCombat(
combat,
units,
{ dt: 1 / 60, player: { x: 999, z: 999 }, playerExposed: false, blocked: () => false },
{ dt: 1 / 60, player: { x: 999, z: 999 }, playerExposed: false, blocked: () => false, canSee: () => true },
freshCondition(),
makeRng(1),
);
@ -353,7 +355,7 @@ describe('rounds in flight', () => {
const result = stepCombat(
combat,
units,
{ dt: 1 / 60, player: { x: 15, z: 0 }, playerExposed: false, blocked: () => false },
{ dt: 1 / 60, player: { x: 15, z: 0 }, playerExposed: false, blocked: () => false, canSee: () => true },
condition,
rng,
);
@ -380,6 +382,7 @@ describe('rounds in flight', () => {
playerExposed: false,
// Walls between the player and each squad, so nothing has a line.
blocked: (x) => (x > 8 && x < 12) || (x > 18 && x < 22),
canSee: () => true,
},
condition,
makeRng(5),
@ -414,7 +417,7 @@ describe('rounds in flight', () => {
stepCombat(
combat,
units,
{ dt: 1 / 60, player: { x: 10, z: 0 }, playerExposed: false, blocked: () => false },
{ dt: 1 / 60, player: { x: 10, z: 0 }, playerExposed: false, blocked: () => false, canSee: () => true },
freshCondition(),
makeRng(9),
);
@ -447,7 +450,7 @@ describe('rounds in flight', () => {
stepCombat(
combat,
units,
{ dt: 1 / 60, player: { x: 10, z: 0 }, playerExposed: true, blocked: () => false },
{ dt: 1 / 60, player: { x: 10, z: 0 }, playerExposed: true, blocked: () => false, canSee: () => true },
freshCondition(),
makeRng(9),
);
@ -611,7 +614,7 @@ describe('your own side, standing in your own streets', () => {
// Just past the line, some of the ones you set off with are still behind
// you in contested ground and legitimately still there.
run(state, 60, { player: deepIn('occupied') }, makeRng(21));
expect(militia()).toBeLessThan(home / 2);
expect(militia()).toBeLessThanOrEqual(home / 2);
// Out on the frontier there is nobody at all. That is what past the line
// means, and it is the one place a friendly face would be a safety net the
@ -627,3 +630,266 @@ describe('your own side, standing in your own streets', () => {
expect(populate('liberated').length).toBeGreaterThan(0);
});
});
describe('walls are real', () => {
/** A slab across the middle of the world, with open ground either side. */
const wall = (x: number, z: number) => Math.abs(x) < 40 && z > 30 && z < 60;
it('keeps people on foot out of the buildings', () => {
const state = createUnits();
run(state, 200, { player: { x: 0, z: 0 }, blocked: wall }, makeRng(12));
const onFoot = state.units.filter((u) => u.kind === 'soldier');
expect(onFoot.length).toBeGreaterThan(5);
for (const u of onFoot) expect(wall(u.x, u.z)).toBe(false);
});
it('does not let a chase cut through the middle of one', () => {
const state = createUnits();
const unit = dispatchTo(state, world.roads, graph, world.roads.segments[4]!, 'patrol', makeRng(7))!;
unit.x = 0;
unit.z = 0;
unit.path = [];
unit.hunting = true;
for (let i = 0; i < 60 * 10; i++) {
stepUnits(
state,
{
dt: 1 / 60,
now: i / 60,
player: { x: 0, z: 300 },
front,
heatLevel: () => 'clear',
decayHeat: () => {},
decayArea: () => {},
hunt: { x: 0, z: 300 },
blocked: wall,
},
world.roads,
graph,
makeRng(5),
);
expect(wall(unit.x, unit.z)).toBe(false);
}
});
});
describe('nobody fires at what they cannot see', () => {
/** Two hostiles in range of each other with something solid between them. */
const facingOff = () => {
const units = createUnits();
const place = (faction: 'enemy' | 'insurgent', x: number) => {
units.units.push({
id: units.nextId++,
kind: 'soldier',
faction,
role: 'fighter',
x,
z: 0,
heading: 0,
speed: 0,
hp: 30,
path: [],
expires: 999,
assigned: null,
onStation: 0,
cooldown: 0,
elevation: 1.2,
});
};
place('enemy', -20);
place('insurgent', 20);
return units;
};
const shotsOver = (canSee: () => boolean) => {
const combat = createCombat();
const units = facingOff();
let fired = 0;
for (let i = 0; i < 300; i++) {
const r = stepCombat(
combat,
units,
{
dt: 1 / 60,
player: { x: 999, z: 999 },
playerExposed: false,
blocked: () => false,
canSee,
},
freshCondition(),
makeRng(9),
);
fired += r.fired.length;
}
return fired;
};
it('holds fire through a wall, and opens up without one', () => {
// The rounds always died against the building. What nothing checked was
// whether to pull the trigger, so tracers appeared out of solid concrete.
expect(shotsOver(() => false)).toBe(0);
expect(shotsOver(() => true)).toBeGreaterThan(0);
});
});
describe('traffic that drives like traffic', () => {
/** Where each car sits relative to the centre of the road it is on. */
const sideOfRoad = (unit: { x: number; z: number; heading: number }, seg: (typeof world.roads.segments)[number]) => {
// Signed offset from the segment's centreline, positive to the car's right.
const along = Math.atan2(seg.bx - seg.ax, seg.bz - seg.az);
const px = unit.x - seg.ax;
const pz = unit.z - seg.az;
const lateral = px * Math.cos(along) - pz * Math.sin(along);
// Flip for cars travelling the other way down the same segment.
return Math.cos(unit.heading - along) >= 0 ? lateral : -lateral;
};
const traffic = () => {
const state = createUnits();
run(state, 240, { player: { x: world.spawn.x, z: world.spawn.z } }, makeRng(31));
return state.units.filter((u) => u.role === 'traffic');
};
it('keeps right rather than straddling the centreline', () => {
// Sampled over four minutes rather than from one frozen frame: at any one
// instant only a handful of cars are squarely mid-segment, and four cars is
// not a measurement.
const state = createUnits();
let onTheRight = 0;
let judged = 0;
for (let i = 0; i < 2400; i++) {
stepUnits(
state,
{
dt: 0.1,
now: i * 0.1,
player: { x: world.spawn.x, z: world.spawn.z },
front,
heatLevel: () => 'clear',
decayHeat: () => {},
decayArea: () => {},
hunt: null,
},
world.roads,
graph,
makeRng(31),
);
if (i % 10 !== 0) continue;
for (const car of state.units) {
if (car.role !== 'traffic' && car.role !== 'convoy') continue;
const seg = world.roads.segments.find((s) => {
const t =
((car.x - s.ax) * (s.bx - s.ax) + (car.z - s.az) * (s.bz - s.az)) / (s.length * s.length);
// Well inside the segment: at a junction there is no side to be on.
if (t < 0.25 || t > 0.75) return false;
const cx = s.ax + (s.bx - s.ax) * t;
const cz = s.az + (s.bz - s.az) * t;
return Math.hypot(car.x - cx, car.z - cz) < s.width / 2;
});
if (!seg) continue;
const along = Math.atan2(seg.bx - seg.ax, seg.bz - seg.az);
// Only cars travelling along this road, not crossing it.
if (Math.abs(Math.cos(car.heading - along)) < 0.9) continue;
judged++;
if (sideOfRoad(car, seg) > 0) onTheRight++;
}
}
expect(judged).toBeGreaterThan(200);
expect(onTheRight / judged).toBeGreaterThan(0.8);
});
it('does not drive through the car in front', () => {
const cars = traffic();
for (const car of cars) {
for (const other of cars) {
if (other === car) continue;
// Nose to nose. Cars passing in opposite directions are fine — they are
// on opposite sides of the road — so only same-direction pairs count.
if (Math.cos(other.heading - car.heading) < 0.5) continue;
expect(Math.hypot(other.x - car.x, other.z - car.z)).toBeGreaterThan(1.5);
}
}
});
it('turns the nose rather than snapping it', () => {
const state = createUnits();
let worst = 0;
const before = new Map<number, number>();
for (let i = 0; i < 60 * 90; i++) {
stepUnits(
state,
{
dt: 1 / 60,
now: i / 60,
player: { x: world.spawn.x, z: world.spawn.z },
front,
heatLevel: () => 'clear',
decayHeat: () => {},
decayArea: () => {},
hunt: null,
},
world.roads,
graph,
makeRng(17),
);
for (const u of state.units) {
if (u.role !== 'traffic') continue;
const was = before.get(u.id);
if (was !== undefined) {
let delta = Math.abs(u.heading - was);
if (delta > Math.PI) delta = Math.PI * 2 - delta;
worst = Math.max(worst, delta);
}
before.set(u.id, u.heading);
}
}
// A right-angle junction used to be one frame of instant pivot. Nothing
// should now turn faster than a car can be steered.
expect(worst).toBeLessThanOrEqual((2.2 * 1) / 60 + 1e-6);
});
});
describe('your own side, with vehicles', () => {
const deepIn = (control: 'liberated' | 'contested' | 'occupied') => {
const wanted = { liberated: -140, contested: 40, occupied: 240 }[control];
const depth = front.boundaries.liberated + wanted;
return { x: front.axis.x * depth, z: front.axis.z * depth };
};
const convoys = (control: 'liberated' | 'contested' | 'occupied') => {
const state = createUnits();
run(state, 200, { player: deepIn(control) }, makeRng(8));
return state.units.filter((u) => u.role === 'convoy');
};
it('puts allied vehicles on ground your side holds', () => {
expect(convoys('liberated').length).toBeGreaterThan(0);
expect(convoys('liberated').length).toBeLessThanOrEqual(AMBIENT_ALLY_CARS.liberated);
});
it('stops at the line, like everything else of yours', () => {
expect(convoys('occupied').length).toBe(0);
});
it('never routes one onto ground your side does not hold', () => {
const state = createUnits();
run(state, 200, { player: deepIn('contested') }, makeRng(8));
for (const u of state.units.filter((c) => c.role === 'convoy')) {
const control = controlAt(front, u.x, u.z);
expect(control === 'liberated' || control === 'contested').toBe(true);
}
});
it('carries a crew that the enemy will shoot at', () => {
// Insurgent faction, so nearestHostile treats them as a target and they
// treat the enemy as one — a convoy is not scenery.
const state = createUnits();
run(state, 200, { player: deepIn('liberated') }, makeRng(8));
for (const u of state.units.filter((c) => c.role === 'convoy')) {
expect(u.faction).toBe('insurgent');
expect(u.kind).toBe('car');
// Rounds leave the man riding in it, not the bonnet.
expect(u.elevation).toBeGreaterThan(1.2);
}
});
});

View File

@ -14,7 +14,7 @@ import type { RoadNetwork, RoadSegment } from './roads';
import { ROAD_SPEED, pointOnSegment, projectOntoSegment } from './roads';
import { findRoute, travelTime, type Graph } from './routing';
import type { Control, Front } from './regions';
import { controlAt } from './regions';
import { controlAt, depthAt } from './regions';
export type Faction = 'enemy' | 'insurgent' | 'civilian';
export type UnitKind = 'car' | 'soldier';
@ -33,7 +33,9 @@ export type Role =
/** Fighting the other army. */
| 'fighter'
/** Your own side, holding the ground it holds. Thick at home, thin at the line. */
| 'militia';
| 'militia'
/** Your own side, with a vehicle and somewhere to be. */
| 'convoy';
export interface Unit {
id: number;
@ -63,6 +65,8 @@ export interface Unit {
hunting?: boolean;
/** Current speed while chasing, wound up from a standing start. */
chaseSpeed?: number;
/** Junction most recently left, so the leg being driven is known. */
lastNode?: number;
}
/**
@ -135,12 +139,12 @@ const HUNT_SPEED = 21;
/** How quickly a hunter winds up to it, m/s². Nobody is at chase speed at once. */
const HUNT_ACCELERATION = 7;
/**
* Headings a hunter will try, in order, when the straight line is blocked:
* dead ahead, then further and further round either side of the obstruction.
* Alternating sides means they take whichever way round is actually open
* rather than committing to a direction and grinding along a wall.
* Headings anything on foot or on wheels will try, in order, when the straight
* line is blocked: dead ahead, then further and further round either side of
* the obstruction. Alternating sides means they take whichever way round is
* actually open rather than committing to a direction and grinding along a wall.
*/
const HUNT_DETOURS = [0, 0.6, -0.6, 1.2, -1.2, 1.9, -1.9];
const DETOURS = [0, 0.6, -0.6, 1.2, -1.2, 1.9, -1.9];
/** Seconds a dispatched patrol works its road before leaving. */
const PATROL_DURATION = 90;
/** Heat removed per second by a patrol actually driving its assigned road. */
@ -163,6 +167,8 @@ export const PATROL_AREA_DECAY = 0.0005;
const BUILD_SECONDS: Record<BuildStage, number> = { barricade: 22, tower: 40 };
/** Minimum gap between skirmishes breaking out. */
const SKIRMISH_SPACING = 55;
/** How far past your own line a fight has to start, so it stays past it. */
const SKIRMISH_CLEARANCE = 70;
/**
* Standing patrols the enemy keeps on the roads near the player, by whose
@ -207,6 +213,22 @@ export const AMBIENT_ALLIES: Record<Control, number> = {
frontier: 0,
};
/**
* Your own side's vehicles, on the same gradient as the men on foot.
*
* Fewer than the militia, because a truck is a scarcer thing than a man with a
* rifle, and because the roads have to stay legible: friendly traffic that
* outnumbered the civilian kind would turn every road into a convoy. They exist
* mostly so home ground has something moving on it that is *yours* a road
* with only civilians on it reads the same behind the lines as past them.
*/
export const AMBIENT_ALLY_CARS: Record<Control, number> = {
liberated: 4,
contested: 2,
occupied: 0,
frontier: 0,
};
/**
* How far from the player a militiaman is still worth simulating.
*
@ -246,25 +268,205 @@ function nearestNode(roads: RoadNetwork, x: number, z: number): number {
return best.id;
}
/** Steps a unit along its path. Returns true when the path is exhausted. */
function advance(unit: Unit, roads: RoadNetwork, dt: number): boolean {
/**
* Moves a unit, going round whatever is in the way rather than through it.
*
* Every kind of free movement in this file used to write straight into x and z,
* which meant pedestrians, militia and fighters all walked through walls and
* a world where people stand inside buildings is one where cover means nothing,
* because nothing is really there. Returns the heading actually taken.
*/
function moveThrough(
unit: Unit,
heading: number,
distance: number,
blocked?: (x: number, z: number) => boolean,
): number {
for (const offset of DETOURS) {
const tried = heading + offset;
const x = unit.x + Math.sin(tried) * distance;
const z = unit.z + Math.cos(tried) * distance;
if (blocked?.(x, z)) continue;
unit.x = x;
unit.z = z;
return tried;
}
// Boxed in on every heading. Stay put rather than clipping out of it.
return heading;
}
/** Range a fighter closes to before standing its ground. */
const gapTooFar = (gap: number) => gap > 45;
/**
* Which segment joins two junctions, built once per network and cached.
*
* Needed so a car can know how wide the road it is on actually is, and put
* itself the right distance off the centreline. Scanning the segment list per
* car per step would not be worth the lane it buys.
*/
const segmentIndexes = new WeakMap<RoadNetwork, Map<number, RoadSegment>>();
function segmentBetween(roads: RoadNetwork, a: number, b: number): RoadSegment | undefined {
let index = segmentIndexes.get(roads);
if (!index) {
index = new Map();
for (const s of roads.segments) {
index.set(s.a * 100000 + s.b, s);
index.set(s.b * 100000 + s.a, s);
}
segmentIndexes.set(roads, index);
}
return index.get(a * 100000 + b);
}
/**
* How far right of the centreline a vehicle sits, as a share of half-width.
*
* Traffic used to drive straight down the middle of every road in both
* directions, which meant two cars meeting head-on occupied the same metre of
* tarmac and passed through each other. Keeping right also gives the player a
* side of the road to be on, which is most of what makes a road feel like a
* road rather than a corridor.
*/
const LANE_SHARE = 0.45;
/** How far ahead a driver looks along their lane when deciding where to point. */
const LANE_LOOKAHEAD = 11;
/** How quickly a driver can swing the nose round, radians per second. */
const TURN_RATE = 2.2;
/** Gap a driver keeps to whatever is in front, metres. */
const FOLLOW_GAP = 9;
/** How far ahead they look for it. */
const FOLLOW_RANGE = 26;
/** How wide a lane counts as "in front of me" rather than "beside me". */
const FOLLOW_WIDTH = 2.6;
/** Closest two vehicles ever get, centre to centre. A car is 1.8m by 4m. */
const CAR_SEPARATION = 4.5;
/** Muzzle height for a man riding in a car, so rounds leave him and not the bonnet. */
export const CREW_ELEVATION = 1.5;
/** Turns `from` toward `to`, by at most `limit`. Both radians. */
function turnToward(from: number, to: number, limit: number): number {
let delta = to - from;
while (delta > Math.PI) delta -= Math.PI * 2;
while (delta < -Math.PI) delta += Math.PI * 2;
return from + Math.max(-limit, Math.min(limit, delta));
}
/**
* Distance to the vehicle in front, or null if the road ahead is clear.
*
* Three restrictions, each of which exists to stop the traffic locking up:
*
* - A **cone**, not a radius. A car alongside is not in the way, and treating
* it as though it were gridlocks every junction.
* - Only cars going **roughly the same way**. You brake for the car in front,
* not for the one coming the other way lane discipline already handles
* that, and on a single track two oncoming cars would otherwise each wait
* for the other forever.
* - Only **civilian traffic** yields. A patrol queueing politely behind a bus
* never reaches the road it was sent to, and the whole escalation chain is
* built on dispatched units actually arriving.
*/
function carAhead(state: UnitState, unit: Unit): number | null {
const forwardX = Math.sin(unit.heading);
const forwardZ = Math.cos(unit.heading);
let nearest: number | null = null;
for (const other of state.units) {
if (other === unit || other.kind !== 'car' || other.role !== 'traffic') continue;
if (Math.cos(other.heading - unit.heading) < 0) continue;
const dx = other.x - unit.x;
const dz = other.z - unit.z;
const ahead = dx * forwardX + dz * forwardZ;
if (ahead <= 0 || ahead > FOLLOW_RANGE) continue;
if (Math.abs(dx * forwardZ - dz * forwardX) > FOLLOW_WIDTH) continue;
if (nearest === null || ahead < nearest) nearest = ahead;
}
return nearest;
}
/**
* Steps a unit along its path. Returns true when the path is exhausted.
*
* Three things beyond "move toward the next node": it aims at a point off to
* the right of the centreline rather than at the junction itself, it eases the
* nose round rather than snapping it, and it lifts off for whatever is in
* front instead of driving through it.
*/
function advance(unit: Unit, roads: RoadNetwork, state: UnitState, dt: number): boolean {
const next = unit.path[0];
if (next === undefined) return true;
const node = nodeById(roads, next);
const dx = node.x - unit.x;
const dz = node.z - unit.z;
const remaining = Math.hypot(dx, dz);
const remaining = Math.hypot(node.x - unit.x, node.z - unit.z);
if (remaining < 3) {
/*
* Drive a line parallel to the centreline, a share of the road's own
* half-width to the right of it so a track gets a nudge and a trunk gets a
* lane, and two cars meeting head-on are on opposite sides of the road.
*
* Built from the leg actually being driven, which is why units remember the
* junction they last left. Deriving the axis from the car-to-node direction
* instead is nearly right in the middle of a leg and degenerates completely
* near either end of it, where the direction swings through ninety degrees
* over a few metres and a grid map with hundred-metre blocks means cars
* spend most of their time near a junction.
*/
const previous = nodeById(roads, unit.lastNode ?? next);
let dirX = node.x - previous.x;
let dirZ = node.z - previous.z;
const legLength = Math.hypot(dirX, dirZ);
if (legLength < 1) {
// No leg to speak of — first step out of a spawn, or a doubled-back path.
dirX = (node.x - unit.x) / remaining;
dirZ = (node.z - unit.z) / remaining;
} else {
dirX /= legLength;
dirZ /= legLength;
}
// Right of the direction of travel.
const rightX = dirZ;
const rightZ = -dirX;
const halfWidth = (segmentBetween(roads, unit.lastNode ?? next, next)?.width ?? 9) / 2;
const offset = halfWidth * LANE_SHARE;
const laneX = previous.x + rightX * offset;
const laneZ = previous.z + rightZ * offset;
/*
* Arrived, if either the junction is close enough or the car has simply
* driven past it.
*
* "Close enough" has to know about the lane, because the car is deliberately
* not aiming at the junction: on a fifteen-metre trunk it passes three and a
* half metres to one side of it. A fixed three-metre radius meant dispatched
* units drove straight past their destination and never registered arriving,
* so no patrol ever worked a road and no engineer ever poured any concrete.
*/
const beyond = (unit.x - node.x) * dirX + (unit.z - node.z) * dirZ;
if (remaining < 3 + offset || beyond > -0.5) {
unit.lastNode = next;
unit.path.shift();
return unit.path.length === 0;
}
unit.heading = Math.atan2(dx, dz);
const move = Math.min(remaining, unit.speed * dt);
unit.x += (dx / remaining) * move;
unit.z += (dz / remaining) * move;
// Pure pursuit: aim at the point on the lane line a fixed distance ahead of
// wherever the car currently projects onto it. Short lookahead weaves, long
// lookahead never quite arrives.
const along = (unit.x - laneX) * dirX + (unit.z - laneZ) * dirZ + LANE_LOOKAHEAD;
const aimX = laneX + dirX * along;
const aimZ = laneZ + dirZ * along;
const desired = Math.atan2(aimX - unit.x, aimZ - unit.z);
unit.heading = turnToward(unit.heading, desired, TURN_RATE * dt);
// Close on the car in front and lift off. Only civilians defer; anyone with
// somewhere to be leans on the horn and keeps going.
const gap = unit.role === 'traffic' || unit.role === 'convoy' ? carAhead(state, unit) : null;
const allowed =
gap === null ? unit.speed : Math.max(0, ((gap - FOLLOW_GAP) / FOLLOW_GAP) * unit.speed);
const move = Math.min(remaining, Math.min(unit.speed, allowed) * dt);
unit.x += Math.sin(unit.heading) * move;
unit.z += Math.cos(unit.heading) * move;
return false;
}
@ -307,6 +509,9 @@ function spawnTraffic(
const to = candidates[Math.floor(rng() * candidates.length)]!;
const path = routeTo(graph, roads, from.id, to.id);
if (path.length === 0) return;
// Not on top of something already sitting there. Junctions are popular, and
// two cars dropped onto the same one start the day inside each other.
if (state.units.some((u) => u.kind === 'car' && distance(u, from) < 12)) return;
makeUnit(state, {
kind: 'car',
@ -326,9 +531,21 @@ function spawnTraffic(
});
}
function spawnPedestrian(state: UnitState, near: { x: number; z: number }, rng: Rng): void {
const angle = rng() * Math.PI * 2;
const radius = 60 + rng() * 200;
function spawnPedestrian(
state: UnitState,
near: { x: number; z: number },
rng: Rng,
blocked?: (x: number, z: number) => boolean,
): void {
// Somewhere that is actually a street. Placing people by polar coordinates
// alone stood a tenth of them inside a building on the first frame.
let angle = rng() * Math.PI * 2;
let radius = 60 + rng() * 200;
for (let attempt = 0; attempt < 8; attempt++) {
if (!blocked?.(near.x + Math.cos(angle) * radius, near.z + Math.sin(angle) * radius)) break;
angle = rng() * Math.PI * 2;
radius = 60 + rng() * 200;
}
makeUnit(state, {
kind: 'soldier',
faction: 'civilian',
@ -390,7 +607,8 @@ export function dispatchTo(
assigned: segment.id,
onStation: 0,
cooldown: 1,
elevation: 1,
// Shots leave the man in the passenger seat, not the bonnet.
elevation: CREW_ELEVATION,
building: role === 'engineer' ? stage : undefined,
});
}
@ -437,7 +655,7 @@ function spawnAmbientPatrol(
// Start part-way through a sweep, so they are not all in step.
onStation: rng() * 20,
cooldown: 1,
elevation: 1,
elevation: CREW_ELEVATION,
});
return true;
}
@ -455,6 +673,7 @@ function spawnAlly(
near: { x: number; z: number },
front: Front,
rng: Rng,
blocked?: (x: number, z: number) => boolean,
): boolean {
for (let attempt = 0; attempt < 8; attempt++) {
const angle = rng() * Math.PI * 2;
@ -463,6 +682,7 @@ function spawnAlly(
const z = near.z + Math.sin(angle) * radius;
const control = controlAt(front, x, z);
if (control !== 'liberated' && control !== 'contested') continue;
if (blocked?.(x, z)) continue;
makeUnit(state, {
kind: 'soldier',
@ -485,6 +705,56 @@ function spawnAlly(
return false;
}
/**
* One of your own side's vehicles, routing across ground your side holds.
*
* Built on the traffic spawner rather than the militia one: it has somewhere to
* be and drives the network to get there, which means it inherits lane
* discipline and following distance for nothing.
*/
function spawnAllyCar(
state: UnitState,
roads: RoadNetwork,
graph: Graph,
near: { x: number; z: number },
front: Front,
rng: Rng,
): boolean {
const friendly = (n: { x: number; z: number }) => {
const control = controlAt(front, n.x, n.z);
return control === 'liberated' || control === 'contested';
};
const candidates = roads.nodes.filter((n) => {
const gap = distance(n, near);
return gap > 90 && gap < SIM_RADIUS && friendly(n);
});
if (candidates.length < 2) return false;
const from = candidates[Math.floor(rng() * candidates.length)]!;
const to = candidates[Math.floor(rng() * candidates.length)]!;
if (state.units.some((u) => u.kind === 'car' && distance(u, from) < 12)) return false;
const path = routeTo(graph, roads, from.id, to.id);
if (path.length === 0) return false;
makeUnit(state, {
kind: 'car',
faction: 'insurgent',
role: 'convoy',
x: from.x,
z: from.z,
heading: 0,
speed: SPEED.car * (0.75 + rng() * 0.3),
hp: UNIT_HP.car,
path,
expires: 500,
assigned: null,
onStation: 0,
cooldown: 1,
elevation: CREW_ELEVATION,
});
return true;
}
/** Two squads run into each other. The player is not invited. */
function spawnSkirmish(
state: UnitState,
@ -492,13 +762,18 @@ function spawnSkirmish(
near: { x: number; z: number },
front: Front,
rng: Rng,
blocked?: (x: number, z: number) => boolean,
): boolean {
const candidates = roads.nodes.filter((n) => {
const d = distance(n, near);
if (d < 150 || d > SIM_RADIUS) return false;
const control = controlAt(front, n.x, n.z);
// Fights happen where the war is, not behind either side's lines.
return control === 'contested' || control === 'occupied';
if (control !== 'contested' && control !== 'occupied') return false;
// And far enough past the line that closing on each other cannot walk them
// over it. Fighters advance up to their standoff range, so a skirmish
// spawned right on the boundary ends up being fought behind your own.
return depthAt(front, n.x, n.z) > front.boundaries.liberated + SKIRMISH_CLEARANCE;
});
if (candidates.length === 0) return false;
@ -512,12 +787,20 @@ function spawnSkirmish(
] as const) {
const count = 2 + Math.floor(rng() * 3);
for (let i = 0; i < count; i++) {
// Scattered around the meeting point, but not inside the scenery.
let fx = 0;
let fz = 0;
for (let attempt = 0; attempt < 6; attempt++) {
fx = at.x + Math.cos(angle) * separation * side + (rng() - 0.5) * 16;
fz = at.z + Math.sin(angle) * separation * side + (rng() - 0.5) * 16;
if (!blocked?.(fx, fz)) break;
}
makeUnit(state, {
kind: 'soldier',
faction,
role: 'fighter',
x: at.x + Math.cos(angle) * separation * side + (rng() - 0.5) * 16,
z: at.z + Math.sin(angle) * separation * side + (rng() - 0.5) * 16,
x: fx,
z: fz,
heading: angle + (side > 0 ? Math.PI : 0),
speed: SPEED.soldier,
hp: UNIT_HP.soldier,
@ -586,7 +869,7 @@ export function stepUnits(
spawnTraffic(state, roads, graph, player, rng);
}
if (pedestrians < PEDESTRIAN_TARGET && rng() < dt * 2) {
spawnPedestrian(state, player, rng);
spawnPedestrian(state, player, rng, step.blocked);
}
// --- Enemy ground is patrolled because it is enemy ground ---
@ -599,12 +882,16 @@ export function stepUnits(
// --- And your own ground has your own people standing in it ---
const allies = state.units.filter((u) => u.role === 'militia').length;
if (allies < AMBIENT_ALLIES[here] && rng() < dt * 3) {
spawnAlly(state, player, step.front, rng);
spawnAlly(state, player, step.front, rng, step.blocked);
}
const allyCars = state.units.filter((u) => u.role === 'convoy').length;
if (allyCars < AMBIENT_ALLY_CARS[here] && rng() < dt * 2) {
spawnAllyCar(state, roads, graph, player, step.front, rng);
}
// --- A war going on regardless of the player ---
if (step.now - state.lastSkirmishAt > SKIRMISH_SPACING && rng() < dt * 0.5) {
if (spawnSkirmish(state, roads, player, step.front, rng)) {
if (spawnSkirmish(state, roads, player, step.front, rng, step.blocked)) {
state.lastSkirmishAt = step.now;
events.skirmish = true;
}
@ -631,21 +918,9 @@ export function stepUnits(
// Hold off a little rather than piling into the car; they want to shoot
// at it, and a ram is the player's move, not theirs.
if (gap > 12) {
const move = Math.min(gap, unit.chaseSpeed * dt);
// Straight at the target if there is a way through, otherwise round the
// side of whatever is in the way. Without this they drive through the
// buildings, and a chase you cannot lose by turning a corner is not a
// chase — it is a timer.
for (const offset of HUNT_DETOURS) {
const heading = wanted + offset;
const x = unit.x + Math.sin(heading) * move;
const z = unit.z + Math.cos(heading) * move;
if (step.blocked?.(x, z)) continue;
unit.x = x;
unit.z = z;
unit.heading = heading;
break;
}
// Round the side of whatever is in the way, never through it: a chase
// you cannot lose by turning a corner is not a chase, it is a timer.
unit.heading = moveThrough(unit, wanted, Math.min(gap, unit.chaseSpeed * dt), step.blocked);
}
// Being chased keeps a unit alive past its ordinary shift.
unit.expires = Math.max(unit.expires, 30);
@ -655,20 +930,33 @@ export function stepUnits(
unit.chaseSpeed = undefined;
switch (unit.role) {
case 'traffic': {
if (advance(unit, roads, dt)) {
const to = roads.nodes[Math.floor(rng() * roads.nodes.length)]!;
unit.path = routeTo(graph, roads, nearestNode(roads, unit.x, unit.z), to.id);
case 'traffic':
case 'convoy': {
if (advance(unit, roads, state, dt)) {
// Somewhere else to be. A convoy stays on ground its own side holds;
// a taxi does not care.
const pool =
unit.role === 'convoy'
? roads.nodes.filter((n) => {
const control = controlAt(step.front, n.x, n.z);
return control === 'liberated' || control === 'contested';
})
: roads.nodes;
const to = pool[Math.floor(rng() * pool.length)];
unit.path = to
? routeTo(graph, roads, nearestNode(roads, unit.x, unit.z), to.id)
: [];
if (unit.path.length === 0) unit.expires = 0;
}
break;
}
case 'pedestrian': {
// A slow wander. People are not going anywhere in particular.
// A slow wander. People are not going anywhere in particular — but they
// do go *round* the buildings, and take the turn as their new heading
// so they walk along a wall rather than repeatedly into it.
if (rng() < dt * 0.4) unit.heading += (rng() - 0.5) * 1.5;
unit.x += Math.sin(unit.heading) * unit.speed * dt;
unit.z += Math.cos(unit.heading) * unit.speed * dt;
unit.heading = moveThrough(unit, unit.heading, unit.speed * dt, step.blocked);
break;
}
@ -680,7 +968,7 @@ export function stepUnits(
break;
}
if (unit.path.length > 0) {
advance(unit, roads, dt);
advance(unit, roads, state, dt);
break;
}
@ -762,11 +1050,10 @@ export function stepUnits(
// Close on the nearest enemy of the other army and stand your ground.
const enemy = nearestHostile(state, unit, 140);
if (enemy) {
const gap = distance(unit, enemy);
unit.heading = Math.atan2(enemy.x - unit.x, enemy.z - unit.z);
if (gap > 45) {
unit.x += Math.sin(unit.heading) * unit.speed * dt;
unit.z += Math.cos(unit.heading) * unit.speed * dt;
const wanted = Math.atan2(enemy.x - unit.x, enemy.z - unit.z);
unit.heading = wanted;
if (gapTooFar(distance(unit, enemy))) {
unit.heading = moveThrough(unit, wanted, unit.speed * dt, step.blocked);
}
break;
}
@ -776,8 +1063,7 @@ export function stepUnits(
// point of putting them there.
if (unit.role !== 'militia') break;
if (rng() < dt * 0.3) unit.heading += (rng() - 0.5) * 1.6;
unit.x += Math.sin(unit.heading) * unit.speed * 0.4 * dt;
unit.z += Math.cos(unit.heading) * unit.speed * 0.4 * dt;
unit.heading = moveThrough(unit, unit.heading, unit.speed * 0.4 * dt, step.blocked);
break;
}
@ -786,6 +1072,36 @@ export function stepUnits(
}
}
// --- Nothing occupies the same metre of road as something else ---
// Following distance handles a queue, but not a merge: two cars converging
// on a junction from different roads are more than ninety degrees apart right
// up until they both turn onto the same heading, by which point they are
// already touching. A give-way rule would fix that and can deadlock; pushing
// the overlap out always resolves, and at these speeds reads as two drivers
// both easing over.
for (let i = 0; i < state.units.length; i++) {
const a = state.units[i]!;
if (a.kind !== 'car') continue;
for (let j = i + 1; j < state.units.length; j++) {
const b = state.units[j]!;
if (b.kind !== 'car') continue;
// Only cars going roughly the same way. Two passing in opposite
// directions are already a lane apart, which on a narrow road is less
// than this radius — separating them too shoved both off their own side
// and undid the lane discipline entirely.
if (Math.cos(b.heading - a.heading) < 0) continue;
const dx = b.x - a.x;
const dz = b.z - a.z;
const gap = Math.hypot(dx, dz);
if (gap >= CAR_SEPARATION || gap < 1e-6) continue;
const push = (CAR_SEPARATION - gap) / 2;
const nx = dx / gap;
const nz = dz / gap;
moveThrough(a, Math.atan2(-nx, -nz), push, step.blocked);
moveThrough(b, Math.atan2(nx, nz), push, step.blocked);
}
}
// --- Civilians get out of the way of a firefight ---
for (const unit of state.units) {
if (unit.faction !== 'civilian') continue;
@ -794,8 +1110,7 @@ export function stepUnits(
);
if (!danger) continue;
const away = Math.atan2(unit.x - danger.x, unit.z - danger.z);
unit.x += Math.sin(away) * unit.speed * 1.4 * dt;
unit.z += Math.cos(away) * unit.speed * 1.4 * dt;
unit.heading = moveThrough(unit, away, unit.speed * 1.4 * dt, step.blocked);
}
// --- Checkpoints whose road has cooled off are abandoned ---
@ -816,10 +1131,13 @@ export function stepUnits(
// with a job to do, and driving deep into occupied ground leaves them behind
// rather than towing a friendly crowd along the front.
for (const unit of state.units) {
if (unit.role !== 'militia') continue;
if (unit.role !== 'militia' && unit.role !== 'convoy') continue;
const control = controlAt(step.front, unit.x, unit.z);
if (control !== 'liberated' && control !== 'contested') unit.expires = 0;
if (distance(unit, player) > MILITIA_RADIUS) unit.expires = 0;
// Vehicles get more rope than men on foot: they cover ground, and culling
// one at walking-pace range would delete it halfway down its own route.
const reach = unit.role === 'convoy' ? SIM_RADIUS : MILITIA_RADIUS;
if (distance(unit, player) > reach) unit.expires = 0;
}
// --- Retire the dead, the finished and the far away ---

View File

@ -1,6 +1,7 @@
import type { Base } from '../sim/bases';
import type { HeatLevel } from '../sim/heat';
import { MISSION_SHAPE, type MissionType, type Offer } from '../sim/quests';
import { money } from './currency';
/**
* What each job actually asks of you, in the words the people asking would use.
@ -42,6 +43,17 @@ function describe(offer: Offer): string {
return `${LEVEL_WORDS[worst]}${stalest === null ? '' : ` · last word ${ago(stalest)}`}`;
}
/**
* The workshop line, on every panel a base can show.
*
* Repairs happen by themselves on hand-in; this is the other thing a base is
* for, and it is the only counterplay to a ceiling that otherwise only ever
* falls. It has to be visible wherever you are standing at one, or nobody
* discovers it exists.
*/
const OVERHAUL_FOOTER =
' · <span class="key">[C]</span> overhaul — spend on what she is capable of';
export function createBoard() {
const el = document.createElement('div');
el.id = 'board';
@ -81,13 +93,13 @@ export function createBoard() {
${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
· ${money(offer.reward)}
<div class="meta">${BRIEFING[offer.type]}</div>
<div class="meta">${describe(offer)}</div>
</li>`,
)
.join('')}</ol>
<footer>press 1${offers.length} to accept</footer>`;
<footer>press 1${offers.length} to accept${OVERHAUL_FOOTER}</footer>`;
},
/**
@ -106,7 +118,7 @@ export function createBoard() {
<div class="meta">Nobody here will take it off your hands, and nobody
here will hold the board open while you decide.</div>
</li></ol>
<footer><span class="key">[X]</span> give it up we lose ground for it</footer>`;
<footer><span class="key">[X]</span> give it up we lose ground for it${OVERHAUL_FOOTER}</footer>`;
},
hide() {

12
src/ui/currency.ts Normal file
View File

@ -0,0 +1,12 @@
/**
* What the insurgency pays in.
*
* `¤` is Unicode's generic currency sign: it looks like money and is not any
* particular country's money, which is exactly right for a place whose banks
* are somebody else's problem. Kept here rather than inlined so the whole game
* agrees on it, and so it is one edit if it ever wants a name.
*/
export const CURRENCY = '¤';
/** Money as the player reads it. Whole units — nobody counts small change here. */
export const money = (amount: number): string => `${CURRENCY}${Math.round(amount)}`;

View File

@ -1,4 +1,5 @@
import { SUBSYSTEMS, type CarCondition } from '../sim/car';
import { money } from './currency';
import type { HeatLevel } from '../sim/heat';
import { MISSION_SHAPE, type MissionType } from '../sim/quests';
import type { Control } from '../sim/regions';
@ -54,7 +55,7 @@ export interface HudModel {
failed: number;
/** Cars written off underneath the player. */
kia: number;
parts: number;
funds: number;
notice: string;
/** 0..1 toward wiping the campaign, while R is held. */
resetProgress: number;
@ -141,7 +142,7 @@ export function createHud(seed: number, debug = false) {
`${part.padEnd(7)} ${bar(condition.level[part], condition.ceiling[part])} ` +
`${(condition.level[part] * 100).toFixed(0)}%`,
),
`parts ${model.parts.toFixed(2)} runs ${model.completed}` +
`funds ${money(model.funds).padEnd(8)} runs ${model.completed}` +
(model.failed > 0 ? ` lost ${model.failed}` : '') +
// Cars are counted, not lives. There is no stock of them to run out
// of; the number is there so a long campaign reads as a history.
@ -170,8 +171,8 @@ export function createHud(seed: number, debug = false) {
`[debug] seed ${seed}`,
]
: []),
`WASD drive · space handbrake · R respawn · X drop job at a base` +
` · M sound ${model.muted ? 'off' : 'on'}`,
'WASD drive · space handbrake · R respawn',
`at a base: X drop job · C overhaul` + ` · M sound ${model.muted ? 'off' : 'on'}`,
'hold R to reset the campaign',
];
if (model.resetProgress > 0.06) {