diff --git a/src/sim/units.test.ts b/src/sim/units.test.ts index 7139a68..df49851 100644 --- a/src/sim/units.test.ts +++ b/src/sim/units.test.ts @@ -1222,18 +1222,73 @@ describe('a car knocked off its lane', () => { return { turns: turned / (Math.PI * 2), lateral: closestToLane, car }; }; + it('gets everybody somewhere, over a long run', () => { + /* + * The honest measure of the circling bug, and the one that took several + * attempts to arrive at. Counting how much cars *turn* is no good: a grid + * with a junction every hundred metres legitimately has them turning + * constantly. Nor is displacement from start to finish, since a car can + * loop the network and come back past where it began. + * + * How far it ever got from where it started is the one that separates + * "driving around town" from "driving around a lamppost". + */ + const state = createUnits(); + const start = new Map(); + const furthest = new Map(); + const seen = new Map(); + for (let i = 0; i < 900; 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(19), + ); + for (const u of state.units) { + if (u.role !== 'traffic') continue; + const from = start.get(u.id) ?? { x: u.x, z: u.z }; + start.set(u.id, from); + furthest.set( + u.id, + Math.max(furthest.get(u.id) ?? 0, Math.hypot(u.x - from.x, u.z - from.z)), + ); + seen.set(u.id, (seen.get(u.id) ?? 0) + 1); + } + } + // Only cars that were around for most of it: one that spawned in the last + // few seconds has had no chance to go anywhere and proves nothing. + const settled = [...furthest.entries()] + .filter(([id]) => (seen.get(id) ?? 0) > 600) + .map(([, d]) => d); + expect(settled.length).toBeGreaterThan(15); + // Nobody spends a minute and a half orbiting the spot they started on. + expect(Math.min(...settled)).toBeGreaterThan(40); + }); + 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. + // Judged on whether it reached its lane, not on how much it turned: over + // thirty seconds it drives well past this junction and on through others, + // and turning at those is what driving is. 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); + // Starts on the line and never wanders more than a lane's width off it. + expect(run.lateral).toBeLessThan(2); }); }); diff --git a/src/sim/units.ts b/src/sim/units.ts index 190c061..7e82065 100644 --- a/src/sim/units.ts +++ b/src/sim/units.ts @@ -67,6 +67,10 @@ export interface Unit { chaseSpeed?: number; /** Junction most recently left, so the leg being driven is known. */ lastNode?: number; + /** Closest this unit has got to its next junction, for spotting a stall. */ + closest?: number; + /** Seconds since it last got any closer to it. */ + stuckFor?: number; } /** @@ -357,8 +361,37 @@ function segmentBetween(roads: RoadNetwork, a: number, b: number): RoadSegment | * road rather than a corridor. */ const LANE_SHARE = 0.45; -/** How far ahead a driver looks along their lane when deciding where to point. */ +/** + * How far ahead a driver looks along their lane, at minimum and per m/s. + * + * The speed term is the one that matters and it is not a nicety — it is the + * stability condition for this kind of steering, and getting it wrong is what + * had traffic driving in circles through four separate attempts at a fix. + * + * A vehicle chasing a point ahead of it oscillates unless that point sits + * outside its own turning circle, and the margin has to be about double. The + * tightest circle anything here can drive is speed / TURN_RATE: at 14 m/s and + * 2.2 rad/s, 6.4 metres. So the lookahead has to clear roughly 13 metres, and + * it was a flat 11 — below the threshold at every speed traffic actually + * drives at, which is why it was never a junction bug or a crowding bug. It + * was arithmetic, and it circled wherever it happened to be. + * + * 1.4 m per m/s gives about 20 metres at cruise: comfortably outside the + * circle, with room for the car to be knocked about and still recover. + */ const LANE_LOOKAHEAD = 11; +const LANE_LOOKAHEAD_PER_SPEED = 1.4; +/** + * How far ahead they look per metre off the lane, and the ceiling on it. + * + * Above 1 so the aim point never sits square to the lane, which is what makes + * the steering oscillate and then circle. Capped so it can never run so far + * ahead that the correction goes to nothing and the car drifts away instead. + */ +const LANE_RECOVERY = 1.7; +const LANE_LOOKAHEAD_CAP = 2.5; +/** How long a car may fail to get any closer to its destination before it is reset. */ +const STUCK_SECONDS = 12; /** 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. */ @@ -367,8 +400,23 @@ const FOLLOW_GAP = 9; 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; +/** + * Closest two vehicles ever get, centre to centre. A car is 1.8m by 4m. + * + * Only genuine overlap, not polite spacing — the following distance already + * keeps a queue nine metres apart, and this exists for the case that rule + * cannot see: two cars crossing at a junction, on different legs, heading + * ninety degrees apart. + * + * It used to be 4.5 and it shoved cars several metres sideways off their own + * lane. Pure pursuit then curved them back, and a steady sideways shove against + * a steady curve back is a circle — which is exactly what was happening at + * intersections. Tight enough now that it separates cars that are actually + * inside one another and otherwise leaves the steering alone. + */ +const CAR_SEPARATION = 3; +/** Share of the overlap resolved per step, so it eases apart rather than jumps. */ +const SEPARATION_EASE = 0.35; /** Muzzle height for a man riding in a car, so rounds leave him and not the bonnet. */ export const CREW_ELEVATION = 1.5; @@ -395,9 +443,14 @@ function turnToward(from: number, to: number, limit: number): number { * 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, player: { x: number; z: number }): number | null { - const forwardX = Math.sin(unit.heading); - const forwardZ = Math.cos(unit.heading); +function carAhead( + state: UnitState, + unit: Unit, + player: { x: number; z: number }, + /** Direction of the lane being driven — *not* the nose. See below. */ + forwardX: number, + forwardZ: number, +): number | null { let nearest: number | null = null; /** Is this thing in my way, and how far off is it? */ @@ -412,7 +465,7 @@ function carAhead(state: UnitState, unit: Unit, player: { x: number; z: number } 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; + if (Math.sin(other.heading) * forwardX + Math.cos(other.heading) * forwardZ < 0) continue; const ahead = inTheWay(other.x, other.z); if (ahead !== null && (nearest === null || ahead < nearest)) nearest = ahead; } @@ -509,6 +562,8 @@ function advance( if (remaining < 3 + offset || beyond > -0.5) { unit.lastNode = next; unit.path.shift(); + unit.closest = undefined; + unit.stuckFor = 0; return unit.path.length === 0; } @@ -538,26 +593,105 @@ function advance( */ 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)); + /* + * The lookahead has to stay comfortably clear of how far off the lane the car + * actually is, and it has to stop growing. + * + * Let it equal the error and the aim point collapses onto the perpendicular: + * the car turns square at its own lane, overshoots, arrives the same distance + * out on the far side, and repeats. At full lock that oscillation is a + * circle, and it was the one still left — 40 laps in a minute, 817 metres + * travelled, eight metres gained, at full speed the whole way. + * + * Letting it grow without limit is the other failure and it was the first fix + * tried here: the point runs away down the lane until the direction to it is + * almost parallel with the lane, the correction vanishes, and the car drifts + * out for ever. So: proportional to the error, floored, and capped. + */ + const lookahead = Math.min( + LANE_LOOKAHEAD * LANE_LOOKAHEAD_CAP, + Math.max( + LANE_LOOKAHEAD, + unit.speed * LANE_LOOKAHEAD_PER_SPEED, + Math.abs(lateral) * LANE_RECOVERY, + ), + ); + const reach = Math.sqrt(Math.max(0, lookahead * lookahead - lateral * lateral)); const along = projected + reach; 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 whatever is in front and lift off. Only civilians defer; anyone - // with somewhere to be leans on the horn and keeps going — and neither does - // anybody who has just heard shooting, because a driver getting out of a - // firefight is not going to wait behind you. + /* + * Close on whatever is in front and lift off. Only civilians defer; anyone + * with somewhere to be leans on the horn and keeps going — and neither does + * anybody who has just heard shooting, because a driver getting out of a + * firefight is not going to wait behind you. + * + * "In front" is measured along the *lane*, not along the nose. In a cluster + * of stopped cars the noses swing about, so a heading-based cone has every + * car intermittently blocked by every other one and none of them can leave. + */ const yields = (unit.role === 'traffic' || unit.role === 'convoy') && !panicking(state, unit); - const gap = yields ? carAhead(state, unit, player) : null; + const gap = yields ? carAhead(state, unit, player, dirX, dirZ) : 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); + + /* + * Steer in proportion to how far the car actually travelled. + * + * This is the fix for traffic driving in circles. A jam brakes every car in + * it to a standstill — each has a neighbour inside the following distance — + * and they were still turning at the full rate while stopped, so a queue + * became a slowly rotating heap that stirred itself and never dispersed. A + * stationary car cannot change which way it is pointing; you steer by moving. + */ + const rolled = unit.speed * dt < 1e-9 ? 0 : move / (unit.speed * dt); + unit.heading = turnToward(unit.heading, desired, TURN_RATE * rolled * dt); + unit.x += Math.sin(unit.heading) * move; unit.z += Math.cos(unit.heading) * move; + + /* + * Last resort: notice when a car is getting nowhere, and put it back. + * + * Everything above is a controller with several interacting parts — a lane to + * follow, a car in front to defer to, other vehicles shoving it out of the + * way — and controllers of that shape have failure modes that are far easier + * to detect than to enumerate. The symptom is always the same and it is + * plainly visible from the road: driving in circles, at speed, for ever. + * + * So rather than trusting that the last one of those is now fixed, this + * measures the only thing that actually matters — is it getting closer to + * where it is going — and if the answer has been no for long enough, sets the + * car back down on its lane pointing the right way. A car that is genuinely + * queueing is not getting closer either, which is why the threshold is long + * enough to sit out any plausible hold-up. + */ + const gapToNode = Math.hypot(node.x - unit.x, node.z - unit.z); + if (unit.closest === undefined || gapToNode < unit.closest - 0.5) { + unit.closest = gapToNode; + unit.stuckFor = 0; + } else { + unit.stuckFor = (unit.stuckFor ?? 0) + dt; + if (unit.stuckFor > STUCK_SECONDS) { + // Put it back on its lane, pointing along it — and throw the route away. + // Repositioning alone was not enough: the car went straight back to + // whatever it had been doing and was stuck again within seconds. Losing + // the path forces a fresh one from wherever it now is, which is the only + // recovery that cannot resume the state it was stuck in. + unit.x = laneX + dirX * Math.max(0, projected); + unit.z = laneZ + dirZ * Math.max(0, projected); + unit.heading = Math.atan2(dirX, dirZ); + unit.path = []; + unit.lastNode = undefined; + unit.closest = undefined; + unit.stuckFor = 0; + return true; + } + } return false; } @@ -1210,10 +1344,17 @@ export function stepUnits( 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; + if (gap >= CAR_SEPARATION) continue; + const push = ((CAR_SEPARATION - gap) / 2) * SEPARATION_EASE; + /* + * Exactly coincident is the one case that has to be handled rather than + * skipped. Two cars at the same point have no direction to be pushed + * apart along, and skipping them welds the pair together permanently — + * they then orbit as a unit, for ever. Any direction will do so long as + * it is deterministic; theirs are opposed, so they part. + */ + const nx = gap < 1e-6 ? 1 : dx / gap; + const nz = gap < 1e-6 ? 0 : dz / gap; moveThrough(a, Math.atan2(-nx, -nz), push, step.blocked); moveThrough(b, Math.atan2(nx, nz), push, step.blocked); }