Patrols everywhere they should be, and concrete that has to be poured
Three fixes, all from watching it run. Cars stuck against buildings. routeTo dropped the node a route starts from, which is fine for a unit standing on it and wrong for anything re-routing from mid-segment: its first leg became a straight line to a node two hops away, across buildings and open country. Routes now include their start node so a unit rejoins the road before setting off. No patrols anywhere. They only existed as a reaction to the player's own tracks, so hostile territory was empty until you had personally made a road notorious. Enemy ground now carries standing patrols by how dangerous it is — none behind your lines, five on the frontier — spawned onto their roads so the territory has them the moment you arrive. This surfaced a second bug: dispatched units were culled at 546m from the player while dispatch origins reached 600m, so a patrol or engineer could be deleted on its first step and simply never turn up. Anything with an assignment is now kept regardless of range; its expiry still bounds it. Barricades appearing instantly. Crossing a heat threshold placed concrete on the road with nobody involved. Construction is now staged: an engineer is dispatched for the barricade, another for the tower, and heatProps is driven by finished builds rather than by heat levels. It still refuses to close a slab around a car parked in the gap. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
4feae2c731
commit
f4ed3cdc48
25
README.md
25
README.md
@ -190,17 +190,24 @@ job is what keeps the car alive a while longer.
|
||||
Up to Phase 5 the world was scenery. Heat poured concrete on a road and that was
|
||||
the whole of the enemy. `sim/units.ts` is the layer that makes it inhabited.
|
||||
|
||||
**Nothing appears any more — things are sent.** When a road turns notorious a
|
||||
patrol is *dispatched*: it starts at a junction a few hundred metres away, drives
|
||||
to the road, sweeps up and down it for about a minute, and while it is working
|
||||
the road it brings the heat back down. Then it leaves. The enemy is not punishing
|
||||
**Enemy ground is patrolled because it is enemy ground.** There are standing
|
||||
patrols on the roads wherever you are past the line, and more of them the deeper
|
||||
in you go — none in liberated territory, one in contested, three in occupied,
|
||||
five on the frontier. Nobody summoned these; they are what hostile territory
|
||||
looks like.
|
||||
|
||||
**On top of that, nothing appears — things are sent.** When a road turns
|
||||
notorious a patrol is *dispatched*: it starts at a junction a few hundred metres
|
||||
away, drives there, sweeps up and down for about a minute, and while it works the
|
||||
road it brings the heat back down. Then it leaves. The enemy is not punishing
|
||||
you; they are re-securing a route and going home.
|
||||
|
||||
**Checkpoints are built.** At `turret` an engineer is dispatched, and the tower
|
||||
rises over about forty seconds of someone standing on site. Arrive early and you
|
||||
find a half-built stump and no one on it. Once it stands, a gunner mans it, up on
|
||||
top so he shoots over his own barricade. Let the road cool and the whole thing is
|
||||
abandoned.
|
||||
**Everything on a road is built, including the concrete.** At `barricade` an
|
||||
engineer is dispatched and the blocks go up over about twenty seconds of someone
|
||||
standing there; at `turret` another is sent and the tower takes forty. Arrive
|
||||
mid-build and you find a half-finished stump with nobody on it. Only once the
|
||||
tower stands does a gunner man it, up on top so he shoots over his own barricade.
|
||||
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.
|
||||
|
||||
@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest';
|
||||
import * as THREE from 'three';
|
||||
import { createHeatProps } from './heatProps';
|
||||
import { createPhysics } from './physics/physics';
|
||||
import { createHeat, stepHeat } from './sim/heat';
|
||||
import { propsFor } from './sim/heat';
|
||||
import { generateWorld } from './sim/world';
|
||||
|
||||
/**
|
||||
@ -10,62 +10,86 @@ import { generateWorld } from './sim/world';
|
||||
* so the prop lifecycle can be checked end to end — including that removing a
|
||||
* barricade removes its collider, not just its mesh.
|
||||
*/
|
||||
describe('heat props lifecycle', () => {
|
||||
it('builds and tears down colliders and meshes together', async () => {
|
||||
const world = generateWorld(3, 0);
|
||||
const segment = world.roads.segments[0]!;
|
||||
/** Somewhere the player is nowhere near the road in question. */
|
||||
const AWAY = { x: 99999, z: 99999 };
|
||||
|
||||
async function setup() {
|
||||
const physics = await createPhysics(world);
|
||||
const scene = new THREE.Scene();
|
||||
const props = createHeatProps(world.roads, physics, scene);
|
||||
const heat = createHeat(world.roads);
|
||||
|
||||
const baseBodies = physics.rapier.bodies.len();
|
||||
const baseMeshes = scene.children.length;
|
||||
|
||||
const drive = (metres: number) => {
|
||||
for (let m = 0; m < metres; m++) {
|
||||
props.sync(stepHeat(heat, { dt: 1 / 60, segmentId: 0, distance: 1 }), heat, 0);
|
||||
}
|
||||
return {
|
||||
physics,
|
||||
scene,
|
||||
props,
|
||||
baseBodies: physics.rapier.bodies.len(),
|
||||
baseMeshes: scene.children.length,
|
||||
};
|
||||
const idle = (seconds: number) => {
|
||||
for (let i = 0; i < seconds * 60; i++) {
|
||||
props.sync(stepHeat(heat, { dt: 1 / 60, segmentId: null, distance: 0 }), heat, null);
|
||||
}
|
||||
};
|
||||
|
||||
drive(400);
|
||||
expect(heat.level[0]).toBe('turret');
|
||||
// Still on the road, so nothing has been built around the car yet.
|
||||
describe('barricades appear only once someone has built them', () => {
|
||||
it('places nothing until a build is reported finished', async () => {
|
||||
const { physics, scene, props, baseBodies, baseMeshes } = await setup();
|
||||
// Heat alone does nothing now. An engineer has to have done the work.
|
||||
for (let i = 0; i < 100; i++) props.sync(AWAY);
|
||||
expect(physics.rapier.bodies.len()).toBe(baseBodies);
|
||||
expect(scene.children.length).toBe(baseMeshes);
|
||||
|
||||
// Leaving it lets the deferred work land.
|
||||
idle(1);
|
||||
const hotBodies = physics.rapier.bodies.len();
|
||||
expect(hotBodies).toBeGreaterThan(baseBodies);
|
||||
expect(scene.children.length - baseMeshes).toBe(hotBodies - baseBodies);
|
||||
props.finished(segment.id);
|
||||
props.sync(AWAY);
|
||||
const expected = propsFor(segment, 'barricade').length;
|
||||
expect(expected).toBeGreaterThan(0);
|
||||
expect(physics.rapier.bodies.len() - baseBodies).toBe(expected);
|
||||
expect(scene.children.length - baseMeshes).toBe(expected);
|
||||
});
|
||||
|
||||
// Left alone, the road should give everything back.
|
||||
idle(300);
|
||||
expect(heat.level[0]).toBe('clear');
|
||||
it('takes colliders and meshes away together when abandoned', async () => {
|
||||
const { physics, scene, props, baseBodies, baseMeshes } = await setup();
|
||||
props.finished(segment.id);
|
||||
props.sync(AWAY);
|
||||
expect(props.has(segment.id)).toBe(true);
|
||||
|
||||
props.abandon(segment.id);
|
||||
expect(props.has(segment.id)).toBe(false);
|
||||
expect(physics.rapier.bodies.len()).toBe(baseBodies);
|
||||
expect(scene.children.length).toBe(baseMeshes);
|
||||
});
|
||||
|
||||
it('does not leak bodies when a road escalates through every level', async () => {
|
||||
const world = generateWorld(3, 0);
|
||||
const physics = await createPhysics(world);
|
||||
const scene = new THREE.Scene();
|
||||
const props = createHeatProps(world.roads, physics, scene);
|
||||
const heat = createHeat(world.roads);
|
||||
const baseBodies = physics.rapier.bodies.len();
|
||||
it('waits rather than closing a slab around the car standing in the gap', async () => {
|
||||
const { physics, props, baseBodies } = await setup();
|
||||
const block = propsFor(segment, 'barricade')[0]!;
|
||||
|
||||
for (let cycle = 0; cycle < 3; cycle++) {
|
||||
for (let m = 0; m < 400; m++) {
|
||||
props.sync(stepHeat(heat, { dt: 1 / 60, segmentId: 0, distance: 1 }), heat, 0);
|
||||
}
|
||||
for (let i = 0; i < 300 * 60; i++) {
|
||||
props.sync(stepHeat(heat, { dt: 1 / 60, segmentId: null, distance: 0 }), heat, null);
|
||||
}
|
||||
props.finished(segment.id);
|
||||
// Parked right where the concrete is going.
|
||||
props.sync({ x: block.x, z: block.z });
|
||||
expect(physics.rapier.bodies.len()).toBe(baseBodies);
|
||||
|
||||
// Moved off; the build lands.
|
||||
props.sync(AWAY);
|
||||
expect(physics.rapier.bodies.len()).toBeGreaterThan(baseBodies);
|
||||
});
|
||||
|
||||
it('does not leak bodies over repeated build and abandon cycles', async () => {
|
||||
const { physics, scene, props, baseBodies, baseMeshes } = await setup();
|
||||
for (let cycle = 0; cycle < 4; cycle++) {
|
||||
props.finished(segment.id);
|
||||
props.sync(AWAY);
|
||||
expect(physics.rapier.bodies.len()).toBeGreaterThan(baseBodies);
|
||||
props.abandon(segment.id);
|
||||
expect(physics.rapier.bodies.len()).toBe(baseBodies);
|
||||
expect(scene.children.length).toBe(baseMeshes);
|
||||
}
|
||||
});
|
||||
|
||||
it('is idempotent, so a repeated report does not double up', async () => {
|
||||
const { physics, props, baseBodies } = await setup();
|
||||
props.finished(segment.id);
|
||||
props.sync(AWAY);
|
||||
const after = physics.rapier.bodies.len();
|
||||
props.finished(segment.id);
|
||||
props.sync(AWAY);
|
||||
expect(physics.rapier.bodies.len()).toBe(after);
|
||||
expect(after).toBeGreaterThan(baseBodies);
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,13 +1,17 @@
|
||||
/**
|
||||
* Turns heat levels into things that physically exist on the road.
|
||||
* Puts the concrete on a road once someone has actually built it.
|
||||
*
|
||||
* This is an integration layer: it is allowed to touch both Rapier and three.js,
|
||||
* which is why it sits outside src/sim/. The sim decides *what* should be there
|
||||
* ({@link propsFor}); this only builds and tears it down.
|
||||
* This used to be driven straight off the heat level: cross a threshold and a
|
||||
* barricade existed. It does not any more. An engineer is dispatched, drives
|
||||
* there, and stands on site while it goes up; only then does this get told to
|
||||
* place anything. What arrives here is a finished build, not a state change.
|
||||
*
|
||||
* Integration layer: it is allowed to touch both Rapier and three.js, because it
|
||||
* owns objects that must exist in both or in neither.
|
||||
*/
|
||||
import * as THREE from 'three';
|
||||
import type RAPIER from '@dimforge/rapier3d-compat';
|
||||
import { propsFor, type HeatProp, type HeatState } from './sim/heat';
|
||||
import { propsFor, type HeatProp } from './sim/heat';
|
||||
import type { RoadNetwork } from './sim/roads';
|
||||
import type { PhysicsWorld } from './physics/physics';
|
||||
|
||||
@ -17,19 +21,18 @@ const MATERIALS: Record<HeatProp['kind'], THREE.MeshStandardMaterial> = {
|
||||
|
||||
const BOX = new THREE.BoxGeometry(1, 1, 1);
|
||||
|
||||
/** Never close a barricade around the car that is standing in the gap. */
|
||||
const PLAYER_CLEARANCE = 8;
|
||||
|
||||
interface Placed {
|
||||
bodies: RAPIER.RigidBody[];
|
||||
meshes: THREE.Mesh[];
|
||||
}
|
||||
|
||||
export function createHeatProps(
|
||||
roads: RoadNetwork,
|
||||
physics: PhysicsWorld,
|
||||
scene: THREE.Scene,
|
||||
) {
|
||||
export function createHeatProps(roads: RoadNetwork, physics: PhysicsWorld, scene: THREE.Scene) {
|
||||
const placed = new Map<number, Placed>();
|
||||
/** Level changes waiting for the player to get off the road in question. */
|
||||
const pending = new Set<number>();
|
||||
/** Builds finished while the player was stood in the way, waiting for room. */
|
||||
const waiting = new Set<number>();
|
||||
|
||||
const clear = (segmentId: number) => {
|
||||
const existing = placed.get(segmentId);
|
||||
@ -39,9 +42,12 @@ export function createHeatProps(
|
||||
placed.delete(segmentId);
|
||||
};
|
||||
|
||||
const build = (segmentId: number, heat: HeatState) => {
|
||||
const raise = (segmentId: number): void => {
|
||||
if (placed.has(segmentId)) return;
|
||||
const segment = roads.segments[segmentId]!;
|
||||
const props = propsFor(segment, heat.level[segmentId]!);
|
||||
// 'barricade' is the level whose props are the concrete itself; the tower
|
||||
// is a unit-owned structure and does not come from here.
|
||||
const props = propsFor(segment, 'barricade');
|
||||
if (props.length === 0) return;
|
||||
|
||||
const entry: Placed = { bodies: [], meshes: [] };
|
||||
@ -60,26 +66,39 @@ export function createHeatProps(
|
||||
placed.set(segmentId, entry);
|
||||
};
|
||||
|
||||
return {
|
||||
/**
|
||||
* Rebuild the segments whose level changed — but never the one the car is
|
||||
* currently on. A barricade appearing around the player would spawn a static
|
||||
* collider inside the chassis, and watching a checkpoint assemble itself in
|
||||
* the mirror would break the fiction anyway. Changes wait until they are
|
||||
* out of sight, which is how the player is meant to find them: by returning.
|
||||
*
|
||||
* Call every step, not only when something changed, so deferred work drains.
|
||||
*/
|
||||
sync(changedSegmentIds: readonly number[], heat: HeatState, occupiedSegmentId: number | null) {
|
||||
for (const id of changedSegmentIds) pending.add(id);
|
||||
if (pending.size === 0) return;
|
||||
const tooClose = (segmentId: number, player: { x: number; z: number }): boolean => {
|
||||
const segment = roads.segments[segmentId]!;
|
||||
return propsFor(segment, 'barricade').some(
|
||||
(prop) => Math.hypot(prop.x - player.x, prop.z - player.z) < PLAYER_CLEARANCE,
|
||||
);
|
||||
};
|
||||
|
||||
for (const id of [...pending]) {
|
||||
if (id === occupiedSegmentId) continue;
|
||||
pending.delete(id);
|
||||
clear(id);
|
||||
build(id, heat);
|
||||
return {
|
||||
/** A barricade has been finished on this road. */
|
||||
finished(segmentId: number) {
|
||||
waiting.add(segmentId);
|
||||
},
|
||||
|
||||
/** The road cooled off, or the checkpoint was abandoned. */
|
||||
abandon(segmentId: number) {
|
||||
waiting.delete(segmentId);
|
||||
clear(segmentId);
|
||||
},
|
||||
|
||||
/**
|
||||
* Call every step. Places anything finished as soon as there is room — a
|
||||
* block spawning on top of the car would put a static collider inside the
|
||||
* chassis, so a player parked in the gap makes the last slab wait.
|
||||
*/
|
||||
sync(player: { x: number; z: number }) {
|
||||
if (waiting.size === 0) return;
|
||||
for (const id of [...waiting]) {
|
||||
if (tooClose(id, player)) continue;
|
||||
waiting.delete(id);
|
||||
raise(id);
|
||||
}
|
||||
},
|
||||
|
||||
has: (segmentId: number) => placed.has(segmentId),
|
||||
};
|
||||
}
|
||||
|
||||
53
src/main.ts
53
src/main.ts
@ -217,15 +217,16 @@ async function boot() {
|
||||
{ x: saved.car.angvel[0], y: saved.car.angvel[1], z: saved.car.angvel[2] },
|
||||
true,
|
||||
);
|
||||
// Rebuild whatever the restored heat implies, so checkpoints are standing
|
||||
// where the save says they are rather than appearing as you drive past.
|
||||
stepFront(front, 0);
|
||||
refreshSegmentControl();
|
||||
heatProps.sync(
|
||||
model.roads.segments.map((s) => s.id),
|
||||
heat,
|
||||
null,
|
||||
);
|
||||
// A save does not record who was standing where, so barricades on roads
|
||||
// that were already hot are treated as already built rather than making the
|
||||
// player wait for a fresh engineer to be sent out.
|
||||
for (const segment of model.roads.segments) {
|
||||
if (heat.level[segment.id] === 'barricade' || heat.level[segment.id] === 'turret') {
|
||||
heatProps.finished(segment.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Chassis transform at the end of the last two fixed steps, for interpolation.
|
||||
@ -296,15 +297,25 @@ async function boot() {
|
||||
// the ground the car happens to be standing on.
|
||||
decayFor: (id) => DECAY_MULTIPLIER[segmentControl[id]!],
|
||||
});
|
||||
heatProps.sync(heatChanged, heat, currentSegment);
|
||||
heatProps.sync({ x: at.x, z: at.z });
|
||||
|
||||
// Escalation is now something the enemy has to *do*. A road that turns
|
||||
// notorious gets a patrol sent to it; one that turns worse gets an
|
||||
// engineer sent to build a checkpoint. Both have to drive here first.
|
||||
// Escalation is entirely something the enemy has to *do*. Every level
|
||||
// sends somebody: a patrol to work the road, an engineer to pour the
|
||||
// concrete, another to put the tower up. Nothing appears on its own.
|
||||
for (const id of heatChanged) {
|
||||
const segment = model.roads.segments[id]!;
|
||||
const level = heat.level[id]!;
|
||||
if (level === 'patrol') dispatchTo(units, model.roads, graph, model.roads.segments[id]!, 'patrol', chatterRng);
|
||||
if (level === 'turret') dispatchTo(units, model.roads, graph, model.roads.segments[id]!, 'engineer', chatterRng);
|
||||
if (level === 'patrol') {
|
||||
dispatchTo(units, model.roads, graph, segment, 'patrol', chatterRng);
|
||||
}
|
||||
if (level === 'barricade') {
|
||||
dispatchTo(units, model.roads, graph, segment, 'engineer', chatterRng, 'barricade');
|
||||
}
|
||||
if (level === 'turret') {
|
||||
dispatchTo(units, model.roads, graph, segment, 'engineer', chatterRng, 'tower');
|
||||
}
|
||||
// Cooled back down: whatever is standing there stops being maintained.
|
||||
if (level === 'clear' || level === 'patrol') heatProps.abandon(id);
|
||||
}
|
||||
// Driving a road is how you learn what is on it, and seeing ground is how
|
||||
// it stops being a blank on the map.
|
||||
@ -442,17 +453,23 @@ async function boot() {
|
||||
chatterRng,
|
||||
);
|
||||
|
||||
// Finished towers become solid; abandoned ones stop being solid.
|
||||
for (const id of unitEvents.built) {
|
||||
const site = units.checkpoints.get(id);
|
||||
if (!site || towerBodies.has(id)) continue;
|
||||
// Whatever an engineer just finished now exists in the world.
|
||||
for (const { segment, stage } of unitEvents.built) {
|
||||
if (stage === 'barricade') {
|
||||
heatProps.finished(segment);
|
||||
say('They have put concrete across that road.', 6);
|
||||
continue;
|
||||
}
|
||||
const site = units.checkpoints.get(segment);
|
||||
if (!site || towerBodies.has(segment)) continue;
|
||||
towerBodies.set(
|
||||
id,
|
||||
segment,
|
||||
physics.addStaticBox({ x: site.x, z: site.z, yaw: 0, width: 3, height: 6, depth: 3 }),
|
||||
);
|
||||
say('They have finished the tower on that road.', 6);
|
||||
}
|
||||
for (const id of unitEvents.removed) {
|
||||
heatProps.abandon(id);
|
||||
const body = towerBodies.get(id);
|
||||
if (!body) continue;
|
||||
physics.removeBody(body);
|
||||
|
||||
@ -122,7 +122,10 @@ export function createUnitView(scene: THREE.Scene) {
|
||||
let towerCount = 0;
|
||||
for (const site of units.checkpoints.values()) {
|
||||
if (towerCount >= towers.count) break;
|
||||
const built = site.built;
|
||||
// A tower only stands once its own stage is done. While the barricade
|
||||
// is going up there is nothing here but activity.
|
||||
const built = site.done.includes('tower');
|
||||
if (!built && site.stage !== 'tower') continue;
|
||||
const height = built ? 1 : Math.max(0.15, site.progress);
|
||||
position.set(site.x, (6 * height) / 2, site.z);
|
||||
scratch.compose(position, quaternion.identity(), scale.set(1, height, 1));
|
||||
|
||||
@ -6,8 +6,10 @@ import { createFront, controlAt } from './regions';
|
||||
import { createHeat } from './heat';
|
||||
import { freshCondition } from './car';
|
||||
import {
|
||||
AMBIENT_PATROLS,
|
||||
createUnits,
|
||||
dispatchTo,
|
||||
hasBuilt,
|
||||
stepUnits,
|
||||
PATROL_HEAT_DECAY,
|
||||
patrolProgress,
|
||||
@ -25,7 +27,11 @@ function run(
|
||||
overrides: Partial<Parameters<typeof stepUnits>[1]> = {},
|
||||
rng = makeRng(3),
|
||||
) {
|
||||
const events = { built: [] as number[], removed: [] as number[], skirmish: false };
|
||||
const events = {
|
||||
built: [] as Array<{ segment: number; stage: string }>,
|
||||
removed: [] as number[],
|
||||
skirmish: false,
|
||||
};
|
||||
for (let i = 0; i < seconds * 10; i++) {
|
||||
const step = stepUnits(
|
||||
state,
|
||||
@ -137,25 +143,47 @@ describe('patrols are dispatched, not conjured', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkpoints have to be built', () => {
|
||||
describe('everything on a road has to be built', () => {
|
||||
const segment = world.roads.segments[6]!;
|
||||
const atSite = { player: { x: segment.ax, z: segment.az } };
|
||||
|
||||
it('does not exist until an engineer has stood there long enough', () => {
|
||||
it('pours no concrete until an engineer has stood there long enough', () => {
|
||||
const state = createUnits();
|
||||
dispatchTo(state, world.roads, graph, segment, 'engineer', makeRng(2));
|
||||
// Nothing is standing yet.
|
||||
dispatchTo(state, world.roads, graph, segment, 'engineer', makeRng(2), 'barricade');
|
||||
// Dispatched, but nothing is standing on the road yet.
|
||||
expect(state.checkpoints.size).toBe(0);
|
||||
|
||||
const events = run(state, 200, { player: { x: segment.ax, z: segment.az } });
|
||||
expect(events.built).toContain(segment.id);
|
||||
const site = state.checkpoints.get(segment.id)!;
|
||||
expect(site.built).toBe(true);
|
||||
const events = run(state, 200, atSite);
|
||||
expect(events.built).toContainEqual({ segment: segment.id, stage: 'barricade' });
|
||||
expect(hasBuilt(state.checkpoints.get(segment.id), 'barricade')).toBe(true);
|
||||
});
|
||||
|
||||
it('is manned once it stands', () => {
|
||||
it('does not raise a tower for a barricade job', () => {
|
||||
const state = createUnits();
|
||||
dispatchTo(state, world.roads, graph, segment, 'engineer', makeRng(2));
|
||||
run(state, 200, { player: { x: segment.ax, z: segment.az } });
|
||||
dispatchTo(state, world.roads, graph, segment, 'engineer', makeRng(2), 'barricade');
|
||||
run(state, 200, atSite);
|
||||
expect(hasBuilt(state.checkpoints.get(segment.id), 'tower')).toBe(false);
|
||||
// And nobody is manning a checkpoint that is only concrete.
|
||||
expect(state.units.some((u) => u.role === 'garrison')).toBe(false);
|
||||
});
|
||||
|
||||
it('takes a second engineer, and longer, to put the tower up', () => {
|
||||
const state = createUnits();
|
||||
dispatchTo(state, world.roads, graph, segment, 'engineer', makeRng(2), 'barricade');
|
||||
run(state, 200, atSite);
|
||||
state.dispatched.delete(segment.id);
|
||||
|
||||
dispatchTo(state, world.roads, graph, segment, 'engineer', makeRng(4), 'tower');
|
||||
const events = run(state, 260, atSite);
|
||||
expect(events.built).toContainEqual({ segment: segment.id, stage: 'tower' });
|
||||
const site = state.checkpoints.get(segment.id)!;
|
||||
expect(site.done).toEqual(['barricade', 'tower']);
|
||||
});
|
||||
|
||||
it('mans the tower once it stands', () => {
|
||||
const state = createUnits();
|
||||
dispatchTo(state, world.roads, graph, segment, 'engineer', makeRng(2), 'tower');
|
||||
run(state, 260, atSite);
|
||||
const gunner = state.units.find((u) => u.role === 'garrison');
|
||||
expect(gunner).toBeDefined();
|
||||
// Up on the tower, so it shoots over its own barricade.
|
||||
@ -164,20 +192,53 @@ describe('checkpoints have to be built', () => {
|
||||
|
||||
it('is abandoned when the road stops being worth guarding', () => {
|
||||
const state = createUnits();
|
||||
dispatchTo(state, world.roads, graph, segment, 'engineer', makeRng(2));
|
||||
run(state, 200, { player: { x: segment.ax, z: segment.az } });
|
||||
dispatchTo(state, world.roads, graph, segment, 'engineer', makeRng(2), 'tower');
|
||||
run(state, 260, atSite);
|
||||
expect(state.checkpoints.size).toBe(1);
|
||||
|
||||
const events = run(state, 5, {
|
||||
player: { x: segment.ax, z: segment.az },
|
||||
heatLevel: () => 'clear',
|
||||
});
|
||||
const events = run(state, 5, { ...atSite, heatLevel: () => 'clear' });
|
||||
expect(events.removed).toContain(segment.id);
|
||||
expect(state.checkpoints.size).toBe(0);
|
||||
expect(state.units.some((u) => u.role === 'garrison')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('enemy ground is patrolled because it is enemy ground', () => {
|
||||
/** Somewhere well past the line, so the player is in hostile territory. */
|
||||
const deep = (() => {
|
||||
const depth = front.boundaries.occupied + 60;
|
||||
return { x: front.axis.x * depth, z: front.axis.z * depth };
|
||||
})();
|
||||
|
||||
it('keeps no patrols behind your own lines', () => {
|
||||
const state = createUnits();
|
||||
run(state, 300, { player: { x: world.spawn.x, z: world.spawn.z } });
|
||||
expect(state.units.some((u) => u.role === 'patrol')).toBe(false);
|
||||
});
|
||||
|
||||
it('has patrols already working the roads out past the front', () => {
|
||||
const state = createUnits();
|
||||
run(state, 200, { player: deep });
|
||||
const patrols = state.units.filter((u) => u.role === 'patrol');
|
||||
// Nobody triggered these: the player never drove anything here.
|
||||
expect(patrols.length).toBeGreaterThan(0);
|
||||
for (const patrol of patrols) expect(patrol.assigned).not.toBeNull();
|
||||
});
|
||||
|
||||
it('puts more of them on more dangerous ground', () => {
|
||||
expect(AMBIENT_PATROLS.liberated).toBe(0);
|
||||
expect(AMBIENT_PATROLS.contested).toBeLessThan(AMBIENT_PATROLS.occupied);
|
||||
expect(AMBIENT_PATROLS.occupied).toBeLessThan(AMBIENT_PATROLS.frontier);
|
||||
});
|
||||
|
||||
it('never stacks two patrols onto the same road', () => {
|
||||
const state = createUnits();
|
||||
run(state, 400, { player: deep });
|
||||
const roads = state.units.filter((u) => u.role === 'patrol').map((u) => u.assigned);
|
||||
expect(new Set(roads).size).toBe(roads.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a war going on regardless', () => {
|
||||
it('breaks out fights between the two armies', () => {
|
||||
const state = createUnits();
|
||||
|
||||
152
src/sim/units.ts
152
src/sim/units.ts
@ -55,17 +55,34 @@ export interface Unit {
|
||||
cooldown: number;
|
||||
/** Height of the muzzle, so a gunner on a tower shoots over the barricade. */
|
||||
elevation: number;
|
||||
/** What an engineer was sent to put up. */
|
||||
building?: BuildStage;
|
||||
}
|
||||
|
||||
/**
|
||||
* What can be put up on a road, in the order it goes up.
|
||||
*
|
||||
* Both stages are *work*. Concrete does not pour itself the instant a road
|
||||
* becomes notorious any more than a tower does — someone has to be sent, arrive,
|
||||
* and stand there while it goes up.
|
||||
*/
|
||||
export type BuildStage = 'barricade' | 'tower';
|
||||
|
||||
export interface Checkpoint {
|
||||
segment: number;
|
||||
/** 0..1. A tower does not exist until someone builds it. */
|
||||
/** What is going up right now. */
|
||||
stage: BuildStage;
|
||||
/** 0..1 toward the current stage. */
|
||||
progress: number;
|
||||
built: boolean;
|
||||
/** Stages finished and standing. */
|
||||
done: BuildStage[];
|
||||
x: number;
|
||||
z: number;
|
||||
}
|
||||
|
||||
export const hasBuilt = (site: Checkpoint | undefined, stage: BuildStage): boolean =>
|
||||
site?.done.includes(stage) ?? false;
|
||||
|
||||
export interface UnitState {
|
||||
units: Unit[];
|
||||
checkpoints: Map<number, Checkpoint>;
|
||||
@ -98,11 +115,30 @@ const SPEED: Record<UnitKind, number> = { car: 14, soldier: 2.4 };
|
||||
const PATROL_DURATION = 90;
|
||||
/** Heat removed per second by a patrol actually driving its assigned road. */
|
||||
export const PATROL_HEAT_DECAY = 0.012;
|
||||
/** Seconds of an engineer standing on site to finish a checkpoint. */
|
||||
const BUILD_SECONDS = 40;
|
||||
/**
|
||||
* Seconds of an engineer standing on site per stage. Concrete is quicker than a
|
||||
* tower, but neither is instant — arriving mid-build is meant to be a thing that
|
||||
* happens to you.
|
||||
*/
|
||||
const BUILD_SECONDS: Record<BuildStage, number> = { barricade: 22, tower: 40 };
|
||||
/** Minimum gap between skirmishes breaking out. */
|
||||
const SKIRMISH_SPACING = 55;
|
||||
|
||||
/**
|
||||
* Standing patrols the enemy keeps on the roads near the player, by whose
|
||||
* ground it is.
|
||||
*
|
||||
* These are not a reaction to anything the player did. Occupied territory is
|
||||
* patrolled because it is occupied, and the deeper in you go the more of it
|
||||
* there is. Heat-triggered patrols arrive on top of this.
|
||||
*/
|
||||
export const AMBIENT_PATROLS: Record<Control, number> = {
|
||||
liberated: 0,
|
||||
contested: 1,
|
||||
occupied: 3,
|
||||
frontier: 5,
|
||||
};
|
||||
|
||||
export const UNIT_HP: Record<UnitKind, number> = { car: 60, soldier: 30 };
|
||||
|
||||
// --- Helpers --------------------------------------------------------------
|
||||
@ -154,8 +190,18 @@ function advance(unit: Unit, roads: RoadNetwork, dt: number): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Node list for a journey, *including* the node it starts from.
|
||||
*
|
||||
* Dropping the start node looked harmless and was not. A unit re-routing from
|
||||
* mid-segment is not standing on the node its route begins at, so its first leg
|
||||
* became a straight line to a node two hops away — across buildings, across
|
||||
* open country, wherever that line happened to go. Keeping the start node makes
|
||||
* a unit rejoin the road before setting off. Anything already standing on it
|
||||
* shifts past within a step.
|
||||
*/
|
||||
function routeTo(graph: Graph, roads: RoadNetwork, from: number, to: number): number[] {
|
||||
return findRoute(graph, from, to, travelTime(roads))?.nodes.slice(1) ?? [];
|
||||
return findRoute(graph, from, to, travelTime(roads))?.nodes ?? [];
|
||||
}
|
||||
|
||||
// --- Spawning -------------------------------------------------------------
|
||||
@ -234,6 +280,7 @@ export function dispatchTo(
|
||||
segment: RoadSegment,
|
||||
role: 'patrol' | 'engineer',
|
||||
rng: Rng,
|
||||
stage: BuildStage = 'barricade',
|
||||
): Unit | null {
|
||||
if (state.dispatched.has(segment.id)) return null;
|
||||
|
||||
@ -241,7 +288,8 @@ export function dispatchTo(
|
||||
// the patrol arrives from somewhere plausible rather than materialising.
|
||||
const origins = roads.nodes.filter((n) => {
|
||||
const d = Math.hypot(n.x - segment.ax, n.z - segment.az);
|
||||
return d > 150 && d < 600;
|
||||
// Far enough to be somewhere else, close enough to arrive while it matters.
|
||||
return d > 150 && d < 430;
|
||||
});
|
||||
if (origins.length === 0) return null;
|
||||
const origin = origins[Math.floor(rng() * origins.length)]!;
|
||||
@ -265,9 +313,57 @@ export function dispatchTo(
|
||||
onStation: 0,
|
||||
cooldown: 1,
|
||||
elevation: 1,
|
||||
building: role === 'engineer' ? stage : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A patrol that is simply *there*, already working a road in enemy territory,
|
||||
* rather than one summoned by the player's own tracks.
|
||||
*
|
||||
* Spawned onto its road rather than driven in from a depot: hostile ground
|
||||
* should have patrols on it the moment you arrive, not thirty seconds later.
|
||||
*/
|
||||
function spawnAmbientPatrol(
|
||||
state: UnitState,
|
||||
roads: RoadNetwork,
|
||||
near: { x: number; z: number },
|
||||
front: Front,
|
||||
rng: Rng,
|
||||
): boolean {
|
||||
const candidates = roads.segments.filter((segment) => {
|
||||
if (state.dispatched.has(segment.id)) return false;
|
||||
const mid = { x: (segment.ax + segment.bx) / 2, z: (segment.az + segment.bz) / 2 };
|
||||
const gap = distance(mid, near);
|
||||
// Not on top of the player, not beyond what is simulated.
|
||||
if (gap < 90 || gap > SIM_RADIUS) return false;
|
||||
return controlAt(front, mid.x, mid.z) !== 'liberated';
|
||||
});
|
||||
if (candidates.length === 0) return false;
|
||||
|
||||
const segment = candidates[Math.floor(rng() * candidates.length)]!;
|
||||
const start = pointOnSegment(segment, rng(), 0);
|
||||
state.dispatched.add(segment.id);
|
||||
makeUnit(state, {
|
||||
kind: 'car',
|
||||
faction: 'enemy',
|
||||
role: 'patrol',
|
||||
x: start.x,
|
||||
z: start.z,
|
||||
heading: start.yaw,
|
||||
speed: SPEED.car,
|
||||
hp: UNIT_HP.car,
|
||||
path: [],
|
||||
expires: 260 + rng() * 200,
|
||||
assigned: segment.id,
|
||||
// Start part-way through a sweep, so they are not all in step.
|
||||
onStation: rng() * 20,
|
||||
cooldown: 1,
|
||||
elevation: 1,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Two squads run into each other. The player is not invited. */
|
||||
function spawnSkirmish(
|
||||
state: UnitState,
|
||||
@ -330,9 +426,9 @@ export interface UnitStep {
|
||||
}
|
||||
|
||||
export interface UnitEvents {
|
||||
/** Checkpoints finished this step, so the renderer can build them. */
|
||||
built: number[];
|
||||
/** Checkpoints abandoned, so the renderer can take them away. */
|
||||
/** Stages finished this step, so the world can put them up. */
|
||||
built: Array<{ segment: number; stage: BuildStage }>;
|
||||
/** Checkpoints abandoned, so the world can take them away. */
|
||||
removed: number[];
|
||||
skirmish: boolean;
|
||||
}
|
||||
@ -357,6 +453,12 @@ export function stepUnits(
|
||||
spawnPedestrian(state, player, rng);
|
||||
}
|
||||
|
||||
// --- Enemy ground is patrolled because it is enemy ground ---
|
||||
const patrols = state.units.filter((u) => u.role === 'patrol').length;
|
||||
if (patrols < AMBIENT_PATROLS[controlAt(step.front, player.x, player.z)] && rng() < dt * 1.5) {
|
||||
spawnAmbientPatrol(state, roads, 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)) {
|
||||
@ -419,20 +521,32 @@ export function stepUnits(
|
||||
step.decayHeat(segment.id, PATROL_HEAT_DECAY * dt);
|
||||
if (unit.onStation > PATROL_DURATION) unit.expires = 0;
|
||||
} else {
|
||||
const stage: BuildStage = unit.building ?? 'barricade';
|
||||
const site = state.checkpoints.get(segment.id) ?? {
|
||||
segment: segment.id,
|
||||
stage,
|
||||
progress: 0,
|
||||
built: false,
|
||||
done: [],
|
||||
x: point.x,
|
||||
z: point.z,
|
||||
};
|
||||
state.checkpoints.set(segment.id, site);
|
||||
if (!site.built) {
|
||||
site.progress = Math.min(1, site.progress + dt / BUILD_SECONDS);
|
||||
|
||||
if (site.done.includes(stage)) {
|
||||
// Already standing; nothing left for this one to do.
|
||||
unit.expires = 0;
|
||||
break;
|
||||
}
|
||||
|
||||
site.stage = stage;
|
||||
site.progress = Math.min(1, site.progress + dt / BUILD_SECONDS[stage]);
|
||||
if (site.progress >= 1) {
|
||||
site.built = true;
|
||||
events.built.push(segment.id);
|
||||
// Someone has to man it once it stands.
|
||||
site.done.push(stage);
|
||||
site.progress = 0;
|
||||
events.built.push({ segment: segment.id, stage });
|
||||
|
||||
// A tower is no use unless someone is standing on it.
|
||||
if (stage === 'tower') {
|
||||
const post = pointOnSegment(segment, 0.5, segment.width / 2 + 2.5);
|
||||
makeUnit(state, {
|
||||
kind: 'soldier',
|
||||
@ -451,8 +565,8 @@ export function stepUnits(
|
||||
// Up on the tower, shooting over its own barricade.
|
||||
elevation: 5,
|
||||
});
|
||||
unit.expires = 0;
|
||||
}
|
||||
unit.expires = 0;
|
||||
}
|
||||
}
|
||||
break;
|
||||
@ -503,7 +617,11 @@ export function stepUnits(
|
||||
// --- Retire the dead, the finished and the far away ---
|
||||
const survivors: Unit[] = [];
|
||||
for (const unit of state.units) {
|
||||
const tooFar = distance(unit, player) > SIM_RADIUS * 1.3;
|
||||
// Anything with a job keeps it regardless of distance. Culling by range
|
||||
// caught units that were dispatched from further out than the cull radius
|
||||
// and deleted them on their first step — so the patrol or the engineer the
|
||||
// player had earned simply never turned up. Their `expires` bounds them.
|
||||
const tooFar = unit.assigned === null && distance(unit, player) > SIM_RADIUS * 1.3;
|
||||
if (unit.hp <= 0 || unit.expires <= 0 || tooFar) {
|
||||
if (unit.assigned !== null && (unit.role === 'patrol' || unit.role === 'engineer')) {
|
||||
state.dispatched.delete(unit.assigned);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user