From 18477f0e3833454f0fb9f6cd2430552cc5f860dd Mon Sep 17 00:00:00 2001 From: dejvino Date: Sun, 9 Aug 2026 17:43:22 +0200 Subject: [PATCH] Traffic that rejoins its lane instead of orbiting it Cars were driving tight circles at certain junctions, one after another: twenty laps in thirty seconds to net eight metres. The lane aim point was a fixed distance *along the lane* from where the car projects onto it, and that breaks down at both ends. Close in, the point falls inside the tightest circle the car can drive - about six metres at TURN_RATE - so it cannot be reached, the car swings past and comes round again, forever. The obvious fix of growing the lookahead with the error makes the other end worse: the point runs away down the lane until the direction to it is almost parallel with the lane itself, the steering correction vanishes, and the car drifts further out every step. Instrumented one drifting from 28 to 42 metres wide of its own road while dutifully pointing along it. Measuring the lookahead from the *car* instead fixes both at once. On the line it sits ahead and the car tracks straight; off it by less than the lookahead the point slides back along the lane and the car cuts in; off by more there is no such point at all, so it aims at the nearest bit of lane there is and drives at it. Worst car over thirty seconds: 21 turns before, 2.7 after - and 2.7 across 419 metres of a grid map is just corners. Worst distance off lane: 42 metres and growing, now 13.6 and closing. Co-Authored-By: Claude Opus 5 --- src/sim/units.test.ts | 91 +++++++++++++++++++++++++++++++++++++++++++ src/sim/units.ts | 32 +++++++++++++-- 2 files changed, 119 insertions(+), 4 deletions(-) diff --git a/src/sim/units.test.ts b/src/sim/units.test.ts index f005a89..7139a68 100644 --- a/src/sim/units.test.ts +++ b/src/sim/units.test.ts @@ -1146,3 +1146,94 @@ describe('traffic and the player', () => { expect(closed).toBeLessThan(kept); }); }); + +describe('a car knocked off its lane', () => { + /** + * The failure this pins is not subtle to watch and is very easy to reintroduce: + * traffic driving tight circles forever, one car after another, because the + * aim point it is steering at cannot be reached. + */ + const straight = world.roads.segments.reduce((a, b) => (a.length > b.length ? a : b)); + const along = Math.atan2(straight.bx - straight.ax, straight.bz - straight.az); + + const displaced = (metresOff: number) => { + const state = createUnits(); + // Right of the direction of travel, matching sim/units.ts. + const offX = -Math.cos(along); + const offZ = Math.sin(along); + const car = { + id: state.nextId++, + kind: 'car' as const, + faction: 'civilian' as const, + role: 'traffic' as const, + x: straight.ax + (straight.bx - straight.ax) * 0.15 + offX * metresOff, + z: straight.az + (straight.bz - straight.az) * 0.15 + offZ * metresOff, + heading: along, + speed: 12, + hp: 60, + path: [straight.b], + lastNode: straight.a, + expires: 1e6, + assigned: null, + onStation: 0, + cooldown: 1, + elevation: 1, + }; + state.units.push(car); + + let turned = 0; + let previousHeading = car.heading; + // Closest it ever gets to its lane while still driving that leg. Measuring + // at the end is useless: it reaches the junction, picks a new destination, + // and the original lane stops meaning anything. + let closestToLane = Infinity; + for (let i = 0; i < 300; i++) { + stepUnits( + state, + { + dt: 0.1, + now: i * 0.1, + // On the road, not far away: units well outside SIM_RADIUS of the + // player are culled, and a culled car proves nothing. + player: { x: (straight.ax + straight.bx) / 2, z: (straight.az + straight.bz) / 2 }, + front, + heatLevel: () => 'clear', + decayHeat: () => {}, + decayArea: () => {}, + hunt: null, + }, + world.roads, + graph, + makeRng(6), + ); + let delta = car.heading - previousHeading; + if (delta > Math.PI) delta -= Math.PI * 2; + if (delta < -Math.PI) delta += Math.PI * 2; + turned += Math.abs(delta); + previousHeading = car.heading; + if (car.path[0] === straight.b) { + closestToLane = Math.min( + closestToLane, + Math.abs((car.x - straight.ax) * -Math.cos(along) + (car.z - straight.az) * Math.sin(along)), + ); + } + } + + return { turns: turned / (Math.PI * 2), lateral: closestToLane, car }; + }; + + it('rejoins the road instead of orbiting it', () => { + // Thirty metres wide of its own road: far enough that the aim point used to + // be unreachable, so the car circled indefinitely and drifted further out + // every lap rather than coming back. + const run = displaced(30); + expect(run.turns).toBeLessThan(1); + // Actually got back to the road, rather than running parallel to it. + expect(run.lateral).toBeLessThan(6); + }); + + it('holds its lane when it is already on it', () => { + const run = displaced(0); + expect(run.turns).toBeLessThan(0.2); + }); +}); diff --git a/src/sim/units.ts b/src/sim/units.ts index 510a91b..190c061 100644 --- a/src/sim/units.ts +++ b/src/sim/units.ts @@ -512,10 +512,34 @@ function advance( return unit.path.length === 0; } - // 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; + /* + * Pure pursuit: aim at the point on the lane line that is `LANE_LOOKAHEAD` + * metres *from the car*. + * + * The distance being measured from the car rather than along the lane is the + * entire trick, and getting it wrong is what had traffic doing twenty laps of + * a junction to net eight metres, one car after another. + * + * Aim a fixed distance *along the lane* from where the car projects onto it + * and the geometry breaks down twice over. Close in, the aim point falls + * inside the tightest circle the car can drive — about six metres at + * `TURN_RATE` — so it cannot reach it, swings past and comes round again. + * Far out, the fix of growing the lookahead makes it worse: the aim point + * runs away down the lane until the direction to it is almost parallel with + * the lane itself, the steering correction vanishes, and the car drifts + * further off every step. Measured drifting from 28 to 42 metres wide of its + * own road while dutifully pointing along it. + * + * Holding the aim at a fixed radius from the car fixes both ends at once. On + * the line, it sits `LANE_LOOKAHEAD` ahead and the car tracks straight. Off + * the line by less than that, the point slides back along the lane and the + * car cuts in at a sane angle. Off by more than that, there is no such point + * at all, so it aims at the nearest bit of lane there is and drives at it. + */ + const lateral = (unit.x - laneX) * rightX + (unit.z - laneZ) * rightZ; + const projected = (unit.x - laneX) * dirX + (unit.z - laneZ) * dirZ; + const reach = Math.sqrt(Math.max(0, LANE_LOOKAHEAD * LANE_LOOKAHEAD - lateral * lateral)); + const along = projected + reach; const aimX = laneX + dirX * along; const aimZ = laneZ + dirZ * along;