Back to Big Rig Rush
SOURCE / PINNED RELEASE

Made of little things.

Big Rig Rush

Release
ec9b6549f752…
Author-recorded commit
74b729258b1a…
License
LICENSE
Author’s source reference
nostr://npub1sj2vketepqw9v6fmge5fdzuj7atj9sv6qv2jss05n2z6rl64lcfqumdsnt/wss%3A%2F%2Fgit.napplet.soy%2F/n-0a99f0cae40

Archive hash verified: 900e22810340ccb1…. The source-to-build association is the author’s claim; it has not been independently rebuilt.

src/game/services.ts
import { laneLayer, SURF_DIRT, SURF_GRASS, SURF_ROAD, SURF_WATER, type City, type Lane, type Station, type StationKind } from './city.js';
import type { Effects } from './effects.js';
import { angleDiff, clamp, poseAt, type Vec } from './geometry.js';
import { axleGrip, boxContact, boxInertia, forwardSpeed, integrate, resolveContact, resolveStatic, type Body } from './physics.js';
import { inView, type Traffic, type View } from './traffic.js';
import { GRAVITY, type Truck } from './truck.js';
import { drawCar, drawPed, makeLook, type CarDamage, type CarLook, type CarModel, type PedLook } from './vehicles.js';

export type ServiceKind = 'tow' | 'roadside' | 'ambulance' | 'fire' | 'police' | 'garbage' | 'sweeper' | 'works' | 'roadcrew' | 'rescue' | 'mytow' | 'mob';

/** What a crew does on arrival: walk (or drive) to each work point, work there, then head home. */
export interface ServiceTask {
  /** Where the crew should go once parked (re-evaluated on arrival). */
  points: () => Vec[];
  /** Cancel an assignment if its scene becomes unsafe again, even en route. */
  valid?: () => boolean;
  /** Seconds of work at each point. */
  work: number;
  /** 'crew' walks to each point; 'drive' drives the vehicle over them (street sweepers). */
  mode?: 'crew' | 'drive';
  /** Leave the road for the last stretch to reach a scene off the road (default true). */
  offroad?: boolean;
  /** Keep standing by until this is true (police waiting for the tow trucks). */
  until?: () => boolean;
  /** Longest a unit stays on scene, in seconds. */
  maxTime?: number;
  onWorking?: (crew: Vec, point: Vec, dt: number) => void;
  onPointDone?: (point: Vec, vehicle: ServiceVehicle) => void;
  /** Called once the whole job is done. */
  onDone?: () => void;
  /** The job fell through (unit wrecked or hopelessly stuck): release any claims. */
  onAbort?: () => void;
}

interface Crew {
  x: number;
  y: number;
  angle: number;
  stride: number;
  look: PedLook;
  queue: Vec[];
  timer: number;
}

interface RoutePt {
  x: number;
  y: number;
  layer: number;
  /** Junction turn: take it slowly. */
  slow: boolean;
}

interface Spec {
  model: CarModel;
  color: string;
  accent: string;
  crew: PedLook;
  crewSize: number;
  /** Cruise speed with lights and siren (or on a normal run). */
  speed: number;
  mass: number;
  siren: boolean;
  /** Amber beacon while working. */
  beacon: boolean;
}

const SPECS: Record<ServiceKind, Spec> = {
  tow: { model: 'tow', color: '#f2a72e', accent: '#ffffff', crew: { shirt: '#ff8a00', pants: '#2c3e50', skin: '#e0ac69', hair: '#2b1d14', hat: '#ffcc00' }, crewSize: 1, speed: 95, mass: 4, siren: false, beacon: true },
  roadside: { model: 'roadside', color: '#f7d038', accent: '#1f3b6e', crew: { shirt: '#1f3b6e', pants: '#2c3e50', skin: '#c68642', hair: '#0f0f0f', hat: '#f7d038' }, crewSize: 1, speed: 82, mass: 2.4, siren: false, beacon: true },
  ambulance: { model: 'ambulance', color: '#fdfdfd', accent: '#ffffff', crew: { shirt: '#f4f4f4', pants: '#1f3b6e', skin: '#c68642', hair: '#0f0f0f' }, crewSize: 2, speed: 108, mass: 3.2, siren: true, beacon: false },
  fire: { model: 'firetruck', color: '#c0392b', accent: '#ffffff', crew: { shirt: '#3a2a1a', pants: '#3a2a1a', skin: '#f1c27d', hair: '#4a3222', hat: '#ffcc00' }, crewSize: 2, speed: 96, mass: 8, siren: true, beacon: false },
  police: { model: 'police', color: '#15161c', accent: '#ffffff', crew: { shirt: '#1f3b6e', pants: '#15161c', skin: '#ffdbac', hair: '#15161c', hat: '#15161c' }, crewSize: 1, speed: 118, mass: 1.7, siren: true, beacon: false },
  garbage: { model: 'garbage', color: '#2e7d4f', accent: '#ffffff', crew: { shirt: '#7dff6a', pants: '#2c3e50', skin: '#8d5524', hair: '#2b1d14' }, crewSize: 2, speed: 58, mass: 7, siren: false, beacon: true },
  sweeper: { model: 'sweeper', color: '#f4f1ea', accent: '#ff8a00', crew: { shirt: '#ff8a00', pants: '#2c3e50', skin: '#ffdbac', hair: '#7a7a7a' }, crewSize: 1, speed: 55, mass: 3.5, siren: false, beacon: true },
  works: { model: 'works', color: '#e67e22', accent: '#ffffff', crew: { shirt: '#ffcc00', pants: '#5d4037', skin: '#e0ac69', hair: '#4a3222', hat: '#ffcc00' }, crewSize: 2, speed: 70, mass: 2.6, siren: false, beacon: true },
  // The player's own tow truck: rig colours, fast, and nothing gets in its way.
  mytow: { model: 'tow', color: '#d8402f', accent: '#ffffff', crew: { shirt: '#d8402f', pants: '#15141b', skin: '#f1c27d', hair: '#2b1d14', hat: '#efeae0' }, crewSize: 1, speed: 150, mass: 4, siren: false, beacon: true },
  // The family's black van.
  mob: { model: 'van', color: '#15161c', accent: '#3a3d42', crew: { shirt: '#15161c', pants: '#15161c', skin: '#e0ac69', hair: '#0f0f0f', hat: '#2b2b2e' }, crewSize: 2, speed: 115, mass: 2.4, siren: false, beacon: false },
  // Heavy rescue: the fire service's search-and-rescue truck, for people under the rubble.
  rescue: { model: 'firetruck', color: '#f2b705', accent: '#c0392b', crew: { shirt: '#f2b705', pants: '#3a2a1a', skin: '#c68642', hair: '#2b1d14', hat: '#f4f4f4' }, crewSize: 2, speed: 92, mass: 7, siren: true, beacon: false },
  roadcrew: { model: 'dump', color: '#e67e22', accent: '#ffffff', crew: { shirt: '#ff8a00', pants: '#2c3e50', skin: '#a0673d', hair: '#0f0f0f', hat: '#ffcc00' }, crewSize: 2, speed: 62, mass: 7.5, siren: false, beacon: true },
};

/** Which units each kind of station keeps. */
const FLEET: Record<StationKind, [ServiceKind, number][]> = {
  hospital: [['ambulance', 3]],
  police: [['police', 3]],
  fire: [['fire', 2], ['rescue', 1]],
  tow: [['tow', 5], ['roadside', 2]],
  works: [['garbage', 2], ['sweeper', 2], ['works', 2], ['roadcrew', 2]],
};

/** A unit written off in a crash is replaced by the city after this long. */
const REPLACE_SECONDS = 150;

export type DispatchResult = 'ok' | 'busy' | 'unreachable';

interface Depot {
  station: Station;
  kind: ServiceKind;
  units: number;
  busy: number;
  lane: number;
  s: number;
  /** Seconds until each written-off unit is replaced. */
  lost: number[];
}

function surfaceGrip(surface: number): number {
  if (surface === SURF_GRASS) return 0.7;
  if (surface === SURF_DIRT) return 0.75;
  if (surface === SURF_WATER) return 0.35;
  return 1;
}

export class ServiceVehicle {
  readonly look: CarLook;
  readonly body: Body;
  readonly spec: Spec;
  state: 'drive' | 'work' | 'return' = 'drive';
  depot: Depot | null = null;
  /** Sent for the player's own rig (as opposed to a city job). */
  forPlayer = false;
  /** A wreck hooked up behind a tow truck. */
  towing: { look: CarLook; dmg: CarDamage } | null = null;
  /** Heading for the junkyard with a wreck. */
  toJunk = false;
  /** Patients aboard an ambulance, for the hospital. */
  patients = 0;
  route: RoutePt[] = [];
  /** Distance along the route to each point. */
  cum: number[] = [];
  idx = 0;
  layer = 0;
  crew: Crew[] = [];
  cones: Vec[] = [];
  age = 0;
  /** Time on scene. */
  onScene = 0;
  steer = 0;
  health = 100;
  /** Sideways offset from the route while getting round something. */
  avoid = 0;
  avoidTarget = 0;
  avoidTimer = 0;
  stuck = 0;
  stuckCount = 0;
  reverse = 0;
  /** Seconds spent held up behind something that won't move. */
  wait = 0;
  /** Squeezing through a jam: ignore traffic for a moment. */
  ghost = 0;
  /** What last damaged it. */
  lastHit = '';
  /** Near enough to the player to matter: off-screen units don't trade paint with traffic. */
  seen = false;
  braking = false;
  /** Work points for drive-mode jobs. */
  sweep: Vec[] = [];
  sweepTimer = 0;
  /** Driving over a work point (slowly, without arriving anywhere). */
  sweeping = false;
  /** The player's tow: drives through traffic as if it weren't there. */
  express = false;
  /** Seconds out of sight while heading home. */
  unseenTime = 0;
  /** Police pull across the lane to close it. */
  parkAngle: number | null = null;
  readonly dmg: CarDamage = { crumple: [0, 0, 0, 0], wheels: [true, true, true, true], flipped: false, z: 0, roll: 0, hazard: false, burnt: 0, glass: false };

  constructor(
    readonly kind: ServiceKind,
    public target: Vec,
    public task: ServiceTask,
  ) {
    this.spec = SPECS[kind];
    this.look = makeLook(this.spec.model, this.spec.color, this.spec.accent, false);
    const m = this.spec.mass;
    this.body = { x: 0, y: 0, vx: 0, vy: 0, angle: 0, av: 0, mass: m, inertia: boxInertia(m, this.look.length / 2, this.look.width / 2), halfL: this.look.length / 2, halfW: this.look.width / 2 };
  }

  get x(): number {
    return this.body.x;
  }

  get y(): number {
    return this.body.y;
  }

  get speed(): number {
    return forwardSpeed(this.body);
  }

  get siren(): boolean {
    return this.spec.siren && this.state === 'drive';
  }

  /** Still out on (or heading to) its job. */
  get busy(): boolean {
    return this.state !== 'return';
  }
}

/** What the fleet needs to see of the rest of the world while driving. */
export interface ServiceWorld {
  traffic: Traffic;
  truck: Truck;
  /** People and animals in the road. */
  obstacles: Vec[];
  view: View;
}

/**
 * Every city service fleet: units wait at their stations, drive out over the lane
 * graph with real physics, leave the road when they must, work the scene and drive
 * back. Units can be rammed and written off; the city replaces them after a while.
 */
export class Services {
  readonly vehicles: ServiceVehicle[] = [];
  readonly depots: Depot[] = [];
  /** Roads the police have closed round a scene: traffic is held back or waved past slowly. */
  readonly closures: { x: number; y: number; r: number; layer: number }[] = [];
  private time = 0;
  private view: View = { x: 0, y: 0, w: 0, h: 0 };
  /** Lanes leading into each lane (for spawning the player's tow behind them). */
  private readonly prev = new Map<number, number[]>();
  /** A unit just finished: give it another job nearby instead of sending it home. */
  onIdle: ((v: ServiceVehicle) => boolean) | null = null;
  /** A unit was rammed hard enough to write it off. */
  onWrecked: ((v: ServiceVehicle, closing: number, px: number, py: number, nx: number, ny: number) => void) | null = null;
  /** The player's rig hit a unit. */
  onTruckHit: ((v: ServiceVehicle, closing: number, px: number, py: number) => void) | null = null;

  constructor(
    private readonly city: City,
    private readonly effects: Effects,
  ) {
    for (const st of city.stations) {
      const near = this.nearestLane(st.x, st.y, 0);
      if (!near) continue;
      for (const [kind, units] of FLEET[st.kind]) this.depots.push({ station: st, kind, units, busy: 0, lane: near.lane.id, s: near.s, lost: [] });
    }
    for (const lane of city.lanes) {
      for (const n of lane.next) {
        const list = this.prev.get(n);
        if (list) list.push(lane.id);
        else this.prev.set(n, [lane.id]);
      }
    }
  }

  /** A private fleet based somewhere that isn't a city station (the family's vans). */
  addDepot(station: Station, kind: ServiceKind, units: number): void {
    const near = this.nearestLane(station.x, station.y, 0);
    if (near) this.depots.push({ station, kind, units, busy: 0, lane: near.lane.id, s: near.s, lost: [] });
  }

  clear(): void {
    this.vehicles.length = 0;
    for (const d of this.depots) {
      d.busy = 0;
      d.lost = [];
    }
  }

  /** The player's own tow truck (one at a time). */
  active(kind: ServiceKind): ServiceVehicle | undefined {
    return this.vehicles.find((v) => v.kind === kind && v.busy && v.forPlayer);
  }

  /** Free units of a kind across every station. */
  available(kind: ServiceKind): number {
    return this.depots.filter((d) => d.kind === kind && !d.station.structure?.destroyed).reduce((n, d) => n + Math.max(0, d.units - d.busy), 0);
  }

  total(kind: ServiceKind): number {
    return this.depots.filter((d) => d.kind === kind).reduce((n, d) => n + d.units, 0);
  }

  /** Is a unit of this kind already working (or heading to) somewhere within r? */
  covering(kind: ServiceKind, x: number, y: number, r: number): boolean {
    return this.vehicles.some((v) => v.kind === kind && v.busy && Math.hypot(v.target.x - x, v.target.y - y) < r);
  }

  /** The closest lane on a layer to a point (excluding junction connectors), preferring one heading our way. */
  private nearestLane(x: number, y: number, layer: number, heading?: number): { lane: Lane; s: number; d: number } | null {
    let best: { lane: Lane; s: number; d: number } | null = null;
    let bestScore = Infinity;
    for (const lane of this.city.lanes) {
      if (lane.kind === 'connector' || (lane.layer0 !== layer && lane.layer1 !== layer)) continue;
      if (x < lane.x0 - 80 || x > lane.x1 + 80 || y < lane.y0 - 80 || y > lane.y1 + 80) continue;
      for (let i = 0; i < lane.pts.length - 1; i++) {
        const a = lane.pts[i];
        const b = lane.pts[i + 1];
        const vx = b.x - a.x;
        const vy = b.y - a.y;
        const l2 = vx * vx + vy * vy || 1;
        const t = Math.max(0, Math.min(1, ((x - a.x) * vx + (y - a.y) * vy) / l2));
        const d = Math.hypot(x - (a.x + vx * t), y - (a.y + vy * t));
        const s = lane.cum[i] + Math.sqrt(l2) * t;
        let score = d;
        if (heading !== undefined && Math.cos(Math.atan2(vy, vx) - heading) < 0) score += 18;
        if (score >= bestScore || laneLayer(lane, s) !== layer) continue;
        bestScore = score;
        best = { lane, s, d };
      }
    }
    return best;
  }

  /**
   * Shortest route by distance along lane links (Dijkstra). A lane can route back
   * round to itself when the goal is behind us on the same lane.
   */
  private laneRoute(from: number, to: number, sameLane: boolean): number[] | null {
    if (from === to && sameLane) return [from];
    const lanes = this.city.lanes;
    const dist = new Map<number, number>();
    const parent = new Map<number, number>();
    // Binary heap of [cost, lane].
    const heap: [number, number][] = [];
    const push = (cost: number, id: number) => {
      heap.push([cost, id]);
      let i = heap.length - 1;
      while (i > 0) {
        const p = (i - 1) >> 1;
        if (heap[p][0] <= heap[i][0]) break;
        [heap[p], heap[i]] = [heap[i], heap[p]];
        i = p;
      }
    };
    const pop = (): [number, number] => {
      const top = heap[0];
      const last = heap.pop()!;
      if (heap.length) {
        heap[0] = last;
        let i = 0;
        for (;;) {
          const l = i * 2 + 1;
          const r = l + 1;
          let m = i;
          if (l < heap.length && heap[l][0] < heap[m][0]) m = l;
          if (r < heap.length && heap[r][0] < heap[m][0]) m = r;
          if (m === i) break;
          [heap[m], heap[i]] = [heap[i], heap[m]];
          i = m;
        }
      }
      return top;
    };
    for (const n of lanes[from].next) {
      if (n === from && from !== to) continue;
      if ((dist.get(n) ?? Infinity) <= lanes[from].len) continue;
      dist.set(n, lanes[from].len);
      parent.set(n, from);
      push(lanes[from].len, n);
    }
    let visited = 0;
    while (heap.length && visited++ < 20000) {
      const [cost, id] = pop();
      if (cost > (dist.get(id) ?? Infinity)) continue;
      if (id === to) {
        const path = [to];
        for (let cur = parent.get(to)!; cur !== from; cur = parent.get(cur)!) path.push(cur);
        path.push(from);
        return path.reverse();
      }
      const next = cost + lanes[id].len;
      for (const n of lanes[id].next) {
        if (n === from && from !== to) continue;
        if (next >= (dist.get(n) ?? Infinity)) continue;
        dist.set(n, next);
        parent.set(n, id);
        push(next, n);
      }
    }
    return null;
  }

  /** Points along a lane path, from s0 on the first lane to s1 on the last. */
  private lanePoints(path: number[], s0: number, s1: number, out: RoutePt[]): void {
    path.forEach((id, i) => {
      const lane = this.city.lanes[id];
      const a = i === 0 ? s0 : 0;
      const b = i === path.length - 1 ? s1 : lane.len;
      const slow = lane.turn || lane.kind === 'connector';
      for (let s = a; s < b; s += 7) {
        const p = poseAt(lane, s);
        out.push({ x: p.x, y: p.y, layer: laneLayer(lane, s), slow });
      }
      const p = poseAt(lane, b);
      out.push({ x: p.x, y: p.y, layer: laneLayer(lane, b), slow });
    });
  }

  private setRoute(v: ServiceVehicle, pts: RoutePt[]): void {
    v.route = pts;
    v.cum = [0];
    for (let i = 1; i < pts.length; i++) v.cum.push(v.cum[i - 1] + Math.hypot(pts[i].x - pts[i - 1].x, pts[i].y - pts[i - 1].y));
    v.idx = 0;
    v.stuck = 0;
    v.stuckCount = 0;
  }

  /**
   * A route from a lane position to a scene: along the lanes to the nearest point,
   * pulled over onto the shoulder, and (if allowed) off the road to the scene itself.
   */
  private planTo(v: ServiceVehicle, fromLane: number, fromS: number, x: number, y: number, layer: number, lead: RoutePt[] = []): boolean {
    const goal = this.nearestLane(x, y, layer);
    if (!goal) return false;
    const offroad = (v.task.offroad ?? true) && layer === 0 && goal.d > 16;
    const stopS = offroad ? goal.s : Math.max(0, goal.s - (v.kind === 'sweeper' ? 4 : 18));
    const path = this.laneRoute(fromLane, goal.lane.id, fromS <= stopS);
    if (!path) return false;
    const pts = [...lead];
    this.lanePoints(path, fromS, stopS, pts);
    if (offroad) {
      // Straight across the verge to just short of the scene.
      const end = pts[pts.length - 1];
      const d = Math.hypot(x - end.x, y - end.y);
      const stop = Math.max(0, d - (v.kind === 'fire' ? 16 : 12));
      for (let t = 8; t < stop; t += 8) pts.push({ x: end.x + ((x - end.x) / d) * t, y: end.y + ((y - end.y) / d) * t, layer: 0, slow: true });
      if (stop > 0) pts.push({ x: end.x + ((x - end.x) / d) * stop, y: end.y + ((y - end.y) / d) * stop, layer: 0, slow: true });
    } else if (pts.length > 2) {
      // Pull over to the right for the last few metres.
      const n = pts.length;
      for (let i = Math.max(1, n - 4); i < n; i++) {
        const a = pts[i - 1];
        const b = pts[i];
        const l = Math.hypot(b.x - a.x, b.y - a.y) || 1;
        const k = (i - (n - 5)) / 4;
        pts[i] = { ...b, x: b.x - ((b.y - a.y) / l) * 5 * k, y: b.y + ((b.x - a.x) / l) * 5 * k };
      }
    }
    this.setRoute(v, pts);
    return true;
  }

  /** Point on the road nearest a spot (for putting the rig back). */
  roadPointNear(x: number, y: number): { x: number; y: number; angle: number; layer: number } | null {
    const near = this.nearestLane(x, y, 0);
    if (!near) return null;
    const p = poseAt(near.lane, near.s);
    return { x: p.x, y: p.y, angle: p.angle, layer: laneLayer(near.lane, near.s) };
  }

  /** Send the nearest free unit of a kind from its station. */
  dispatch(kind: ServiceKind, x: number, y: number, task: ServiceTask, forPlayer = false, layer = 0): DispatchResult {
    // A station that's been destroyed sends nobody until it's rebuilt.
    const depots = this.depots.filter((d) => d.kind === kind && d.busy < d.units && !d.station.structure?.destroyed).sort((a, b) => Math.hypot(a.station.x - x, a.station.y - y) - Math.hypot(b.station.x - x, b.station.y - y));
    if (!depots.length) return 'busy';
    const v = new ServiceVehicle(kind, { x, y }, task);
    v.forPlayer = forPlayer;
    // Of the three nearest stations, the one with the shortest drive.
    let best: { d: Depot; route: ServiceVehicle['route']; cum: number[] } | null = null;
    let bestLen = Infinity;
    for (const d of depots.slice(0, 3)) {
      if (!this.planTo(v, d.lane, d.s, x, y, layer)) continue;
      const len = v.cum[v.cum.length - 1];
      if (len < bestLen) {
        bestLen = len;
        best = { d, route: v.route, cum: v.cum };
      }
    }
    if (best) {
      const d = best.d;
      let route = best.route;
      // A long run: the unit was already out on patrol, so it starts its last stretch just out of sight.
      const total = bestLen;
      // The family's van is always lurking nearby.
      const lead = kind === 'mob' ? 380 : 750;
      if (total > lead + 250) {
        const cum = best.cum;
        let i = cum.findIndex((c) => total - c <= lead);
        while (i > 0 && inView(route[i].x, route[i].y, this.view, 90)) i--;
        if (i > 0) route = route.slice(i);
      }
      this.setRoute(v, route);
      d.busy++;
      v.depot = d;
      const p = route[0];
      const q = route[Math.min(route.length - 1, 1)];
      v.body.x = p.x;
      v.body.y = p.y;
      v.body.angle = q === p ? 0 : Math.atan2(q.y - p.y, q.x - p.x);
      v.layer = p.layer;
      if (total > lead + 250) {
        v.body.vx = Math.cos(v.body.angle) * v.spec.speed * 0.6;
        v.body.vy = Math.sin(v.body.angle) * v.spec.speed * 0.6;
      }
      this.vehicles.push(v);
      return 'ok';
    }
    return 'unreachable';
  }

  /**
   * The player's own tow truck: it comes from just behind the rig, out of sight,
   * fast, straight through the traffic, and fixes things in no time.
   */
  dispatchExpress(x: number, y: number, task: ServiceTask): boolean {
    const goal = this.nearestLane(x, y, 0);
    if (!goal) return false;
    const v = new ServiceVehicle('mytow', { x, y }, task);
    v.forPlayer = true;
    v.express = true;
    for (const back of [240, 380, 600]) {
      let lane = goal.lane.id;
      let s = goal.s;
      let left = back;
      while (left > s) {
        left -= s;
        const prevs = this.prev.get(lane)?.filter((l) => this.city.lanes[l].layer0 === 0 && this.city.lanes[l].layer1 === 0);
        if (!prevs?.length) {
          left = s;
          break;
        }
        lane = prevs[Math.floor(Math.random() * prevs.length)];
        s = this.city.lanes[lane].len;
      }
      const startS = Math.max(0, s - left);
      const p = poseAt(this.city.lanes[lane], startS);
      if (back < 600 && inView(p.x, p.y, this.view, 20)) continue;
      if (!this.planTo(v, lane, startS, x, y, 0)) continue;
      v.body.x = p.x;
      v.body.y = p.y;
      v.body.angle = p.angle;
      v.body.vx = Math.cos(p.angle) * 90;
      v.body.vy = Math.sin(p.angle) * 90;
      this.vehicles.push(v);
      return true;
    }
    return false;
  }

  /** Give a unit that's out and about a new job (instead of going back to base). */
  reassign(v: ServiceVehicle, x: number, y: number, layer: number, task: ServiceTask): boolean {
    const start = this.nearestLane(v.x, v.y, v.layer, v.body.angle);
    if (!start) return false;
    const old = { target: v.target, task: v.task };
    v.target = { x, y };
    v.task = task;
    const lead: RoutePt[] = start.d > 10 ? [{ x: v.x, y: v.y, layer: v.layer, slow: true }] : [];
    if (!this.planTo(v, start.lane.id, start.s, x, y, layer, lead)) {
      v.target = old.target;
      v.task = old.task;
      return false;
    }
    v.state = 'drive';
    v.age = 0;
    v.onScene = 0;
    v.unseenTime = 0;
    v.parkAngle = null;
    v.crew = [];
    v.cones = [];
    v.sweep = [];
    return true;
  }

  /** Job done: take the next one nearby if there is one, otherwise head home. */
  private finish(v: ServiceVehicle): void {
    v.crew = [];
    v.cones = [];
    v.sweep = [];
    v.state = 'return';
    // Carrying a wreck or patients: deliver them first.
    if (!v.towing && !v.patients && !v.forPlayer && this.onIdle?.(v)) return;
    this.goHome(v);
  }

  /** Head back to the station once the job is done (or just drive off, for units with no station). */
  private goHome(v: ServiceVehicle): void {
    v.state = 'return';
    v.age = 0;
    v.unseenTime = 0;
    v.parkAngle = null;
    v.avoid = v.avoidTarget = 0;
    const home = v.depot;
    const start = this.nearestLane(v.x, v.y, v.layer, v.body.angle);
    const lead: RoutePt[] = [];
    if (start && start.d > 10) lead.push({ x: v.x, y: v.y, layer: v.layer, slow: true });
    // A tow truck with a wreck on the hook takes it to the junkyard.
    const junk = this.city.junkyard;
    if (v.towing && junk && start) {
      const goal = this.nearestLane(junk.x, junk.y, 0);
      const path = goal ? this.laneRoute(start.lane.id, goal.lane.id, start.s <= goal.s) : null;
      if (goal && path) {
        this.lanePoints(path, start.s, goal.s, lead);
        this.setRoute(v, lead);
        v.toJunk = true;
        return;
      }
    }
    if (!home && start) {
      // Drive off down the road and out of sight.
      const path = [start.lane.id];
      let len = this.city.lanes[start.lane.id].len - start.s;
      while (len < 500) {
        const next = this.city.lanes[path[path.length - 1]].next;
        if (!next.length) break;
        const n = next[Math.floor(Math.random() * next.length)];
        path.push(n);
        len += this.city.lanes[n].len;
      }
      this.lanePoints(path, start.s, this.city.lanes[path[path.length - 1]].len, lead);
      this.setRoute(v, lead);
      return;
    }
    if (!home || !start) {
      this.setRoute(v, []);
      return;
    }
    const path = this.laneRoute(start.lane.id, home.lane, start.s <= home.s);
    if (!path) {
      this.setRoute(v, []);
      return;
    }
    this.lanePoints(path, start.s, home.s, lead);
    this.setRoute(v, lead);
  }

  private abort(v: ServiceVehicle): void {
    if (v.state === 'return') return;
    v.task.onAbort?.();
    v.crew = [];
    v.cones = [];
    v.sweep = [];
    this.goHome(v);
  }

  update(dt: number, world: ServiceWorld): void {
    this.time += dt;
    this.view = world.view;
    this.closures.length = 0;
    for (const v of this.vehicles) if (v.kind === 'police' && v.state === 'work') this.closures.push({ x: v.target.x, y: v.target.y, r: 55, layer: v.layer });
    for (const d of this.depots) {
      for (let i = d.lost.length - 1; i >= 0; i--) {
        d.lost[i] -= dt;
        if (d.lost[i] <= 0) {
          d.lost.splice(i, 1);
          d.busy = Math.max(0, d.busy - 1);
        }
      }
    }
    for (const v of this.vehicles) {
      if (v.state !== 'return' && v.task.valid && !v.task.valid()) this.abort(v);
      v.age += dt;
      v.seen = inView(v.x, v.y, world.view, 160);
      const flashing = v.siren || (v.state === 'work' && (v.spec.siren || v.spec.beacon)) || (v.spec.beacon && v.state === 'drive') || !!v.towing;
      v.dmg.hazard = flashing && Math.floor(this.time * 4) % 2 === 0;
      if (v.state === 'work') this.work(v, dt, world);
      else this.drive(v, dt, world);
      if (v.siren) this.clearTheWay(v, world.traffic);
    }
    this.collide(world);
    for (let i = this.vehicles.length - 1; i >= 0; i--) {
      const v = this.vehicles[i];
      // Out of sight on the way home: it's as good as back (wrecks and patients delivered).
      if (v.state === 'return') {
        v.unseenTime = inView(v.x, v.y, world.view, 260) ? 0 : v.unseenTime + dt;
        if (v.unseenTime > 2.5) {
          v.towing = null;
          v.toJunk = false;
          v.patients = 0;
          v.unseenTime = 0;
          if (!v.forPlayer && this.onIdle?.(v)) continue;
          if (v.depot) v.depot.busy = Math.max(0, v.depot.busy - 1);
          this.vehicles.splice(i, 1);
          continue;
        }
      }
      const last = v.route[v.route.length - 1];
      const home = v.state === 'return' && (!last || (v.idx >= v.route.length - 2 && Math.hypot(last.x - v.x, last.y - v.y) < 10));
      if (home && v.toJunk) {
        // Dropped at the junkyard: on to the next job, or back to the depot.
        v.towing = null;
        v.toJunk = false;
        if (!this.onIdle?.(v)) this.goHome(v);
        continue;
      }
      const lost = v.state === 'return' && v.age > 240 && !inView(v.x, v.y, world.view, 60);
      if (home || lost) {
        if (v.depot) v.depot.busy = Math.max(0, v.depot.busy - 1);
        this.vehicles.splice(i, 1);
      }
    }
  }

  /** Everyone ahead of a siren (same way, oncoming or at the next junction) pulls over to the right and waits. */
  private clearTheWay(v: ServiceVehicle, traffic: Traffic): void {
    const c = Math.cos(v.body.angle);
    const s = Math.sin(v.body.angle);
    for (const car of traffic.cars) {
      if (car.layer !== v.layer || Math.abs(car.x - v.x) > 170 || Math.abs(car.y - v.y) > 170) continue;
      const dx = car.x - v.x;
      const dy = car.y - v.y;
      const along = dx * c + dy * s;
      if (along < -8 || along > 170 || Math.abs(-dx * s + dy * c) > 45) continue;
      car.yieldTime = 1.6;
    }
  }

  /** Steer along the route with real tyre physics, pass anything in the way, and get unstuck. */
  private drive(v: ServiceVehicle, dt: number, world: ServiceWorld): void {
    const b = v.body;
    const pts = v.route;
    const n = pts.length;
    if (!n) {
      v.age = 999;
      return;
    }
    const fwd = forwardSpeed(b);
    while (v.idx < n - 1) {
      const p = pts[v.idx];
      const q = pts[v.idx + 1];
      const d = Math.hypot(p.x - b.x, p.y - b.y);
      if (d < 5 + Math.abs(fwd) * 0.12 || Math.hypot(q.x - b.x, q.y - b.y) < d) v.idx++;
      else break;
    }
    v.layer = pts[v.idx].layer;
    const lastPt = pts[n - 1];
    const remaining = v.cum[n - 1] - v.cum[v.idx] + Math.hypot(pts[v.idx].x - b.x, pts[v.idx].y - b.y);
    if (v.state === 'drive' && !v.sweeping && (remaining < 3 || (v.idx >= n - 2 && Math.hypot(lastPt.x - b.x, lastPt.y - b.y) < 4 && Math.abs(fwd) < 6))) {
      this.arrive(v);
      return;
    }

    // Look ahead along the route, shifted sideways if we're passing something.
    const lookDist = 9 + Math.abs(fwd) * 0.3;
    let j = v.idx;
    while (j < n - 1 && Math.hypot(pts[j].x - b.x, pts[j].y - b.y) < lookDist) j++;
    const tp = pts[j];
    const prev = pts[Math.max(0, j - 1)];
    const tl = Math.hypot(tp.x - prev.x, tp.y - prev.y) || 1;
    v.avoidTimer -= dt;
    if (v.avoidTimer <= 0) v.avoidTarget = 0;
    v.avoid += clamp(v.avoidTarget - v.avoid, -18 * dt, 18 * dt);
    const tx = tp.x - ((tp.y - prev.y) / tl) * v.avoid;
    const ty = tp.y + ((tp.x - prev.x) / tl) * v.avoid;

    // How fast: cruise, slow for junctions, verges and the stop, and leave room ahead.
    const surface = this.city.surfaceAt(b.x, b.y);
    // Out of sight the city's drivers make good time.
    const unseen = !inView(b.x, b.y, world.view, 400);
    let want = v.sweeping ? 16 : v.spec.speed * (v.state === 'return' ? 0.7 : v.siren ? 1 : 0.85) * (unseen ? 1.25 : 1);
    for (let k = v.idx; k < Math.min(n, v.idx + 6); k++) if (pts[k].slow) want = Math.min(want, v.siren ? 42 : 30);
    if (surface !== SURF_ROAD) want = Math.min(want, 36);
    want = Math.min(want, Math.sqrt(2 * 80 * Math.max(0, remaining - 2)) + 3);
    v.ghost -= dt;
    const ahead = this.scanAhead(v, world);
    // Squeezing through a jam: slowly.
    if (v.ghost > 0) want = Math.min(want, v.siren ? 34 : 22);
    if (ahead.gap < Infinity) {
      // Never faster than we can stop in the space left.
      want = Math.min(want, Math.sqrt(2 * 110 * Math.max(0, ahead.gap - 4)));
      if (ahead.blocking && ahead.gap < 34 && v.avoidTimer <= 0) this.findWayRound(v, ahead.lat, ahead.halfW, ahead.gap, world, v.wait > 4);
      // Held up by something that won't budge: go round it wide, then squeeze through.
      if (want < 3 && Math.abs(fwd) < 2) v.wait += dt;
      // Held up just short of the scene: park here and walk the rest.
      if (v.state === 'drive' && !v.sweeping && v.wait > 2 && (remaining < 45 || Math.hypot(v.target.x - b.x, v.target.y - b.y) < 70)) {
        this.arrive(v);
        return;
      }
      // Leaving a busy scene squeezes out quickly; arriving waits a little longer first.
      if (v.wait > (v.state === 'return' ? 1.5 : v.siren ? 2.5 : 5)) {
        v.wait = 0;
        if (unseen) this.hop(v);
        else v.ghost = 3;
      }
    } else {
      v.wait = Math.max(0, v.wait - dt * 2);
    }

    // Stuck against something: back up, turn the other way and try again.
    if (v.reverse > 0) {
      v.reverse -= dt;
      want = -16;
    } else if (want > 8 && Math.abs(fwd) < 2.5) {
      v.stuck += dt;
      if (v.stuck > 2.2) {
        v.stuck = 0;
        v.stuckCount++;
        v.reverse = 1.3;
        v.avoidTarget = (Math.random() < 0.5 ? -1 : 1) * (8 + Math.random() * 8);
        v.avoidTimer = 3;
        if (v.stuckCount >= 4 && !inView(b.x, b.y, world.view, 40)) this.hop(v);
        if (v.stuckCount >= 9) this.abort(v);
      }
    } else if (Math.abs(fwd) > 6) {
      v.stuck = Math.max(0, v.stuck - dt);
    }

    // Pure-pursuit steering.
    const alpha = angleDiff(Math.atan2(ty - b.y, tx - b.x), b.angle);
    const wheelbase = b.halfL * 1.2;
    let steerWant = Math.abs(alpha) > Math.PI / 2 ? Math.sign(alpha) * 0.65 : Math.atan2(2 * wheelbase * Math.sin(alpha), Math.max(lookDist, 6));
    if (v.reverse > 0) steerWant = -steerWant;
    steerWant = clamp(steerWant, -0.65, 0.65);
    v.steer += clamp(steerWant - v.steer, -3.5 * dt, 3.5 * dt);
    v.braking = want < fwd - 8;

    const steps = 2;
    const h = dt / steps;
    const mu = surfaceGrip(surface) * 1.35;
    const accel = v.spec.mass > 5 ? 42 : 62;
    for (let i = 0; i < steps; i++) {
      const c = Math.cos(b.angle);
      const s = Math.sin(b.angle);
      const f = b.vx * c + b.vy * s;
      const acc = clamp((want - f) * 3, -150, accel);
      b.vx += c * acc * h;
      b.vy += s * acc * h;
      const nrm = (b.mass * GRAVITY) / 2;
      const ax = b.halfL * 0.6;
      axleGrip(b, b.x + c * ax, b.y + s * ax, b.angle + v.steer, mu * nrm * h);
      axleGrip(b, b.x - c * ax, b.y - s * ax, b.angle, mu * nrm * h);
      b.av *= Math.exp(-1.5 * h);
      integrate(b, h);
      this.collideWorld(v);
    }
    if (surface !== SURF_ROAD && Math.abs(fwd) > 15 && Math.random() < dt * 8) this.effects.dust(b.x - Math.cos(b.angle) * b.halfL, b.y - Math.sin(b.angle) * b.halfL, 0.6);
  }

  /** The nearest thing in our path: how far, where, and whether we should go round it. */
  private scanAhead(v: ServiceVehicle, world: ServiceWorld): { gap: number; lat: number; halfW: number; blocking: boolean } {
    const b = v.body;
    const c = Math.cos(b.angle);
    const s = Math.sin(b.angle);
    const fwd = Math.max(0, forwardSpeed(b));
    const reach = b.halfL + 14 + fwd * 0.9;
    let best = { gap: Infinity, lat: 0, halfW: 0, blocking: false };
    const consider = (x: number, y: number, angle: number, halfL: number, halfW: number, speed: number, parked: boolean) => {
      const dx = x - b.x;
      const dy = y - b.y;
      const along = dx * c + dy * s;
      // Its footprint in our frame (a wreck lying sideways is wide).
      const rel = angle - b.angle;
      const extAlong = Math.abs(halfL * Math.cos(rel)) + Math.abs(halfW * Math.sin(rel));
      const extSide = Math.abs(halfL * Math.sin(rel)) + Math.abs(halfW * Math.cos(rel));
      if (along + extAlong <= 0 || along > reach + extAlong) return;
      const lat = -dx * s + dy * c;
      if (Math.abs(lat) > b.halfW + extSide + 1.5) return;
      const gap = along - b.halfL - extAlong;
      halfW = extSide;
      if (gap >= best.gap) return;
      // Sirens go round anything slow; everyone else only round wrecks and parked units.
      const blocking = parked || (v.siren && speed < Math.max(12, fwd * 0.5));
      best = { gap, lat, halfW, blocking };
    };
    for (const car of v.ghost > 0 || v.express ? [] : world.traffic.all()) {
      if (car.layer !== v.layer || Math.abs(car.x - b.x) > reach + 20 || Math.abs(car.y - b.y) > reach + 20) continue;
      consider(car.x, car.y, car.angle, car.body.halfL, car.body.halfW, Math.abs(car.speed), car.wrecked || car.broken);
    }
    for (const o of v.ghost > 0 || v.express ? [] : this.vehicles) {
      if (o === v || o.layer !== v.layer || Math.abs(o.x - b.x) > reach + 30 || Math.abs(o.y - b.y) > reach + 30) continue;
      // Units already overlapping (just leaving the same station) sort themselves out.
      if (boxContact(b, o.body)) continue;
      consider(o.x, o.y, o.body.angle, o.body.halfL, o.body.halfW, Math.abs(o.speed), o.state === 'work');
    }
    const t = world.truck;
    if (t.layer === v.layer && v.ghost <= 0) {
      for (const body of [t.cab, t.trailer]) {
        if (Math.abs(body.x - b.x) > reach + 30 || Math.abs(body.y - b.y) > reach + 30) continue;
        consider(body.x, body.y, body.angle, body.halfL, body.halfW, Math.abs(t.speed), Math.abs(t.speed) < 3);
      }
    }
    if (v.layer === 0 && v.ghost <= 0) for (const p of world.obstacles) consider(p.x, p.y, 0, 1.5, 2, 0, false);
    return best;
  }

  /** Pick a side to pass on, even across the verge, if there's room. */
  private findWayRound(v: ServiceVehicle, lat: number, halfW: number, gap: number, world: ServiceWorld, wide = false): void {
    const b = v.body;
    const c = Math.cos(b.angle);
    const s = Math.sin(b.angle);
    const clear = halfW + b.halfW + 2.5;
    const options = [lat - clear, lat + clear, lat - clear - 6, lat + clear + 6, ...(wide ? [lat - clear - 12, lat + clear + 12] : [])].sort((p, q) => Math.abs(p) - Math.abs(q));
    for (const off of options) {
      const total = v.avoid + off;
      if (Math.abs(total) > (wide ? 30 : 24)) continue;
      let ok = true;
      for (const along of [gap * 0.5 + b.halfL, gap + b.halfL + 6, gap + b.halfL + 18]) {
        const px = b.x + c * along - s * off;
        const py = b.y + s * along + c * off;
        const push = this.city.resolveCircle(px, py, b.halfW, v.layer);
        if (push.x || push.y) ok = false;
        for (const car of world.traffic.all()) {
          if (wide && car.mode === 'drive') continue;
          if (car.layer === v.layer && Math.abs(car.x - px) < car.body.halfL + b.halfW && Math.abs(car.y - py) < car.body.halfL + b.halfW && Math.hypot(car.x - px, car.y - py) < car.body.halfW + b.halfW + 1) ok = false;
        }
        for (const o of this.vehicles) {
          if (o !== v && o.layer === v.layer && Math.hypot(o.x - px, o.y - py) < o.body.halfL + b.halfW + 2) ok = false;
        }
        if (!ok) break;
      }
      // Don't swing out into oncoming traffic.
      for (const car of world.traffic.cars) {
        if (!ok || car.layer !== v.layer || car.speed < 5) continue;
        const dx = car.x - b.x;
        const dy = car.y - b.y;
        const along = dx * c + dy * s;
        const side = -dx * s + dy * c;
        if (along > 0 && along < 140 && Math.abs(side - off) < b.halfW + car.body.halfW + 3 && Math.cos(car.angle - b.angle) < -0.3) ok = false;
      }
      if (!ok) continue;
      v.avoidTarget = total;
      v.avoidTimer = 2 + gap / 25;
      return;
    }
  }

  /** Hopelessly stuck out of sight: skip ahead along the route. */
  private hop(v: ServiceVehicle): void {
    const k = Math.min(v.route.length - 1, v.idx + 6);
    const p = v.route[k];
    const q = v.route[Math.min(v.route.length - 1, k + 1)];
    v.body.x = p.x;
    v.body.y = p.y;
    if (q !== p) v.body.angle = Math.atan2(q.y - p.y, q.x - p.x);
    v.body.vx = v.body.vy = v.body.av = 0;
    v.idx = k;
    v.layer = p.layer;
    v.avoid = v.avoidTarget = 0;
  }

  private collideWorld(v: ServiceVehicle): void {
    const b = v.body;
    for (const d of [b.halfL - b.halfW * 0.55, 0, -(b.halfL - b.halfW * 0.55)]) {
      const px = b.x + Math.cos(b.angle) * d;
      const py = b.y + Math.sin(b.angle) * d;
      const push = this.city.resolveCircle(px, py, b.halfW, v.layer);
      if (!push.x && !push.y) continue;
      const material = this.city.lastMaterial;
      const l = Math.hypot(push.x, push.y);
      const cx = px - (push.x / l) * b.halfW;
      const cy = py - (push.y / l) * b.halfW;
      const impact = resolveStatic(b, push, cx, cy, 0.2, 0.4);
      if (impact > 22) {
        this.effects.crash(cx, cy, impact / 140, [v.look.color, '#1b1b1f'], { x: push.x / l, y: push.y / l }, { material, glass: impact > 60 });
        if ((material === 'concrete' || material === 'wood') && impact > 30) this.effects.structureHit(cx, cy, impact, push.x / l, push.y / l, material);
        this.damage(v, impact * 0.7, cx, cy, -push.x / l, -push.y / l, material);
      }
    }
  }

  /** Units bump into traffic, wrecks, each other and the player's rig. */
  private collide(world: ServiceWorld): void {
    const { traffic, truck } = world;
    for (let i = 0; i < this.vehicles.length; i++) {
      const v = this.vehicles[i];
      if (!v.seen || v.express) continue;
      for (const car of v.ghost > 0 ? [] : traffic.all()) {
        if (car.ghost > 0 || car.layer !== v.layer || car.dmg.z > 8 || Math.abs(car.x - v.x) > 30 || Math.abs(car.y - v.y) > 30) continue;
        const c = boxContact(v.body, car.body);
        if (!c) continue;
        if (car.mode === 'drive') {
          // Everyday nudges between a unit and a driver: they stop, nobody's wrecked.
          // Units and drivers only ever nudge each other: they stop, nobody's wrecked.
          traffic.bump(car, c.depth);
          const push = Math.max(0, c.depth - 0.05);
          v.body.x -= c.nx * push;
          v.body.y -= c.ny * push;
          const vn = v.body.vx * c.nx + v.body.vy * c.ny;
          if (vn > 0) {
            v.body.vx -= c.nx * vn;
            v.body.vy -= c.ny * vn;
          }
          continue;
        }
        const hit = resolveContact(v.body, car.body, c, 0.15, 0.4);
        if (hit.closing < 8) continue;
        traffic.applyHit(car, hit.closing, c.px, c.py, -c.nx, -c.ny, { material: 'car', color: v.look.color }, 'pileup');
        this.damage(v, hit.closing * Math.min(1.5, car.body.mass / v.body.mass), c.px, c.py, c.nx, c.ny, car.wrecked ? 'wreck' : 'car');
      }
      for (let k = i + 1; k < this.vehicles.length; k++) {
        const o = this.vehicles[k];
        if (!o.seen || o.express || v.ghost > 0 || o.ghost > 0 || o.layer !== v.layer || Math.abs(o.x - v.x) > 34 || Math.abs(o.y - v.y) > 34) continue;
        const c = boxContact(v.body, o.body);
        if (!c) continue;
        const hit = resolveContact(v.body, o.body, c, 0.15, 0.4);
        // Fleet drivers only trade paint with each other in a real smash.
        if (hit.closing < 45) continue;
        this.damage(v, hit.closing, c.px, c.py, c.nx, c.ny, 'unit');
        this.damage(o, hit.closing, c.px, c.py, -c.nx, -c.ny, 'unit');
      }
      if (v.ghost > 0 || truck.layer !== v.layer || Math.abs(truck.x - v.x) > 70 || Math.abs(truck.y - v.y) > 70) continue;
      for (const body of [truck.cab, truck.trailer]) {
        const c = boxContact(body, v.body);
        if (!c) continue;
        const hit = resolveContact(body, v.body, c, 0.12, 0.45);
        if (hit.closing < 6) continue;
        this.onTruckHit?.(v, hit.closing, c.px, c.py);
        this.damage(v, hit.closing * 1.2, c.px, c.py, -c.nx, -c.ny, 'truck');
      }
    }
    for (let i = this.vehicles.length - 1; i >= 0; i--) {
      const v = this.vehicles[i];
      if (v.health > 0) continue;
      // Written off: the wreck joins the others on the road and the unit is lost for a while.
      this.abort(v);
      this.vehicles.splice(i, 1);
      if (v.depot) v.depot.lost.push(REPLACE_SECONDS);
      this.onWrecked?.(v, 0, v.x, v.y, 0, 0);
    }
  }

  /** An explosion: units nearby are thrown and battered (a direct hit writes one off). */
  blast(x: number, y: number, r: number, layer: number): void {
    for (const v of this.vehicles) {
      if (v.layer !== layer) continue;
      const dx = v.x - x;
      const dy = v.y - y;
      const d = Math.hypot(dx, dy);
      if (d > r) continue;
      const nx = d > 0.1 ? dx / d : 1;
      const ny = d > 0.1 ? dy / d : 0;
      const power = (1 - d / r) * 160 + 20;
      v.body.vx += nx * power * (3 / v.body.mass);
      v.body.vy += ny * power * (3 / v.body.mass);
      this.damage(v, power, v.x - nx * v.body.halfW, v.y - ny * v.body.halfW, -nx, -ny, 'blast');
    }
  }

  /** (nx, ny) points from the unit toward whatever hit it. */
  private damage(v: ServiceVehicle, closing: number, px: number, py: number, nx: number, ny: number, what: string): void {
    if (closing < 18) return;
    v.lastHit = `${what}:${Math.round(closing)}`;
    const b = v.body;
    const lx = (px - b.x) * Math.cos(b.angle) + (py - b.y) * Math.sin(b.angle);
    const ly = -(px - b.x) * Math.sin(b.angle) + (py - b.y) * Math.cos(b.angle);
    const zone = Math.abs(lx) / b.halfL > Math.abs(ly) / b.halfW ? (lx > 0 ? 0 : 1) : ly < 0 ? 2 : 3;
    v.dmg.crumple[zone] = Math.min(zone < 2 ? 5 : 3, v.dmg.crumple[zone] + closing * 0.03);
    if (closing > 45) v.dmg.glass = true;
    v.health -= (closing - 14) * 0.7 * (4 / (v.spec.mass + 2));
    this.effects.crash(px, py, closing / 120, [v.look.color, '#1b1b1f', '#c9ccd1'], { x: -nx, y: -ny }, { material: 'car', glass: closing > 45, flavor: v.look.model });
    // A really hard hit writes it off straight away.
    if (closing > 85 + v.spec.mass * 6) v.health = 0;
  }

  /** Parked at the scene: send the crew out (or start sweeping). */
  private arrive(v: ServiceVehicle): void {
    v.state = 'work';
    v.onScene = 0;
    v.body.vx *= 0.3;
    v.body.vy *= 0.3;
    const points = v.task.points();
    if (v.task.mode === 'drive') {
      v.sweep = points;
      return;
    }
    const side = { x: -Math.sin(v.body.angle), y: Math.cos(v.body.angle) };
    for (let i = 0; i < v.spec.crewSize; i++) {
      v.crew.push({
        x: v.x + side.x * (v.body.halfW + 2) - Math.cos(v.body.angle) * i * 4,
        y: v.y + side.y * (v.body.halfW + 2) - Math.sin(v.body.angle) * i * 4,
        angle: v.body.angle,
        stride: 0,
        look: { ...v.spec.crew },
        queue: points.filter((_, k) => k % v.spec.crewSize === i),
        timer: 0,
      });
    }
    // Police swing across the lane to close the road behind them.
    if (v.kind === 'police' && this.city.surfaceAt(v.x, v.y) === SURF_ROAD) v.parkAngle = v.body.angle + 0.9;
    if (v.kind === 'police' || v.kind === 'roadcrew' || v.kind === 'works') {
      // Cones round the scene (and round each hole for road crews).
      const centres = v.kind === 'police' ? [v.target] : points.slice(0, 4);
      const r = v.kind === 'police' ? 24 : 8;
      for (const c of centres) for (let a = 0; a < Math.PI * 2; a += Math.PI / 3) v.cones.push({ x: c.x + Math.cos(a) * r, y: c.y + Math.sin(a) * r });
    }
  }

  private work(v: ServiceVehicle, dt: number, world: ServiceWorld): void {
    v.onScene += dt;
    const b = v.body;
    if (v.task.mode === 'drive') {
      this.sweepDrive(v, dt, world);
      return;
    }
    if (v.parkAngle !== null) b.angle += clamp(angleDiff(v.parkAngle, b.angle), -1.6 * dt, 1.6 * dt);
    // Parked: the handbrake is on.
    const stop = Math.exp(-6 * dt);
    b.vx *= stop;
    b.vy *= stop;
    b.av *= stop;
    integrate(b, dt);
    this.collideWorld(v);
    let allHome = true;
    for (const c of v.crew) {
      const goal = c.queue[0] ?? { x: b.x - Math.sin(b.angle) * (b.halfW + 2), y: b.y + Math.cos(b.angle) * (b.halfW + 2) };
      const dx = goal.x - c.x;
      const dy = goal.y - c.y;
      const d = Math.hypot(dx, dy);
      const reach = v.kind === 'fire' ? 14 : 3;
      if (d > reach) {
        const step = Math.min(d - reach + 0.1, 22 * dt);
        c.x += (dx / d) * step;
        c.y += (dy / d) * step;
        c.angle = Math.atan2(dy, dx);
        c.stride += step;
        allHome = false;
        continue;
      }
      if (!c.queue.length) continue;
      allHome = false;
      c.angle = Math.atan2(dy, dx);
      c.timer += dt;
      v.task.onWorking?.(c, goal, dt);
      if (c.timer >= v.task.work) {
        c.timer = 0;
        v.task.onPointDone?.(goal, v);
        c.queue.shift();
      }
    }
    const standBy = v.task.until && !v.task.until();
    if ((allHome && !standBy) || v.onScene > (v.task.maxTime ?? 100)) {
      v.task.onDone?.();
      this.finish(v);
    }
  }

  /** Street sweepers drive slowly over each dirty spot. */
  private sweepDrive(v: ServiceVehicle, dt: number, world: ServiceWorld): void {
    const goal = v.sweep[0];
    if (!goal || v.onScene > (v.task.maxTime ?? 100)) {
      v.task.onDone?.();
      this.finish(v);
      return;
    }
    if (!v.route.length || v.route[v.route.length - 1].x !== goal.x || v.route[v.route.length - 1].y !== goal.y) {
      this.setRoute(v, [{ x: v.x, y: v.y, layer: v.layer, slow: true }, { x: goal.x, y: goal.y, layer: v.layer, slow: true }]);
    }
    v.state = 'drive';
    v.sweeping = true;
    this.drive(v, dt, world);
    v.sweeping = false;
    if (v.state !== 'drive') return;
    v.state = 'work';
    v.sweepTimer -= dt;
    if (v.sweepTimer <= 0) {
      v.sweepTimer = 0.25;
      v.task.onWorking?.(v, goal, dt);
    }
    if (Math.hypot(goal.x - v.x, goal.y - v.y) < 7 || v.stuckCount > 2) {
      v.task.onPointDone?.(goal, v);
      v.sweep.shift();
      v.stuckCount = 0;
    }
  }

  draw(ctx: CanvasRenderingContext2D, view: View, time: number, layer: number): void {
    for (const v of this.vehicles) {
      if (v.layer !== layer || !inView(v.x, v.y, view, 40)) continue;
      for (const cone of v.cones) {
        ctx.fillStyle = '#ff7a1a';
        ctx.beginPath();
        ctx.moveTo(cone.x, cone.y - 1.6);
        ctx.lineTo(cone.x + 1.3, cone.y + 1);
        ctx.lineTo(cone.x - 1.3, cone.y + 1);
        ctx.fill();
        ctx.fillStyle = '#ffffff';
        ctx.fillRect(cone.x - 0.7, cone.y - 0.3, 1.4, 0.5);
      }
      if (v.towing) {
        // The wreck rides behind on the hook, nose up.
        const back = v.body.halfL + v.towing.look.length / 2 - 2;
        drawCar(ctx, v.towing.look, v.x - Math.cos(v.body.angle) * back, v.y - Math.sin(v.body.angle) * back, v.body.angle, true, false, v.towing.dmg);
      }
      if (v.kind === 'sweeper' && v.state === 'work') v.look.seed = (time * 2) % 1;
      drawCar(ctx, v.look, v.x, v.y, v.body.angle, false, v.braking, v.dmg);
      for (const c of v.crew) drawPed(ctx, c.look, c.x, c.y, c.angle, c.stride, 'walk', 0, 0, time);
    }
  }

  /** Units parked at their stations. */
  drawDepots(ctx: CanvasRenderingContext2D, view: View): void {
    const rows = new Map<Station, number>();
    for (const d of this.depots) {
      const st = d.station;
      if (d.kind === 'mob' || st.structure?.destroyed || !inView(st.x, st.y, view, 60)) continue;
      const free = d.units - d.busy;
      const look = makeLook(SPECS[d.kind].model, SPECS[d.kind].color, SPECS[d.kind].accent, false);
      const c = Math.cos(st.angle);
      const sn = Math.sin(st.angle);
      let slot = rows.get(st) ?? 0;
      for (let i = 0; i < free; i++, slot++) {
        const u = -st.w / 2 + 6 + look.width / 2 + slot * 12;
        if (u > st.w / 2 - 4) break;
        const w = -st.d / 2 + look.length / 2 + 2;
        drawCar(ctx, look, st.x + c * u - sn * w, st.y + sn * u + c * w, st.angle - Math.PI / 2);
      }
      rows.set(st, slot);
    }
  }

  /** Lightbars and beacons for the night overlay. */
  lights(view: View): { x: number; y: number; color: string }[] {
    const out: { x: number; y: number; color: string }[] = [];
    for (const v of this.vehicles) {
      if (!v.dmg.hazard || !inView(v.x, v.y, view, 40)) continue;
      const color = !v.spec.siren ? 'rgba(255, 176, 0, 0.7)' : Math.floor(this.time * 8) % 2 ? 'rgba(255, 40, 40, 0.7)' : 'rgba(60, 90, 255, 0.7)';
      out.push({ x: v.x, y: v.y, color });
    }
    return out;
  }
}