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/dispatch.ts
import type { Wildlife } from './animals.js';
import type { Effects } from './effects.js';
import type { Vec } from './geometry.js';
import type { Helicopters } from './helicopters.js';
import type { Pedestrians } from './peds.js';
import type { ServiceKind, Services, ServiceTask, ServiceVehicle } from './services.js';
import type { Street } from './street.js';
import type { Traffic } from './traffic.js';
import type { Truck } from './truck.js';

/** Something smashed that a repair crew can rebuild (market stalls, party gear, bombed buildings). */
export interface Repairable {
  x: number;
  y: number;
  fix(): void;
}

export interface DispatchWorld {
  traffic: Traffic;
  peds: Pedestrians;
  effects: Effects;
  wildlife: Wildlife;
  street: Street;
  services: Services;
  helis: Helicopters;
  truck: Truck;
  brokenProps(): Repairable[];
  hour(): number;
  /** Buildings and trees on fire, and putting them out. */
  burning(): Vec[];
  douse(x: number, y: number, r: number): void;
  /** Inside the family's turf, where the police don't go. */
  inTurf(x: number, y: number): boolean;
  /** Crew sounds at a spot: hose, winch and wrench, or tools. */
  sound(kind: 'hose' | 'tow' | 'tools', x: number, y: number): void;
  /** Hydrant pressure at a spot (0 dry .. 1 full): crews on low pressure put fires out slower. */
  pressure(x: number, y: number): number;
  /** How well the city can coordinate (0..1): command and comms lost, dispatch slows down. */
  coordination(): number;
  /** Collapse sites with people trapped, and digging them out. */
  rescueSites(): { x: number; y: number; trapped: number; st: { w: number; d: number } }[];
  dig(site: { x: number; y: number; trapped: number }): void;
}

/** A piece of work for one unit. */
interface Job {
  x: number;
  y: number;
  layer: number;
  task: ServiceTask;
  /** Called when a unit takes it. */
  claim?: () => void;
}

/**
 * An accident scene, worked in stages: police and paramedics (and the fire brigade)
 * close the road and see to the injured, then tow trucks clear the wrecks, then a
 * road crew sweeps up the debris and fixes the road, and the road reopens.
 */
export interface Incident {
  x: number;
  y: number;
  r: number;
  layer: number;
  stage: 'respond' | 'tow' | 'clean' | 'done';
  born: number;
  stageAt: number;
}

interface Cluster<T> {
  x: number;
  y: number;
  layer: number;
  items: T[];
}

type CityKind = Exclude<ServiceKind, 'mytow' | 'mob'>;

const dist = (a: Vec, b: Vec) => Math.hypot(a.x - b.x, a.y - b.y);

/** Greedy clustering: each item joins the first cluster within r of its seed. */
function cluster<T extends Vec>(items: T[], r: number, layerOf: (t: T) => number = () => 0): Cluster<T>[] {
  const out: Cluster<T>[] = [];
  for (const it of items) {
    const layer = layerOf(it);
    const c = out.find((k) => k.layer === layer && Math.hypot(k.x - it.x, k.y - it.y) < r);
    if (c) c.items.push(it);
    else out.push({ x: it.x, y: it.y, layer, items: [it] });
  }
  for (const c of out) {
    c.x = c.items.reduce((s, i) => s + i.x, 0) / c.items.length;
    c.y = c.items.reduce((s, i) => s + i.y, 0) / c.items.length;
  }
  return out;
}

/** Visit points nearest-first from a start, so crews don't zig-zag. */
function tour(start: Vec, pts: Vec[]): Vec[] {
  const left = [...pts];
  const out: Vec[] = [];
  let at = start;
  while (left.length) {
    let bi = 0;
    for (let i = 1; i < left.length; i++) if (dist(left[i], at) < dist(left[bi], at)) bi = i;
    at = left.splice(bi, 1)[0];
    out.push(at);
  }
  return out;
}

/** How close another unit of the same kind must be for a job to count as covered. */
const COVER: Record<CityKind, number> = { fire: 75, ambulance: 60, police: 90, tow: 0, roadside: 0, sweeper: 90, works: 70, roadcrew: 70, garbage: 90, rescue: 90 };
// Life safety first: rescue and the injured come round twice as often as anything else.
const ORDER: CityKind[] = ['rescue', 'ambulance', 'fire', 'police', 'rescue', 'ambulance', 'fire', 'tow', 'roadside', 'works', 'roadcrew', 'sweeper', 'garbage'];

/**
 * The city's dispatch centre. Nobody calls it: it watches the whole map for fires,
 * casualties, wrecks, breakdowns, debris, damaged buildings and roads, roadkill,
 * knocked-over street furniture and full trash cans. Police go first and close the
 * road; free units are sent nearest the action first, and a unit that finishes a job
 * takes the next one nearby rather than driving back to base.
 */
export class Dispatcher {
  private timer = 0;
  private round = 0;
  private time = 0;
  private readonly claimed = new Set<object>();
  readonly incidents: Incident[] = [];
  private monitorTimer = 0;
  private readonly sweepQuiet = new Map<string, { n: number; since: number; seen: number }>();
  /** A scene moved on a stage (for HUD toasts near the player). */
  onStage: ((inc: Incident) => void) | null = null;
  private readonly retry = new Map<string, number>();
  /** A unit was sent out (for HUD toasts near the player). */
  onDispatch: ((kind: ServiceKind | 'medevac' | 'chopper', x: number, y: number) => void) | null = null;

  constructor(private readonly w: DispatchWorld) {
    w.services.onIdle = (v) => this.nextJob(v);
  }

  clear(): void {
    this.claimed.clear();
    this.retry.clear();
    this.incidents.length = 0;
    this.sweepQuiet.clear();
  }

  /** The scene a point belongs to, if any. */
  incidentAt(x: number, y: number, pad = 0): Incident | undefined {
    return this.incidents.find((i) => Math.hypot(i.x - x, i.y - y) < i.r + pad);
  }

  /** Watch the whole city: every wreck, casualty and fire belongs to a scene, and each scene moves through its stages. */
  private monitor(): void {
    const { traffic, peds, effects, services } = this.w;
    const trouble = [
      ...traffic.wrecks.map((w) => ({ x: w.x, y: w.y, layer: w.layer })),
      ...peds.injured().map((p) => ({ x: p.x, y: p.y, layer: 0 })),
      ...traffic.burningNear(0, 0, Infinity).map((p) => ({ ...p, layer: 0 })),
      ...effects.allFires().map((p) => ({ ...p, layer: 0 })),
      ...this.w.burning().map((p) => ({ ...p, layer: 0 })),
    ];
    for (const t of trouble) {
      const inc = this.incidents.find((i) => i.layer === t.layer && i.stage !== 'done' && Math.hypot(i.x - t.x, i.y - t.y) < i.r + 30);
      if (inc) {
        const d = Math.hypot(inc.x - t.x, inc.y - t.y);
        inc.r = Math.min(130, Math.max(inc.r, d + 20));
        // Fresh trouble at a scene that had moved on: back to the emergency stage.
        if ((inc.stage === 'clean' || inc.stage === 'tow') && d < inc.r) {
          const fresh = peds.injuredNear(t.x, t.y, 6).length || effects.firesNear(t.x, t.y, 6).length;
          if (fresh) this.setStage(inc, 'respond');
        }
      } else {
        this.incidents.push({ x: t.x, y: t.y, r: 60, layer: t.layer, stage: 'respond', born: this.time, stageAt: this.time });
      }
    }
    for (const inc of this.incidents) {
      const age = this.time - inc.stageAt;
      const near = (p: Vec) => dist(p, inc) < inc.r;
      const wrecks = traffic.wrecks.filter((w) => w.layer === inc.layer && near(w)).length;
      const injured = peds.injured().filter(near).length;
      const fires = traffic.burningNear(inc.x, inc.y, inc.r).length + effects.firesNear(inc.x, inc.y, inc.r).length;
      if (inc.stage === 'respond') {
        const secured = this.w.inTurf(inc.x, inc.y) || services.vehicles.some((v) => v.kind === 'police' && v.state === 'work' && dist(v.target, inc) < inc.r + 40) || age > 45;
        if (!injured && !fires && secured) this.setStage(inc, wrecks ? 'tow' : 'clean');
      } else if (inc.stage === 'tow') {
        if (!wrecks) this.setStage(inc, 'clean');
      } else if (inc.stage === 'clean') {
        if (wrecks) { this.setStage(inc, 'tow'); continue; }
        const mess = effects.messSpots().filter((m) => dist(m, inc) < inc.r + 30).length + effects.roadDamage().filter((h) => dist(h, inc) < inc.r + 20).length;
        if (!mess || age > 150) this.setStage(inc, 'done');
      }
    }
    for (let i = this.incidents.length - 1; i >= 0; i--) if (this.incidents[i].stage === 'done') this.incidents.splice(i, 1);
  }

  private setStage(inc: Incident, stage: Incident['stage']): void {
    inc.stage = stage;
    inc.stageAt = this.time;
    this.onStage?.(inc);
  }

  /** A huge pile-up or explosion: the police helicopter comes to take a look. */
  major(x: number, y: number): void {
    const { helis } = this.w;
    if (helis.covering('police', x, y, 250) || !helis.dispatch('police', x, y, null)) return;
    this.onDispatch?.('chopper', x, y);
  }

  update(dt: number): void {
    this.time += dt;
    this.monitorTimer -= dt;
    if (this.monitorTimer <= 0) {
      this.monitorTimer = 0.8;
      this.monitor();
    }
    this.timer -= dt;
    if (this.timer > 0) return;
    // With City Hall and the masts gone, calls go unanswered for longer.
    this.timer = 0.2 / Math.max(0.3, this.w.coordination());
    // One kind of job per tick keeps the scans cheap.
    const kind = ORDER[this.round++ % ORDER.length];
    if (kind === 'ambulance') this.medevac();
    const { services } = this.w;
    for (const job of this.jobs(kind)) {
      // No roadside van free: a tow truck can fix a breakdown too.
      const unit: ServiceKind = kind === 'roadside' && services.available('roadside') <= 0 ? 'tow' : kind;
      if (services.available(unit) <= 0) return;
      this.send(unit, job);
    }
  }

  /** A unit just finished: hand it the nearest open job of its kind, if there's one close by. */
  private nextJob(v: ServiceVehicle): boolean {
    if (v.kind === 'mytow' || v.kind === 'mob') return false;
    const jobs = this.jobs(v.kind)
      .filter((j) => dist(j, v) < 900)
      .sort((a, b) => dist(a, v) - dist(b, v));
    for (const job of jobs.slice(0, 3)) {
      if (!this.w.services.reassign(v, job.x, job.y, job.layer, job.task)) continue;
      job.claim?.();
      this.onDispatch?.(v.kind, job.x, job.y);
      return true;
    }
    return false;
  }

  private jobs(kind: CityKind): Job[] {
    const list =
      kind === 'rescue'
        ? this.rescues()
        : kind === 'fire'
        ? this.fires()
        : kind === 'ambulance'
          ? this.casualties()
          : kind === 'police'
            ? this.police()
            : kind === 'tow'
              ? this.tows()
              : kind === 'roadside'
                ? this.breakdowns()
                : kind === 'sweeper'
                  ? this.sweep()
                  : kind === 'works'
                    ? this.repairs()
                    : kind === 'roadcrew'
                      ? this.roads()
                      : this.garbage();
    const r = COVER[kind];
    const open = r ? list.filter((j) => !this.w.services.covering(kind, j.x, j.y, r)) : list;
    const t = this.w.truck;
    return open.sort((a, b) => Math.hypot(a.x - t.x, a.y - t.y) - Math.hypot(b.x - t.x, b.y - t.y));
  }

  /** Send a unit, remembering spots no unit could reach so we don't keep trying. */
  private send(kind: ServiceKind, job: Job): boolean {
    const key = `${kind}:${Math.round(job.x / 60)}:${Math.round(job.y / 60)}`;
    if ((this.retry.get(key) ?? 0) > this.time) return false;
    const result = this.w.services.dispatch(kind, job.x, job.y, job.task, false, job.layer);
    if (result === 'unreachable') this.retry.set(key, this.time + 40);
    if (result !== 'ok') return false;
    job.claim?.();
    this.onDispatch?.(kind, job.x, job.y);
    return true;
  }

  private fires(): Job[] {
    const { traffic, effects } = this.w;
    const burning = [
      ...traffic.wrecks.filter((c) => c.fire > 0 || c.fuse > 0).map((c) => ({ x: c.x, y: c.y, layer: c.layer })),
      ...effects.allFires().map((p) => ({ ...p, layer: 0 })),
      ...this.w.burning().map((p) => ({ ...p, layer: 0 })),
    ];
    return cluster(burning, 45, (b) => b.layer).map((c) => {
      const at = { x: c.x, y: c.y };
      return {
        ...c,
        task: {
          points: () => {
            const pts = [...traffic.burningNear(at.x, at.y, 95), ...effects.firesNear(at.x, at.y, 95), ...this.w.burning().filter((p) => dist(p, at) < 95)].slice(0, 6);
            return pts.length ? pts : [at];
          },
          // On low hydrant pressure the hoses are weak and it takes far longer.
          work: 2.5 / Math.max(0.2, this.w.pressure(at.x, at.y)),
          onWorking: (crew, point) => {
            effects.water(crew.x, crew.y, point.x, point.y);
            effects.water(crew.x, crew.y, point.x, point.y);
            if (Math.random() < 0.02) this.w.sound('hose', point.x, point.y);
          },
          onPointDone: (pt) => {
            effects.extinguish(pt.x, pt.y, 26);
            traffic.extinguish(pt.x, pt.y, 28);
            this.w.douse(pt.x, pt.y, 26);
          },
        },
      };
    });
  }

  /** Search and rescue: dig out the people trapped in collapsed buildings. */
  private rescues(): Job[] {
    return this.w.rescueSites().map((site) => ({
      x: site.x,
      y: site.y,
      layer: 0,
      task: {
        points: () => {
          if (site.trapped <= 0) return [];
          const r = Math.max(site.st.w, site.st.d) * 0.3;
          return Array.from({ length: Math.min(4, site.trapped) }, (_, i) => ({ x: site.x + Math.cos(i * 2.1) * r, y: site.y + Math.sin(i * 2.1) * r }));
        },
        work: 5,
        maxTime: 90,
        // Park in the street and go in on foot: the rubble's no place for the truck.
        offroad: false,
        onWorking: (crew, point) => {
          if (Math.random() < 0.08) this.w.effects.dust(point.x, point.y, 1);
          if (Math.random() < 0.02) this.w.sound('tools', point.x, point.y);
          void crew;
        },
        onPointDone: () => this.w.dig(site),
      },
    }));
  }

  private ambulanceTask(at: Vec): ServiceTask {
    const { peds } = this.w;
    return {
      points: () => {
        const now = peds.injuredNear(at.x, at.y, 75).slice(0, 4);
        return now.length ? now.map((p) => ({ x: p.x, y: p.y })) : [at];
      },
      work: 2.5,
      // Load the injured and take them to hospital.
      onPointDone: (pt, unit) => {
        for (const p of peds.injuredNear(pt.x, pt.y, 8)) if (peds.evacuate(p) && unit) unit.patients++;
      },
    };
  }

  private casualties(): Job[] {
    return cluster(this.w.peds.injured(), 50).map((c) => ({ ...c, task: this.ambulanceTask({ x: c.x, y: c.y }) }));
  }

  /** The badly hurt (or anyone, when every ambulance is out) get the medevac helicopter. */
  private medevac(): void {
    const { peds, services, helis } = this.w;
    for (const c of cluster(peds.injured(), 50)) {
      if (helis.available('medevac') <= 0) return;
      if (helis.covering('medevac', c.x, c.y, 60)) continue;
      const severe = c.items.length >= 3 || c.items.some((p) => p.crushed);
      if (!severe && (services.available('ambulance') > 0 || services.covering('ambulance', c.x, c.y, 60))) continue;
      if (helis.dispatch('medevac', c.x, c.y, this.ambulanceTask({ x: c.x, y: c.y }))) this.onDispatch?.('medevac', c.x, c.y);
    }
  }

  /** Police first to every scene (outside the family's turf): they close the road until it's cleared and reopened. */
  private police(): Job[] {
    return this.incidents
      .filter((inc) => inc.stage !== 'done' && !this.w.inTurf(inc.x, inc.y))
      .map((inc) => ({
        x: inc.x,
        y: inc.y,
        layer: inc.layer,
        task: {
          points: () => [
            { x: inc.x + 10, y: inc.y },
            { x: inc.x - 8, y: inc.y + 7 },
          ],
          work: 1.2,
          // Hold the road closed until the scene's cleared, then reopen it.
          until: () => !this.incidents.includes(inc),
          maxTime: 300,
        },
      }));
  }

  private tows(): Job[] {
    const { traffic, effects } = this.w;
    // Tow trucks are called once the scene is secure and the injured are seen to.
    const waiting = traffic.wrecks.filter((w) => {
      if (this.claimed.has(w) || w.fire > 0 || w.fuse > 0 || w.dmg.z > 0 || Math.hypot(w.body.vx, w.body.vy) >= 3) return false;
      const inc = this.incidentAt(w.x, w.y);
      return !!inc && inc.stage === 'tow';
    });
    return waiting.map((wreck) => {
      const release = () => {
        this.claimed.delete(wreck);
        wreck.towClaim = false;
      };
      return {
        x: wreck.x,
        y: wreck.y,
        layer: wreck.layer,
        claim: () => {
          this.claimed.add(wreck);
          wreck.towClaim = true;
        },
        task: {
          points: () => (traffic.wrecks.includes(wreck) ? [{ x: wreck.x, y: wreck.y }] : []),
          work: 2,
          onWorking: (crew, point) => {
            if (Math.random() < 0.2) effects.scrape(point.x, point.y, crew.x - point.x, crew.y - point.y);
            if (Math.random() < 0.02) this.w.sound('tow', point.x, point.y);
          },
          onPointDone: (_pt, unit) => {
            if (!traffic.wrecks.includes(wreck)) return;
            traffic.removeWreck(wreck);
            wreck.dmg.z = 0;
            wreck.dmg.roll = 0;
            wreck.dmg.flipped = false;
            unit.towing = { look: wreck.look, dmg: wreck.dmg };
            effects.cleanup(wreck.x, wreck.y, 30);
            this.w.sound('tow', wreck.x, wreck.y);
          },
          onDone: release,
          onAbort: release,
        },
      };
    });
  }

  private breakdowns(): Job[] {
    const { traffic } = this.w;
    return traffic
      .brokenDown()
      .filter((c) => !this.claimed.has(c))
      .map((car) => {
        const release = () => this.claimed.delete(car);
        return {
          x: car.x,
          y: car.y,
          layer: car.layer,
          claim: () => this.claimed.add(car),
          task: {
            points: () => (car.broken ? [{ x: car.x, y: car.y }] : []),
            work: 3,
            offroad: false,
            onWorking: (_crew, point) => {
              if (Math.random() < 0.02) this.w.sound('tools', point.x, point.y);
            },
            onPointDone: () => traffic.repair(car),
            onDone: release,
            onAbort: release,
          },
        };
      });
  }

  /** Cleanup belongs after rescue, firefighting and towing, including scene margins. */
  private cleanupSafe(x: number, y: number, layer = 0, pad = 60): boolean {
    const at = { x, y };
    if (this.incidents.some((i) => i.layer === layer && i.stage !== 'done' && i.stage !== 'clean' && dist(i, at) < i.r + pad)) return false;
    if (this.w.traffic.wrecks.some((w) => w.layer === layer && dist(w, at) < pad)) return false;
    if (this.w.traffic.all().some((c) => c.layer === layer && (c.fire > 0 || c.fuse > 0) && dist(c, at) < pad)) return false;
    if (layer === 0 && (this.w.peds.injuredNear(x, y, pad).length || this.w.effects.firesNear(x, y, pad).length || this.w.burning().some((p) => dist(p, at) < pad) || this.w.rescueSites().some((p) => p.trapped > 0 && dist(p, at) < pad))) return false;
    return true;
  }

  private sweep(): Job[] {
    const { effects } = this.w;
    // Even ungrouped debris must settle before a sweeper enters. New debris resets
    // the quiet period; removing debris during cleanup does not delay the crew.
    const ready = effects.messSpots().filter((spot) => {
      const key = `${Math.floor(spot.x / 40)},${Math.floor(spot.y / 40)}`;
      let quiet = this.sweepQuiet.get(key);
      if (!quiet || spot.n > quiet.n) quiet = { n: spot.n, since: this.time, seen: this.time };
      quiet.n = spot.n;
      quiet.seen = this.time;
      this.sweepQuiet.set(key, quiet);
      return this.time - quiet.since >= 12 && this.cleanupSafe(spot.x, spot.y);
    });
    for (const [key, quiet] of this.sweepQuiet) if (this.time - quiet.seen > 30) this.sweepQuiet.delete(key);
    const settled = (spot: { x: number; y: number; n: number }) => {
      const quiet = this.sweepQuiet.get(`${Math.floor(spot.x / 40)},${Math.floor(spot.y / 40)}`);
      return !!quiet && spot.n <= quiet.n && this.time - quiet.since >= 12;
    };
    // Prefer the road crew for managed scenes; sweepers remain a final-stage backup.
    const eligible = (spot: Vec) => {
      if (!this.cleanupSafe(spot.x, spot.y)) return false;
      const scenes = this.incidents.filter((i) => i.layer === 0 && dist(i, spot) < i.r + 60 && i.stage !== 'done');
      return !scenes.length || (this.w.services.available('roadcrew') <= 0 && scenes.every((i) => i.stage === 'clean' && !this.w.services.covering('roadcrew', i.x, i.y, i.r + 30)));
    };
    return cluster(ready.filter(eligible), 80).filter((c) => this.cleanupSafe(c.x, c.y, 0, 90)).map((c) => {
      const at = { x: c.x, y: c.y };
      return {
        ...c,
        task: {
          valid: () => this.cleanupSafe(at.x, at.y, 0, 90) && !effects.messSpots().some((spot) => dist(spot, at) < 90 && !settled(spot)),
          mode: 'drive',
          points: () => tour(at, effects.messSpots().filter((s) => dist(s, at) < 90 && settled(s) && eligible(s)).slice(0, 8)),
          work: 0,
          maxTime: 90,
          onWorking: (v: Vec) => { if (this.cleanupSafe(v.x, v.y)) effects.cleanup(v.x, v.y, 14); },
          onPointDone: (pt: Vec) => { if (this.cleanupSafe(pt.x, pt.y)) effects.cleanup(pt.x, pt.y, 24); },
        },
      };
    });
  }

  /** Builders fix cracked walls, lamp posts, hydrants, smashed stalls and bombed-out buildings. */
  private repairs(): Job[] {
    const { effects, street } = this.w;
    const all = () => [...effects.structureDamage(), ...street.broken().filter((f) => f.kind !== 'bin'), ...this.w.brokenProps()].filter((p) => this.cleanupSafe(p.x, p.y));
    return cluster(all(), 60).filter((c) => this.cleanupSafe(c.x, c.y, 0, 75)).map((c) => {
      const at = { x: c.x, y: c.y };
      return {
        ...c,
        task: {
          valid: () => this.cleanupSafe(at.x, at.y, 0, 75),
          points: () => tour(at, all().filter((p) => dist(p, at) < 75).slice(0, 6).map((p) => ({ x: p.x, y: p.y }))),
          work: 4,
          maxTime: 110,
          onWorking: (crew, point) => {
            if (Math.random() < 0.15) effects.scrape(point.x, point.y, crew.x - point.x, crew.y - point.y);
            if (Math.random() < 0.015) this.w.sound('tools', point.x, point.y);
          },
          onPointDone: (pt) => {
            if (!this.cleanupSafe(pt.x, pt.y)) return;
            effects.repairStructure(pt.x, pt.y, 8);
            for (const f of street.broken()) if (f.kind !== 'bin' && dist(f, pt) < 4) street.fix(f);
            for (const p of this.w.brokenProps()) if (dist(p, pt) < 6) p.fix();
          },
        },
      };
    });
  }

  /**
   * Road crews: the last stage of every scene (sweep up the debris, glass and blood,
   * fill the holes, reopen the road), plus stray holes and scorch marks elsewhere.
   */
  private roads(): Job[] {
    const { effects } = this.w;
    const scenes: Job[] = this.incidents
      .filter((inc) => inc.stage === 'clean' && this.cleanupSafe(inc.x, inc.y, inc.layer, inc.r + 30))
      .map((inc) => {
        const at = { x: inc.x, y: inc.y };
        const r = inc.r + 30;
        return {
          x: inc.x,
          y: inc.y,
          layer: inc.layer,
          task: {
            valid: () => inc.stage === 'clean' && this.cleanupSafe(inc.x, inc.y, inc.layer, r),
            points: () => tour(at, [...effects.messSpots().filter((m) => dist(m, at) < r && this.cleanupSafe(m.x, m.y, inc.layer)), ...effects.roadDamage().filter((h) => dist(h, at) < r && this.cleanupSafe(h.x, h.y, inc.layer))].slice(0, 8)),
            work: 2,
            maxTime: 120,
            onWorking: (crew, point) => {
              if (!this.cleanupSafe(crew.x, crew.y, inc.layer)) return;
              effects.cleanup(crew.x, crew.y, 8);
              if (Math.random() < 0.06) effects.dust(point.x, point.y, 0.6);
              if (Math.random() < 0.015) this.w.sound('tools', point.x, point.y);
            },
            onPointDone: (pt) => {
              if (!this.cleanupSafe(pt.x, pt.y, inc.layer)) return;
              effects.cleanup(pt.x, pt.y, 26);
              effects.patchRoad(pt.x, pt.y, 8);
            },
          },
        };
      });
    const holes = effects.roadDamage().filter((h) => !this.incidentAt(h.x, h.y, 30) && this.cleanupSafe(h.x, h.y));
    const stray: Job[] = cluster(holes, 60).filter((c) => this.cleanupSafe(c.x, c.y, 0, 75)).map((c) => {
      const at = { x: c.x, y: c.y };
      return {
        ...c,
        task: {
          valid: () => this.cleanupSafe(at.x, at.y, 0, 75),
          points: () => tour(at, effects.roadDamage().filter((h) => dist(h, at) < 75 && this.cleanupSafe(h.x, h.y)).slice(0, 6)),
          work: 4,
          maxTime: 120,
          onWorking: (_crew, point) => {
            if (Math.random() < 0.08) effects.steam(point.x, point.y, 0.6);
            if (Math.random() < 0.015) this.w.sound('tools', point.x, point.y);
          },
          onPointDone: (pt) => { if (this.cleanupSafe(pt.x, pt.y)) effects.patchRoad(pt.x, pt.y, 7); },
        },
      };
    });
    return [...scenes, ...stray];
  }

  /** Garbage trucks: knocked-over cans, roadkill, and the morning round of full bins. */
  private garbage(): Job[] {
    const { street, wildlife, effects } = this.w;
    const hour = this.w.hour();
    const round = hour >= 5 && hour < 14;
    // Trash collection resumes after the scene reopens, leaving room for cleanup
    // and repair crews as well as first responders. Protect the whole pickup area.
    const safe = (p: Vec, pad = 60) => this.cleanupSafe(p.x, p.y, 0, pad) && !this.incidents.some((i) => i.layer === 0 && i.stage !== 'done' && dist(i, p) < i.r + pad);
    // The morning round works outward from wherever the action is.
    const jobs: Vec[] = [...street.broken().filter((f) => f.kind === 'bin'), ...wildlife.carcasses, ...(round ? street.fullBins().filter((f) => dist(f, this.w.truck) < 1800) : [])];
    return cluster(jobs.filter((p) => safe(p)), 80).filter((c) => safe(c, 90)).map((c) => {
      const at = { x: c.x, y: c.y };
      return {
        ...c,
        task: {
          valid: () => safe(at, 90),
          points: () => {
            const now: Vec[] = [...street.broken().filter((f) => f.kind === 'bin'), ...wildlife.carcasses, ...(round ? street.fullBins() : [])].filter((p) => dist(p, at) < 90 && safe(p));
            return tour(at, now.slice(0, 6).map((p) => ({ x: p.x, y: p.y })));
          },
          work: 2,
          maxTime: 100,
          onPointDone: (pt) => {
            if (!safe(pt)) return;
            for (const f of street.items) if (f.kind === 'bin' && dist(f, pt) < 4) street.fix(f);
            for (const a of [...wildlife.carcasses]) if (dist(a, pt) < 5) wildlife.removeCarcass(a);
            effects.cleanup(pt.x, pt.y, 8);
            if (Math.random() < 0.5) this.w.sound('tools', pt.x, pt.y);
          },
        },
      };
    });
  }
}