import { describe, expect, it } from 'vitest'; import { createPhysics } from './physics'; import { createDriveState, drive } from './drive'; import { deriveHandling, freshCondition, ROUGH, surfaceFor, TARMAC, type Surface, } from '../sim/car'; import type { DriverInput } from '../core/input'; import { generateWorld } from '../sim/world'; const STEP = 1 / 60; const IDLE: DriverInput = { throttle: 0, steer: 0, handbrake: false, respawn: false, select: null, toggleMute: false, abandon: false, overhaul: false, look: { yaw: 0, pitch: 0, active: false }, }; /** Rapier runs headless, so vehicle tuning is checkable without a browser. */ async function run(input: Partial, seconds: number) { const world = generateWorld(1, 0); const physics = await createPhysics(world); const start = physics.chassis.translation(); const state = createDriveState(); const handling = deriveHandling(freshCondition()); const cmd = { ...IDLE, ...input }; let maxYawRate = 0; for (let i = 0; i < Math.round(seconds / STEP); i++) { drive(physics, state, cmd, handling, STEP); physics.step(STEP); maxYawRate = Math.max(maxYawRate, Math.abs(physics.chassis.angvel().y)); } const now = physics.chassis.translation(); return { // Displacement from the spawn point, which is a road junction, not the origin. pos: { x: now.x - start.x, y: now.y, z: now.z - start.z }, speed: physics.vehicle.currentVehicleSpeed(), maxYawRate, grounded: [0, 1, 2, 3].every((i) => physics.vehicle.wheelIsInContact(i)), }; } /** Gets up to speed, then measures what happens after a given input. */ async function stopFrom(after: Partial, seconds = 8) { const world = generateWorld(1, 0); const physics = await createPhysics(world); const state = createDriveState(); const handling = deriveHandling(freshCondition()); for (let i = 0; i < 6 / STEP; i++) { drive(physics, state, { ...IDLE, throttle: 1 }, handling, STEP); physics.step(STEP); } const entry = physics.vehicle.currentVehicleSpeed(); const from = physics.chassis.translation(); let time = 0; const cmd = { ...IDLE, ...after }; for (let i = 0; i < seconds / STEP; i++) { if (Math.abs(physics.vehicle.currentVehicleSpeed()) < 0.3) break; drive(physics, state, cmd, handling, STEP); physics.step(STEP); time += STEP; } const to = physics.chassis.translation(); return { entrySpeed: entry, finalSpeed: physics.vehicle.currentVehicleSpeed(), distance: Math.hypot(to.x - from.x, to.z - from.z), time, }; } describe('slowing down', () => { it('hauls the car to a standstill on the brake', async () => { const r = await stopFrom({ throttle: -1 }); expect(r.entrySpeed).toBeGreaterThan(12); expect(Math.abs(r.finalSpeed)).toBeLessThan(0.3); // Roughly road-car braking: not a parachute, not a barge. The lower bound // matters as much as the upper one — brakes strong enough to stop in 10m // read as hitting a wall. expect(r.distance).toBeGreaterThan(15); expect(r.distance).toBeLessThan(38); expect(r.time).toBeGreaterThan(1.5); expect(r.time).toBeLessThan(4.5); }); it('comes to rest on its own when you simply lift off', async () => { const r = await stopFrom({}, 20); // The complaint this fixes: coasting used to never actually stop. expect(Math.abs(r.finalSpeed)).toBeLessThan(0.3); expect(r.time).toBeLessThan(16); }); it('stops harder on the brake than on the overrun', async () => { const braked = await stopFrom({ throttle: -1 }); const coasted = await stopFrom({}, 20); expect(braked.distance).toBeLessThan(coasted.distance); }); it('stops worse on bald tyres', async () => { const physics = await createPhysics(generateWorld(1, 0)); const worn = deriveHandling({ level: { engine: 0.2, tires: 0.2, chassis: 0.2 }, ceiling: { engine: 1, tires: 1, chassis: 1 }, }); expect(worn.brakeForce).toBeLessThan(deriveHandling(freshCondition()).brakeForce); expect(physics.vehicle.numWheels()).toBe(4); }); }); /** Seconds to swing the nose through a quarter turn from a given entry speed. */ async function quarterTurn(entrySpeed: number) { const physics = await createPhysics(generateWorld(1, 0)); const state = createDriveState(); const handling = deriveHandling(freshCondition()); // Get up to the speed you would actually take a junction at. while (physics.vehicle.currentVehicleSpeed() < entrySpeed) { drive(physics, state, { ...IDLE, throttle: 1 }, handling, STEP); physics.step(STEP); } const yawOf = () => { const r = physics.chassis.rotation(); return Math.atan2(2 * (r.w * r.y + r.x * r.z), 1 - 2 * (r.y * r.y + r.x * r.x)); }; let turned = 0; let previous = yawOf(); let time = 0; let travelled = 0; for (let i = 0; i < 8 / STEP && turned < Math.PI / 2; i++) { drive(physics, state, { ...IDLE, throttle: 0.4, steer: 1 }, handling, STEP); physics.step(STEP); const now = yawOf(); let delta = now - previous; // Unwrap, so crossing ±π does not read as a huge jump. if (delta > Math.PI) delta -= Math.PI * 2; if (delta < -Math.PI) delta += Math.PI * 2; turned += Math.abs(delta); previous = now; time += STEP; travelled += Math.abs(physics.vehicle.currentVehicleSpeed()) * STEP; } // Radius, not time, is what a corner costs you: a fast car sweeps through // ninety degrees *quicker* than a slow one, just across far more tarmac. return { turned, time, radius: travelled / Math.max(turned, 1e-6) }; } describe('taking a junction', () => { it('gets round a right-angle corner at junction speed', async () => { // The map is a grid of 90-degree turns. If the car cannot make one at a // sane approach speed, the whole road network fights the player. const turn = await quarterTurn(9); expect(turn.turned).toBeGreaterThanOrEqual(Math.PI / 2); expect(turn.time).toBeLessThan(3); // Tight enough to stay inside a junction rather than swinging into the // buildings on the far side of it. expect(turn.radius).toBeLessThan(14); }); it('costs you road, not steering, as speed rises', async () => { const slow = await quarterTurn(6); const fast = await quarterTurn(22); // Both corners get made; the fast one just eats far more tarmac doing it. expect(slow.turned).toBeGreaterThanOrEqual(Math.PI / 2); expect(fast.turned).toBeGreaterThanOrEqual(Math.PI / 2); expect(fast.radius).toBeGreaterThan(slow.radius * 1.5); }); }); describe('roads are worth using', () => { /** Flat out on a given surface for long enough to find its ceiling. */ async function topSpeed(surface: Surface) { const physics = await createPhysics(generateWorld(1, 0)); const state = createDriveState(); const handling = deriveHandling(freshCondition()); let top = 0; for (let i = 0; i < 30 / STEP; i++) { drive(physics, state, { ...IDLE, throttle: 1 }, handling, STEP, surface); physics.step(STEP); top = Math.max(top, physics.vehicle.currentVehicleSpeed()); } return top; } it('goes markedly faster on tarmac than across country', async () => { const road = await topSpeed(TARMAC); const rough = await topSpeed(ROUGH); expect(rough).toBeLessThan(road * 0.6); }); it('still lets you leave the road when you need to', async () => { // Going around a checkpoint cross-country has to cost something without // being impossible. An earlier tuning made off-road a 12 km/h crawl, which // is not a decision — it is a wall. const rough = await topSpeed(ROUGH); expect(rough).toBeGreaterThan(12); }); it('is chosen by whether the car is on a route at all', () => { expect(surfaceFor(true)).toBe(TARMAC); expect(surfaceFor(false)).toBe(ROUGH); }); }); describe('vehicle', () => { it('settles on its suspension instead of sinking or bouncing away', async () => { const r = await run({}, 2); expect(r.grounded).toBe(true); expect(r.pos.y).toBeGreaterThan(0.4); expect(r.pos.y).toBeLessThan(1.1); expect(Math.abs(r.speed)).toBeLessThan(0.2); }); it('accelerates forward along +Z at a plausible rate', async () => { const r = await run({ throttle: 1 }, 5); expect(r.pos.z).toBeGreaterThan(20); // Roughly 40–140 km/h after five seconds: quick, but not a rocket. expect(r.speed).toBeGreaterThan(11); expect(r.speed).toBeLessThan(39); }); it('turns when steered, without spinning like a top', async () => { const straight = await run({ throttle: 1 }, 5); const turning = await run({ throttle: 1, steer: 1 }, 5); // Position is a poor check here — a hard turn loops back near the start. expect(turning.maxYawRate).toBeGreaterThan(0.3); // Loosened when steering was sharpened for junctions: at low speed under // full lock the car now comes round at about 100 deg/s, which is tight but // is what makes the map's right angles drivable. expect(turning.maxYawRate).toBeLessThan(2.2); expect(straight.maxYawRate).toBeLessThan(0.05); }); it('steers left on positive input', async () => { // Forward is +Z and up is +Y, so left is +X. const r = await run({ throttle: 1, steer: 1 }, 2); expect(r.pos.x).toBeGreaterThan(0.2); }); it('stays upright under power and steering', async () => { const r = await run({ throttle: 1, steer: 1 }, 8); expect(r.grounded).toBe(true); }); });