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.
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);
},
},
};
});
}
}
