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/expansion.ts
import { DISTRICT_RECTS, type District, type Structure } from './city.js';
import { boxCorners, type Vec } from './geometry.js';

/**
 * The country beyond the old city: ten times the map. Across the bay the docks (quays,
 * gantry cranes, moored ships and acres of containers); north-east the steelworks and
 * refinery; the rail yard running the width of the map; the suburb of Maple Grove and
 * the Strip with its stadium; Fort Dunmore, an air base with a runway, hangars, parked
 * jets, a tank park and ammunition bunkers; and to the south the backcountry: farms,
 * forest, hills, the reservoir and its dam, a wind farm and the market town of Harlan.
 * Route 9 runs on south, Route 101 and Route 50 tie it all together.
 *
 * Everything here that stands up can be knocked down. Tanks, tank cars, aircraft,
 * ammunition and fuel are flagged as fuel: they go up when they come down.
 */

/** A road junction, as the city builds them. */
export interface BNode {
  x: number;
  y: number;
}

export type RoadKind = 'street' | 'avenue' | 'boulevard' | 'expressway' | 'industrial' | 'country' | 'drive';

/** What the expansion needs from the city while it's being built. */
export interface Builder {
  add(layer: number, x0: number, y0: number, x1: number, y1: number, draw: (ctx: CanvasRenderingContext2D) => void): void;
  node(x: number, y: number, district: District): BNode;
  road(a: BNode, b: BNode, kind: RoadKind, district: District, ctrl?: Vec[]): void;
  /** A road along a curve with junctions at the given distances; returns the junction at a distance. */
  chain(curve: Vec[], positions: number[], kind: RoadKind, district: District, ends?: [number, BNode][]): (s: number) => BNode;
  grid(xs: number[], ys: number[], district: District, kindAt: (vertical: boolean, index: number) => RoadKind, skip?: number): BNode[][];
  /** A winding two-lane road from a to b through the control points. */
  country(ctrl: Vec[], a: BNode, b: BNode, district: District): void;
  special(district: District, corners: Vec[], draw: (ctx: CanvasRenderingContext2D) => void, opts?: { mat?: 'wood' | 'stone'; hp?: number; fuel?: number; building?: boolean; layer?: number }): Structure;
  lake(cx: number, cy: number, rx: number, ry: number): void;
  /** Keep an area clear of the buildings that line the roads. */
  reserve(x0: number, y0: number, x1: number, y1: number): void;
  /** Nothing built, no road and no water within r of a point. */
  isFree(x: number, y: number, r: number): boolean;
  claimBox(cx: number, cy: number, angle: number, hw: number, hd: number): boolean;
  forest(x0: number, y0: number, x1: number, y1: number, density: number): void;
  ground(x0: number, y0: number, x1: number, y1: number, surface: 'road' | 'sidewalk' | 'dirt'): void;
  stacks: { x: number; y: number; steam: boolean }[];
  curvePath(ctrl: Vec[], step: number): Vec[];
  pathLength(pts: Vec[]): number;
  sAtY(pts: Vec[], y: number): number;
  sAtX(pts: Vec[], x: number): number;
  layers: { ground: number; walk: number; object: number; canopy: number };
  /** The city's critical infrastructure, as it's built. */
  infra: InfraSpec[];
  /** Existing ordinary buildings (for picking City Hall and the shelters). */
  buildings: Structure[];
}

/** Critical infrastructure: what keeps the city alive, and what the A-10 is there to break. */
export type InfraKind = 'power' | 'substation' | 'waterworks' | 'watertower' | 'comms' | 'eoc' | 'shelter';

export interface InfraSpec {
  kind: InfraKind;
  name: string;
  st: Structure;
  /** Share of the city's generating capacity (power stations). */
  capacity?: number;
}

const rand = Math.random;
const range = (a: number, b: number) => a + Math.random() * (b - a);
const pick = <T>(items: readonly T[]): T => items[Math.floor(Math.random() * items.length)];

function shadeHex(hex: string, amount: number): string {
  const n = parseInt(hex.slice(1), 16);
  const f = (v: number) => Math.max(0, Math.min(255, Math.round(amount > 0 ? v + (255 - v) * amount : v * (1 + amount))));
  return `rgb(${f((n >> 16) & 255)}, ${f((n >> 8) & 255)}, ${f(n & 255)})`;
}

function fill(ctx: CanvasRenderingContext2D, pts: Vec[], color: string): void {
  ctx.beginPath();
  pts.forEach((p, i) => (i ? ctx.lineTo(p.x, p.y) : ctx.moveTo(p.x, p.y)));
  ctx.closePath();
  ctx.fillStyle = color;
  ctx.fill();
}

/** A hard drop shadow down-right, as every building in the game casts. */
function dropShadow(ctx: CanvasRenderingContext2D, pts: Vec[], height: number): void {
  const dx = Math.min(24, height * 0.8);
  const dy = dx * 1.15;
  ctx.save();
  ctx.translate(dx, dy);
  fill(ctx, pts, 'rgba(0, 0, 0, 0.3)');
  ctx.restore();
}

/** Draw in a box's own frame (centre, angle). */
function local(ctx: CanvasRenderingContext2D, x: number, y: number, angle: number, draw: () => void): void {
  ctx.save();
  ctx.translate(x, y);
  ctx.rotate(angle);
  draw();
  ctx.restore();
}

const box = (x: number, y: number, w: number, h: number, angle = 0) => boxCorners(x, y, angle, w / 2, h / 2);

const circlePts = (x: number, y: number, r: number, n = 16): Vec[] => Array.from({ length: n }, (_, i) => ({ x: x + Math.cos((i / n) * Math.PI * 2) * r, y: y + Math.sin((i / n) * Math.PI * 2) * r }));

// --- Layout (map units). The old city fills x < 5200, y < 9200.

const DOCK_X = [7950, 8400, 8850, 9300, 9750, 10200, 10650];
const DOCK_Y = [450, 1050, 1650, 2250, 2850, 3450, 4050, 4650];
const FAC_X = [11550, 12150, 12750, 13350, 13950, 14550, 15150, 15750];
const FAC_Y = DOCK_Y;
const R101_X = 11200;
const R101S_X = 8800;
const R50_Y = 7700;
const DEPOT_Y = 5480;
const YARD_Y = 7330;
const TRACK_Y0 = 5800;
const TRACKS = 14;
const TRACK_GAP = 60;
const COM_X = [9300, 10050, 10800, 11550, 12300, 13050, 13800, 14550, 15300, 16000];
const COM_Y = [8050, 8900, 9750];
const SUB_X = [5700, 6100, 6500, 6900, 7300, 7700, 8100, 8500];
const SUB_Y = [8100, 8550, 9000, 9450, 9900, 10350, 10800, 11250, 11700, 12150, 12600, 13050, 13500, 13950, 14400];
const MIL_X = [9500, 10300, 11100, 11900, 12700, 13500, 14300, 15100, 15900];
const MIL_Y = [10500, 11300, 12100, 12900];
const BASE = { x0: 9180, y0: 10180, x1: 16120, y1: 16280 };
const RUNWAY = { x0: 10000, x1: 15500, y: 14650, w: 150 };
const TOWN_X = [6350, 6520, 6690, 6860, 7030, 7200, 7370, 7540];
const TOWN_Y = [20850, 21020, 21190, 21360, 21530, 21700, 21870];
const RESERVOIR = { x: 12000, y: 21300, rx: 1500, ry: 800 };

/** Junctions the old city's network hands over: Route 9's south end, the port and the industrial park. */
export interface Handover {
  r9End: BNode;
  port: BNode;
  industrial: BNode;
}

/** Ground painting that has to go down before the roads: the rail yard, the airfield, hills, the quay. */
export function expansionScenery(b: Builder): void {
  const L = b.layers;
  // Rolling hills in the backcountry: darker swells with soft contour bands.
  for (let i = 0; i < 26; i++) {
    const x = range(400, 16000);
    const y = range(15500, 28800);
    if (Math.hypot(x - RESERVOIR.x, y - RESERVOIR.y) < 2200) continue;
    const r = range(500, 1100);
    b.add(L.ground, x - r, y - r, x + r, y + r, (ctx) => {
      for (let k = 0; k < 5; k++) {
        const rr = r * (1 - k * 0.18);
        const g = ctx.createRadialGradient(x, y, rr * 0.2, x, y, rr);
        g.addColorStop(0, 'rgba(40, 80, 34, 0.12)');
        g.addColorStop(1, 'rgba(40, 80, 34, 0)');
        ctx.fillStyle = g;
        ctx.beginPath();
        ctx.ellipse(x, y, rr, rr * 0.8, i, 0, Math.PI * 2);
        ctx.fill();
      }
    });
  }

  // The quay: a concrete apron along the docks' waterfront.
  b.add(L.ground, 7680, 200, 7940, 4720, (ctx) => {
    ctx.fillStyle = '#a3a097';
    ctx.fillRect(7690, 250, 220, 4450);
    ctx.fillStyle = '#e0c040';
    ctx.fillRect(7700, 250, 3, 4450);
    ctx.fillStyle = '#3a3a3c';
    for (let y = 280; y < 4680; y += 70) ctx.fillRect(7706, y, 5, 5);
  });
  b.ground(7690, 250, 7910, 4700, 'sidewalk');

  // The rail yard: ballast, sleepers and steel, in chunks so each painted tile only draws its own piece.
  for (let x = 5650; x < 16150; x += 512) {
    const x1 = Math.min(16150, x + 512);
    b.add(L.ground, x, TRACK_Y0 - 40, x1, TRACK_Y0 + TRACKS * TRACK_GAP, (ctx) => {
      ctx.fillStyle = '#8c8270';
      ctx.fillRect(x, TRACK_Y0 - 30, x1 - x, TRACKS * TRACK_GAP + 10);
      for (let t = 0; t < TRACKS; t++) {
        const y = TRACK_Y0 + t * TRACK_GAP;
        ctx.fillStyle = '#7a705f';
        ctx.fillRect(x, y - 11, x1 - x, 22);
        ctx.fillStyle = '#4a3a2c';
        for (let sx = x; sx < x1; sx += 6) ctx.fillRect(sx, y - 7, 2.4, 14);
        ctx.fillStyle = '#b7b9bd';
        ctx.fillRect(x, y - 4.6, x1 - x, 1.4);
        ctx.fillRect(x, y + 3.2, x1 - x, 1.4);
      }
    });
  }
  b.ground(5650, TRACK_Y0 - 30, 16150, TRACK_Y0 + TRACKS * TRACK_GAP - 20, 'dirt');
  b.reserve(5600, TRACK_Y0 - 120, 16200, TRACK_Y0 + TRACKS * TRACK_GAP + 40);

  // The airfield: runway, taxiway, apron.
  const rw = RUNWAY;
  b.add(L.ground, rw.x0 - 60, rw.y - 500, rw.x1 + 60, rw.y + 120, (ctx) => {
    ctx.fillStyle = '#9a9890';
    ctx.fillRect(rw.x0, 13350, rw.x1 - rw.x0, 620);
    ctx.fillStyle = '#3e3f3d';
    ctx.fillRect(rw.x0 - 40, rw.y - rw.w / 2, rw.x1 - rw.x0 + 80, rw.w);
    ctx.fillRect(rw.x0, 14180, rw.x1 - rw.x0, 54);
    for (let x = rw.x0 + 300; x < rw.x1; x += 900) ctx.fillRect(x - 27, 13950, 54, 260);
    // Runway markings: edges, the dashed centre line, thresholds and numbers.
    ctx.fillStyle = '#f2f2ee';
    ctx.fillRect(rw.x0 - 40, rw.y - rw.w / 2 + 4, rw.x1 - rw.x0 + 80, 3);
    ctx.fillRect(rw.x0 - 40, rw.y + rw.w / 2 - 7, rw.x1 - rw.x0 + 80, 3);
    for (let x = rw.x0 + 260; x < rw.x1 - 260; x += 120) ctx.fillRect(x, rw.y - 2, 70, 4);
    for (const [x, dir] of [
      [rw.x0, 1],
      [rw.x1, -1],
    ] as const) {
      for (let k = 0; k < 8; k++) ctx.fillRect(x + dir * 20 - (dir < 0 ? 90 : 0), rw.y - 64 + k * 17, 90, 9);
      ctx.save();
      ctx.translate(x + dir * 180, rw.y);
      ctx.rotate(dir > 0 ? Math.PI / 2 : -Math.PI / 2);
      ctx.font = '800 60px system-ui, sans-serif';
      ctx.textAlign = 'center';
      ctx.textBaseline = 'middle';
      ctx.fillText(dir > 0 ? '09' : '27', 0, 0);
      ctx.restore();
    }
    // Taxiway centre line and the apron's parking marks.
    ctx.fillStyle = '#e8c33a';
    ctx.fillRect(rw.x0, 14205, rw.x1 - rw.x0, 3);
    for (let x = rw.x0 + 300; x < rw.x1; x += 900) ctx.fillRect(x - 1.5, 13950, 3, 260);
    for (let x = rw.x0 + 150; x < rw.x1 - 100; x += 300) {
      ctx.fillRect(x, 13620, 3, 300);
      ctx.fillRect(x - 40, 13900, 80, 3);
    }
  });
  b.ground(rw.x0 - 40, rw.y - rw.w / 2, rw.x1 + 40, rw.y + rw.w / 2, 'road');
  b.ground(rw.x0, 13350, rw.x1, 14240, 'sidewalk');
  b.reserve(BASE.x0 + 60, 13150, BASE.x1 - 20, BASE.y1 - 40);

  // Reserve the plant sites in the steelworks, the stadium block and the motor pool.
  for (const [i, j] of PLANT_BLOCKS) b.reserve(FAC_X[i] + 70, FAC_Y[j] + 70, FAC_X[i + 1] - 70, FAC_Y[j + 1] - 70);
  b.reserve(COM_X[4] + 80, COM_Y[1] + 70, COM_X[5] - 80, COM_Y[2] - 70);
}

/** Factory blocks given over to the big plants (column, row of the block's top-left junction). */
const PLANT_BLOCKS: [number, number][] = [
  [5, 0],
  [6, 0],
  [2, 3],
  [3, 3],
  [5, 4],
  [1, 6],
  [4, 6],
];

/** The roads: two new highways, the districts' grids and the country roads. */
export function expansionNetwork(b: Builder, h: Handover): void {
  // Route 9 runs on south through the backcountry.
  const r9 = b.curvePath(
    [
      { x: h.r9End.x, y: h.r9End.y },
      { x: 1560, y: 11000 },
      { x: 1480, y: 13500 },
      { x: 1600, y: 16000 },
      { x: 1500, y: 19000 },
      { x: 1650, y: 22500 },
      { x: 1520, y: 26000 },
      { x: 1600, y: 29000 },
    ],
    8,
  );
  const r9Ys = [11000, 14000, 17500, 21000, 24500, 28000];
  const r9Len = b.pathLength(r9);
  const r9Node = b.chain(r9, [0, ...r9Ys.map((y) => b.sAtY(r9, y)), r9Len], 'expressway', 'highway', [[0, h.r9End]]);
  const r9At = (y: number) => r9Node(b.sAtY(r9, y));

  // The docks: a coarse grid of wide streets behind the quay.
  const docks = b.grid(DOCK_X, DOCK_Y, 'docks', () => 'industrial', 0.08);
  // Bay Road: round the south end of the bay from the port to the docks.
  b.country(
    [
      { x: 5100, y: 4350 },
      { x: 5800, y: 5080 },
      { x: 6700, y: 5190 },
      { x: 7500, y: 5110 },
    ],
    h.port,
    docks[0][DOCK_Y.length - 1],
    'docks',
  );

  // The steelworks: big blocks for big plants.
  const fac = b.grid(FAC_X, FAC_Y, 'factory', () => 'industrial', 0.06);

  // Route 50: east-west from the industrial park across the whole map.
  const r50Xs = [6100, 7300, R101S_X, 10050, R101_X, 12300, 14550, 15800];
  const r50 = b.curvePath([{ x: h.industrial.x, y: h.industrial.y }, { x: 16050, y: R50_Y }], 10);
  const r50Len = b.pathLength(r50);
  const r50Node = b.chain(r50, [0, ...r50Xs.map((x) => b.sAtX(r50, x)), r50Len], 'expressway', 'highway', [[0, h.industrial]]);
  const r50At = (x: number) => r50Node(b.sAtX(r50, x));


  // Route 101: north-south between the docks and the steelworks, across the rail yard.
  const r101Ys = [...DOCK_Y, DEPOT_Y, YARD_Y];
  const r101 = b.curvePath([{ x: R101_X, y: 150 }, { x: R101_X, y: R50_Y }], 10);
  const r101Len = b.pathLength(r101);
  const r101Node = b.chain(r101, [0, ...r101Ys.map((y) => y - 150), r101Len], 'expressway', 'highway', [[r101Len, r50At(R101_X)]]);
  for (const [j, y] of DOCK_Y.entries()) {
    b.road(docks[DOCK_X.length - 1][j], r101Node(y - 150), 'industrial', 'docks');
    b.road(r101Node(y - 150), fac[0][j], 'industrial', 'factory');
  }
  // Depot Road and Yard Road run along either side of the rail yard.
  const depotW = b.node(5800, DEPOT_Y, 'rail');
  const depotE = b.node(16050, DEPOT_Y, 'rail');
  b.road(depotW, r101Node(DEPOT_Y - 150), 'industrial', 'rail');
  b.road(r101Node(DEPOT_Y - 150), depotE, 'industrial', 'rail');
  const yardW = b.node(5800, YARD_Y, 'rail');
  const yardE = b.node(16050, YARD_Y, 'rail');
  b.road(yardW, r101Node(YARD_Y - 150), 'industrial', 'rail');
  b.road(r101Node(YARD_Y - 150), yardE, 'industrial', 'rail');

  // Route 101 south: on down past the suburb and the base to the bottom of the map.
  const r101sYs = [9000, 9750, 10350, 11300, 11700, 13050, 14400, 17500, 21360, 24500, 28000];
  const r101s = b.curvePath([{ x: R101S_X, y: R50_Y }, { x: R101S_X + 60, y: 16000 }, { x: R101S_X - 40, y: 22000 }, { x: R101S_X + 30, y: 29000 }], 10);
  const r101sNode = b.chain(r101s, [0, ...r101sYs.map((y) => b.sAtY(r101s, y)), b.pathLength(r101s)], 'expressway', 'highway', [[0, r50At(R101S_X)]]);
  const r101sAt = (y: number) => r101sNode(b.sAtY(r101s, y));

  // Maple Grove: a leafy suburb of streets and houses.
  const sub = b.grid(SUB_X, SUB_Y, 'suburb', (vertical, i) => ((vertical && i === 3) || (!vertical && i === 6) ? 'avenue' : 'street'), 0.12);
  b.road(sub[1][0], r50At(6100), 'street', 'suburb');
  b.road(sub[4][0], r50At(7300), 'street', 'suburb');
  for (const j of [2, 5, 9, 11, 14]) b.road(sub[SUB_X.length - 1][j], r101sAt(SUB_Y[j]), 'street', 'suburb');

  // The Strip: wide avenues of big stores, and the stadium.
  const com = b.grid(COM_X, COM_Y, 'commercial', (vertical) => (vertical ? 'street' : 'avenue'));
  b.road(com[1][0], r50At(10050), 'avenue', 'commercial');
  b.road(com[4][0], r50At(12300), 'avenue', 'commercial');
  b.road(com[7][0], r50At(14550), 'avenue', 'commercial');
  b.road(r101sAt(9750), com[0][2], 'avenue', 'commercial');

  // Fort Dunmore: the base's own grid, through the gate off Route 101.
  const mil = b.grid(MIL_X, MIL_Y, 'military', (vertical) => (vertical ? 'street' : 'avenue'));
  b.road(r101sAt(11300), mil[0][1], 'avenue', 'military');

  // Harlan: a market town in the backcountry.
  const town = b.grid(TOWN_X, TOWN_Y, 'town', (vertical, i) => ((vertical && i === 4) || (!vertical && i === 3) ? 'avenue' : 'street'), 0.1);
  b.road(town[TOWN_X.length - 1][3], r101sAt(21360), 'street', 'town');

  // Country roads across the backcountry.
  b.country([{ x: 2600, y: 11300 }, { x: 3800, y: 10900 }, { x: 4900, y: 11500 }], r9At(11000), sub[0][7], 'rural');
  b.country([{ x: 3200, y: 17200 }, { x: 5200, y: 18100 }, { x: 7000, y: 17600 }], r9At(17500), r101sAt(17500), 'rural');
  b.country([{ x: 10200, y: 17700 }, { x: 12000, y: 18600 }, { x: 14000, y: 18300 }], r101sAt(17500), b.node(15800, 18900, 'rural'), 'rural');
  b.country([{ x: 2800, y: 21500 }, { x: 4400, y: 20800 }, { x: 5600, y: 21300 }], r9At(21000), town[0][3], 'rural');
  b.country([{ x: 10500, y: 23900 }, { x: 12500, y: 25200 }, { x: 14200, y: 24600 }], r101sAt(24500), b.node(15800, 25200, 'rural'), 'rural');
  b.country([{ x: 3200, y: 25300 }, { x: 5200, y: 24700 }, { x: 7000, y: 25100 }], r9At(24500), r101sAt(24500), 'rural');
  b.country([{ x: 4000, y: 28300 }, { x: 6500, y: 27800 }], r9At(28000), r101sAt(28000), 'rural');
  b.country([{ x: 11000, y: 28300 }, { x: 13500, y: 27600 }], r101sAt(28000), b.node(15800, 28200, 'rural'), 'rural');
  b.country([{ x: 800, y: 14600 }, { x: 400, y: 16200 }], r9At(14000), b.node(600, 17500, 'rural'), 'rural');
  b.country([{ x: 9800, y: 15000 }], r101sAt(14400), b.node(10600, 16900, 'rural'), 'rural');
}

/** The landmarks, once the roads and the buildings along them are in. */
export function expansionFeatures(b: Builder): void {
  docksFeatures(b);
  factoryFeatures(b);
  railFeatures(b);
  baseFeatures(b);
  stripFeatures(b);
  backcountryFeatures(b);
  infrastructure(b);
}

// --- The docks.

function docksFeatures(b: Builder): void {
  const L = b.layers;
  // Gantry cranes along the quay, their booms out over the water.
  for (let y = 520; y < 4500; y += 520) {
    const cy = y + range(-60, 60);
    const color = pick(['#c0392b', '#e67e22', '#2471a3']);
    b.special(
      'docks',
      box(7800, cy, 70, 50),
      (ctx) => {
        dropShadow(ctx, box(7760, cy, 200, 46), 40);
        local(ctx, 7800, cy, 0, () => {
          // Legs and portal beams, then the boom reaching out across the berth.
          ctx.fillStyle = color;
          for (const [lx, ly] of [
            [-33, -24],
            [27, -24],
            [-33, 20],
            [27, 20],
          ])
            ctx.fillRect(lx, ly, 6, 4);
          ctx.fillRect(-35, -22, 70, 6);
          ctx.fillRect(-35, 16, 70, 6);
          ctx.fillRect(-160, -6, 210, 12);
          ctx.fillStyle = shadeHex(color, -0.3);
          ctx.fillRect(-160, -2, 210, 4);
          ctx.fillStyle = '#2b2b2e';
          ctx.fillRect(-20, -12, 22, 24);
          ctx.fillStyle = '#ffd400';
          ctx.fillRect(-120, -4, 10, 8);
        });
      },
      { mat: 'stone', hp: 700, building: false, layer: L.canopy },
    );
  }
  // Ships at the berths: container ships stacked high, and a tanker.
  const berths = [
    { y0: 400, len: 880, tanker: false },
    { y0: 1450, len: 760, tanker: true },
    { y0: 2400, len: 900, tanker: false },
    { y0: 3500, len: 700, tanker: false },
  ];
  for (const s of berths) {
    const cx = 7560;
    const cy = s.y0 + s.len / 2;
    const w = s.tanker ? 120 : 130;
    const hull = s.tanker ? '#2c3e50' : pick(['#7b241c', '#1f618d', '#145a32']);
    const rows = Array.from({ length: Math.floor((s.len - 220) / 44) }, () => Array.from({ length: 6 }, () => pick(['#c0392b', '#2471a3', '#d4ac0d', '#239b56', '#7d3c98', '#ca6f1e', '#5d6d7e', '#ecf0f1'])));
    const outline: Vec[] = [
      { x: cx - w / 2, y: s.y0 + 60 },
      { x: cx, y: s.y0 },
      { x: cx + w / 2, y: s.y0 + 60 },
      { x: cx + w / 2, y: s.y0 + s.len - 20 },
      { x: cx + w / 2 - 12, y: s.y0 + s.len },
      { x: cx - w / 2 + 12, y: s.y0 + s.len },
      { x: cx - w / 2, y: s.y0 + s.len - 20 },
    ];
    b.special(
      'docks',
      box(cx, cy, w, s.len),
      (ctx) => {
        dropShadow(ctx, outline, 14);
        fill(ctx, outline, hull);
        fill(
          ctx,
          outline.map((p) => ({ x: cx + (p.x - cx) * 0.86, y: cy + (p.y - cy) * 0.97 })),
          s.tanker ? '#8a3324' : '#5d6d7e',
        );
        // The bridge and funnel aft.
        ctx.fillStyle = '#ecf0f1';
        ctx.fillRect(cx - w / 2 + 14, s.y0 + s.len - 110, w - 28, 50);
        ctx.fillStyle = '#1d2a3c';
        ctx.fillRect(cx - w / 2 + 18, s.y0 + s.len - 104, w - 36, 6);
        ctx.fillStyle = s.tanker ? '#c0392b' : '#f39c12';
        ctx.fillRect(cx - 12, s.y0 + s.len - 52, 24, 22);
        if (s.tanker) {
          // Deck pipework and tank hatches.
          ctx.strokeStyle = '#d5d8dc';
          ctx.lineWidth = 3;
          ctx.beginPath();
          ctx.moveTo(cx, s.y0 + 70);
          ctx.lineTo(cx, s.y0 + s.len - 120);
          ctx.stroke();
          ctx.fillStyle = '#d5d8dc';
          for (let y = s.y0 + 100; y < s.y0 + s.len - 130; y += 60) ctx.fillRect(cx - 34, y, 68, 10);
        } else {
          rows.forEach((row, k) => {
            row.forEach((c, i) => {
              ctx.fillStyle = c;
              ctx.fillRect(cx - w / 2 + 14 + i * ((w - 28) / 6), s.y0 + 90 + k * 44, (w - 28) / 6 - 2, 40);
            });
          });
        }
      },
      { mat: 'stone', hp: s.tanker ? 1600 : 2200, fuel: s.tanker ? 3 : 0.6, building: false },
    );
  }
  // Container stacks wherever there's room in the yards.
  const colors = ['#c0392b', '#2471a3', '#d4ac0d', '#239b56', '#7d3c98', '#ca6f1e', '#5d6d7e', '#a04000', '#1abc9c'];
  for (let i = 0; i < 2600; i++) {
    const x = range(7990, 10900);
    const y = range(320, 4820);
    const vertical = rand() < 0.5;
    const n = 2 + Math.floor(rand() * 4);
    const w = vertical ? 30 : n * 42;
    const h = vertical ? n * 42 : 30;
    if (!b.claimBox(x, y, 0, w / 2 + 4, h / 2 + 4)) continue;
    const stack = Array.from({ length: n * 2 }, () => pick(colors));
    b.special(
      'docks',
      box(x, y, w, h),
      (ctx) => {
        dropShadow(ctx, box(x, y, w, h), 10);
        stack.forEach((c, k) => {
          const a = Math.floor(k / 2);
          const sIdx = k % 2;
          const cx = vertical ? x - 7.5 + sIdx * 15 : x - w / 2 + 21 + a * 42;
          const cy = vertical ? y - h / 2 + 21 + a * 42 : y - 7.5 + sIdx * 15;
          ctx.fillStyle = c;
          if (vertical) ctx.fillRect(cx - 7, cy - 20, 14, 40);
          else ctx.fillRect(cx - 20, cy - 7, 40, 14);
          ctx.fillStyle = 'rgba(0, 0, 0, 0.18)';
          if (vertical) for (let r = -18; r < 20; r += 4) ctx.fillRect(cx - 7, cy + r, 14, 0.8);
          else for (let r = -18; r < 20; r += 4) ctx.fillRect(cx + r, cy - 7, 0.8, 14);
        });
      },
      { mat: 'stone', hp: 160, building: false },
    );
  }
}

// --- The steelworks and refinery.

function factoryFeatures(b: Builder): void {
  const blockOf = (i: number, j: number) => ({ x0: FAC_X[i] + 80, y0: FAC_Y[j] + 80, x1: FAC_X[i + 1] - 80, y1: FAC_Y[j + 1] - 80 });
  const [power1, power2, ref1, ref2, mill, gas, ref3] = PLANT_BLOCKS.map(([i, j]) => blockOf(i, j));
  // The power station: two hyperbolic cooling towers trailing steam, and the turbine hall.
  const hall = box((power1.x1 + power2.x0) / 2, (power1.y0 + power1.y1) / 2, 160, 300);
  b.infra.push({
    kind: 'power',
    name: 'Steelworks Power Station',
    capacity: 0.5,
    st: b.special(
      'factory',
      hall,
      (ctx) => {
        dropShadow(ctx, hall, 30);
        fill(ctx, hall, '#8d8f94');
        ctx.fillStyle = 'rgba(0, 0, 0, 0.18)';
        const [c0, , c2] = hall;
        for (let y = Math.min(c0.y, c2.y) + 10; y < Math.max(c0.y, c2.y) - 6; y += 18) ctx.fillRect(Math.min(c0.x, c2.x) + 8, y, 144, 8);
        ctx.fillStyle = '#ffd400';
        ctx.fillRect(Math.min(c0.x, c2.x) + 4, Math.min(c0.y, c2.y) + 4, 10, 10);
      },
      { mat: 'stone', hp: 2600, building: true },
    ),
  });
  for (const blk of [power1, power2]) {
    const cx = (blk.x0 + blk.x1) / 2;
    const cy = (blk.y0 + blk.y1) / 2;
    const r = 150;
    b.stacks.push({ x: cx, y: cy, steam: true });
    b.special(
      'factory',
      box(cx, cy, r * 1.6, r * 1.6),
      (ctx) => {
        dropShadow(ctx, circlePts(cx, cy, r), 60);
        const g = ctx.createRadialGradient(cx - r * 0.3, cy - r * 0.3, r * 0.2, cx, cy, r);
        g.addColorStop(0, '#d6d3cb');
        g.addColorStop(1, '#8f8b82');
        ctx.fillStyle = g;
        ctx.beginPath();
        ctx.arc(cx, cy, r, 0, Math.PI * 2);
        ctx.fill();
        ctx.fillStyle = '#4f4c47';
        ctx.beginPath();
        ctx.arc(cx, cy, r * 0.62, 0, Math.PI * 2);
        ctx.fill();
        ctx.fillStyle = 'rgba(240, 240, 240, 0.5)';
        ctx.beginPath();
        ctx.arc(cx, cy, r * 0.5, 0, Math.PI * 2);
        ctx.fill();
      },
      { mat: 'stone', hp: 3200, building: true },
    );
  }
  // Refinery tank farms: rows of big round tanks in their bund walls, and a gas holder.
  for (const blk of [ref1, ref2, ref3, gas]) {
    const big = blk === gas;
    const step = big ? 0 : 110;
    const tanks: { x: number; y: number; r: number; color: string }[] = [];
    if (big) tanks.push({ x: (blk.x0 + blk.x1) / 2, y: (blk.y0 + blk.y1) / 2, r: 140, color: '#7b8a8b' });
    else for (let y = blk.y0 + 60; y < blk.y1 - 40; y += step) for (let x = blk.x0 + 60; x < blk.x1 - 40; x += step) if (rand() < 0.85) tanks.push({ x, y, r: range(34, 46), color: pick(['#ecf0f1', '#d5d8dc', '#b3b6b7', '#e5e1d3']) });
    b.add(b.layers.ground, blk.x0, blk.y0, blk.x1, blk.y1, (ctx) => {
      ctx.fillStyle = '#9a958a';
      ctx.fillRect(blk.x0, blk.y0, blk.x1 - blk.x0, blk.y1 - blk.y0);
      ctx.strokeStyle = '#7d786d';
      ctx.lineWidth = 3;
      ctx.strokeRect(blk.x0 + 6, blk.y0 + 6, blk.x1 - blk.x0 - 12, blk.y1 - blk.y0 - 12);
      // Pipe racks between the tanks.
      ctx.strokeStyle = '#6f6a62';
      ctx.lineWidth = 2;
      ctx.beginPath();
      for (let y = blk.y0 + 115; y < blk.y1 - 40; y += 110) {
        ctx.moveTo(blk.x0 + 20, y);
        ctx.lineTo(blk.x1 - 20, y);
      }
      ctx.stroke();
    });
    for (const t of tanks) {
      b.special(
        'factory',
        box(t.x, t.y, t.r * 1.6, t.r * 1.6),
        (ctx) => {
          dropShadow(ctx, circlePts(t.x, t.y, t.r), big ? 40 : 18);
          const g = ctx.createRadialGradient(t.x - t.r * 0.35, t.y - t.r * 0.35, t.r * 0.1, t.x, t.y, t.r);
          g.addColorStop(0, shadeHex(t.color, 0.3));
          g.addColorStop(1, shadeHex(t.color, -0.25));
          ctx.fillStyle = g;
          ctx.beginPath();
          ctx.arc(t.x, t.y, t.r, 0, Math.PI * 2);
          ctx.fill();
          ctx.strokeStyle = 'rgba(0, 0, 0, 0.2)';
          ctx.lineWidth = 1;
          ctx.beginPath();
          ctx.arc(t.x, t.y, t.r * 0.7, 0, Math.PI * 2);
          ctx.stroke();
          ctx.fillStyle = '#c0392b';
          ctx.fillRect(t.x - 2, t.y - t.r + 3, 4, 6);
        },
        { mat: 'stone', hp: big ? 1800 : 520, fuel: big ? 3.2 : 1.6, building: false },
      );
    }
  }
  // The steel mill: a blast furnace with its stoves and stack, and the long rolling mill.
  const mx = (mill.x0 + mill.x1) / 2;
  const my = (mill.y0 + mill.y1) / 2;
  b.special(
    'factory',
    box(mx, my + 90, mill.x1 - mill.x0 - 40, 120),
    (ctx) => {
      const pts = box(mx, my + 90, mill.x1 - mill.x0 - 40, 120);
      dropShadow(ctx, pts, 26);
      fill(ctx, pts, '#7a8088');
      ctx.fillStyle = 'rgba(0, 0, 0, 0.18)';
      for (let x = mill.x0 + 30; x < mill.x1 - 20; x += 16) ctx.fillRect(x, my + 32, 6, 116);
    },
    { mat: 'stone', hp: 2400, building: true },
  );
  for (const [k, sx] of [-120, -50, 20, 90].entries()) {
    const x = mx + sx;
    const y = my - 90;
    const r = k === 0 ? 44 : 30;
    if (k === 0) b.stacks.push({ x, y, steam: false });
    b.special(
      'factory',
      box(x, y, r * 1.6, r * 1.6),
      (ctx) => {
        dropShadow(ctx, circlePts(x, y, r), 50);
        ctx.fillStyle = k === 0 ? '#5d4a3a' : '#8d7f6e';
        ctx.beginPath();
        ctx.arc(x, y, r, 0, Math.PI * 2);
        ctx.fill();
        ctx.fillStyle = k === 0 ? '#ff8c2a' : '#3a3530';
        ctx.beginPath();
        ctx.arc(x, y, r * 0.35, 0, Math.PI * 2);
        ctx.fill();
      },
      { mat: 'stone', hp: k === 0 ? 1500 : 700, fuel: k === 0 ? 1.2 : 0, building: k === 0 },
    );
  }
}

// --- The rail yard.

function railFeatures(b: Builder): void {
  // Trains standing on most of the tracks: boxcars, hoppers, flats with containers, tank cars.
  const kinds = ['box', 'box', 'hopper', 'flat', 'tank', 'tank', 'box'] as const;
  for (let t = 0; t < TRACKS; t++) {
    if (rand() < 0.3) continue;
    const y = TRACK_Y0 + t * TRACK_GAP;
    let x = range(5800, 9000);
    const end = Math.min(16000, x + range(1800, 5200));
    let first = true;
    while (x < end) {
      const kind = first ? 'loco' : pick(kinds);
      const len = kind === 'loco' ? 62 : 52;
      const cx = x + len / 2;
      const color = kind === 'loco' ? pick(['#f2b705', '#1f618d', '#c0392b']) : kind === 'tank' ? pick(['#1b1b1f', '#ecf0f1']) : kind === 'hopper' ? '#6e5a44' : kind === 'flat' ? '#5d6d7e' : pick(['#8a3324', '#6e2c00', '#7b4b2a', '#2e4053']);
      const cargo = pick(['#c0392b', '#2471a3', '#d4ac0d', '#239b56']);
      // Crossing Route 101: leave the level crossing clear.
      if (Math.abs(cx - R101_X) > 80) {
        b.special(
          'rail',
          box(cx, y, len - 4, 16),
          (ctx) => {
            dropShadow(ctx, box(cx, y, len - 4, 16), 8);
            ctx.fillStyle = color;
            ctx.beginPath();
            ctx.roundRect(cx - len / 2 + 2, y - 8, len - 4, 16, kind === 'tank' ? 8 : 2);
            ctx.fill();
            if (kind === 'loco') {
              ctx.fillStyle = '#1d2a3c';
              ctx.fillRect(cx + len / 2 - 14, y - 6, 8, 12);
              ctx.fillStyle = 'rgba(0, 0, 0, 0.3)';
              for (let gx = cx - len / 2 + 8; gx < cx + 10; gx += 6) ctx.fillRect(gx, y - 5, 3, 10);
            } else if (kind === 'flat') {
              ctx.fillStyle = cargo;
              ctx.fillRect(cx - len / 2 + 5, y - 6.5, len - 10, 13);
            } else if (kind === 'hopper') {
              ctx.fillStyle = '#2b2118';
              for (let k = 0; k < 3; k++) ctx.fillRect(cx - len / 2 + 6 + k * 15, y - 5, 12, 10);
            } else if (kind === 'tank') {
              ctx.fillStyle = 'rgba(255, 255, 255, 0.25)';
              ctx.fillRect(cx - len / 2 + 6, y - 6, len - 12, 3);
              ctx.fillStyle = '#c0392b';
              ctx.fillRect(cx - 3, y - 7, 6, 3);
            } else {
              ctx.fillStyle = 'rgba(0, 0, 0, 0.2)';
              for (let gx = cx - len / 2 + 6; gx < cx + len / 2 - 4; gx += 5) ctx.fillRect(gx, y - 8, 1, 16);
            }
          },
          { mat: 'stone', hp: kind === 'loco' ? 600 : 260, fuel: kind === 'tank' ? 1.3 : kind === 'loco' ? 0.6 : 0, building: false },
        );
      }
      x += len;
      first = false;
    }
  }
  // The engine shed and the station at the west end of the yard.
  const shed = box(6150, YARD_Y - 230, 520, 150);
  b.special(
    'rail',
    shed,
    (ctx) => {
      dropShadow(ctx, shed, 22);
      fill(ctx, shed, '#8a3324');
      ctx.fillStyle = 'rgba(0, 0, 0, 0.2)';
      for (let x = 5900; x < 6410; x += 30) ctx.fillRect(x, YARD_Y - 305, 12, 150);
      ctx.fillStyle = '#d5d8dc';
      ctx.fillRect(5890, YARD_Y - 232, 520, 4);
    },
    { mat: 'stone', hp: 1400, building: true },
  );
}

// --- Fort Dunmore.

function baseFeatures(b: Builder): void {
  const L = b.layers;
  // The perimeter fence, open at the main gate.
  const gateY = MIL_Y[1];
  b.add(L.object, BASE.x0 - 4, BASE.y0 - 4, BASE.x1 + 4, BASE.y1 + 4, (ctx) => {
    ctx.strokeStyle = 'rgba(150, 156, 160, 0.9)';
    ctx.lineWidth = 2;
    ctx.setLineDash([6, 3]);
    ctx.beginPath();
    ctx.moveTo(BASE.x0, gateY - 60);
    ctx.lineTo(BASE.x0, BASE.y0);
    ctx.lineTo(BASE.x1, BASE.y0);
    ctx.lineTo(BASE.x1, BASE.y1);
    ctx.lineTo(BASE.x0, BASE.y1);
    ctx.lineTo(BASE.x0, gateY + 60);
    ctx.stroke();
    ctx.setLineDash([]);
    ctx.fillStyle = '#c0392b';
    ctx.fillRect(BASE.x0 - 12, gateY - 64, 24, 8);
    ctx.fillRect(BASE.x0 - 12, gateY + 56, 24, 8);
  });
  // Hangars along the apron, arched roofs ribbed in the sun.
  const hangarY = 13450;
  for (let x = RUNWAY.x0 + 200; x < RUNWAY.x1 - 200; x += 560) {
    const pts = box(x, hangarY, 300, 190);
    b.special(
      'military',
      pts,
      (ctx) => {
        dropShadow(ctx, pts, 30);
        const g = ctx.createLinearGradient(x - 150, 0, x + 150, 0);
        g.addColorStop(0, '#5f6a52');
        g.addColorStop(0.5, '#8d977c');
        g.addColorStop(1, '#4f5a44');
        ctx.fillStyle = g;
        ctx.fillRect(x - 150, hangarY - 95, 300, 190);
        ctx.fillStyle = 'rgba(0, 0, 0, 0.18)';
        for (let ry = hangarY - 90; ry < hangarY + 95; ry += 14) ctx.fillRect(x - 150, ry, 300, 2);
        ctx.fillStyle = '#2b2b2e';
        ctx.fillRect(x - 130, hangarY + 92, 260, 4);
      },
      { mat: 'stone', hp: 1600, building: true },
    );
  }
  // Jets parked on the apron: fuel and ordnance aboard.
  for (let x = RUNWAY.x0 + 150; x < RUNWAY.x1 - 100; x += 300) {
    if (rand() < 0.2) continue;
    const y = 13770;
    const ang = -Math.PI / 2;
    const fighter = rand() < 0.6;
    b.special(
      'military',
      box(x, y, 50, 40),
      (ctx) => {
        local(ctx, x + 4, y + 5, ang, () => drawJet(ctx, fighter, 'rgba(0, 0, 0, 0.28)'));
        local(ctx, x, y, ang, () => drawJet(ctx, fighter));
      },
      { mat: 'stone', hp: 140, fuel: 0.9, building: false },
    );
  }
  // The control tower and the radar dome by the apron.
  const tower = box(12600, 13230, 70, 70);
  b.special(
    'military',
    tower,
    (ctx) => {
      dropShadow(ctx, tower, 70);
      fill(ctx, tower, '#d5d0c4');
      ctx.fillStyle = '#1d2a3c';
      ctx.fillRect(12570, 13200, 60, 60);
      ctx.fillStyle = 'rgba(170, 205, 255, 0.5)';
      ctx.fillRect(12575, 13205, 25, 8);
      ctx.fillStyle = '#c0392b';
      ctx.fillRect(12598, 13228, 4, 4);
    },
    { mat: 'stone', hp: 900, building: true },
  );
  b.special(
    'military',
    box(13000, 13230, 70, 70),
    (ctx) => {
      dropShadow(ctx, circlePts(13000, 13230, 44), 40);
      const g = ctx.createRadialGradient(12985, 13215, 6, 13000, 13230, 44);
      g.addColorStop(0, '#ffffff');
      g.addColorStop(1, '#b8bcc0');
      ctx.fillStyle = g;
      ctx.beginPath();
      ctx.arc(13000, 13230, 44, 0, Math.PI * 2);
      ctx.fill();
    },
    { mat: 'stone', hp: 500, building: false },
  );
  // The motor pool: rows of tanks south of the runway.
  for (let row = 0; row < 3; row++) {
    for (let col = 0; col < 12; col++) {
      if (rand() < 0.15) continue;
      const x = 10200 + col * 70;
      const y = 15050 + row * 60;
      b.special(
        'military',
        box(x, y, 30, 20),
        (ctx) => {
          local(ctx, x + 2.5, y + 3, -Math.PI / 2, () => drawTank(ctx, 'rgba(0, 0, 0, 0.3)'));
          local(ctx, x, y, -Math.PI / 2, () => drawTank(ctx));
        },
        { mat: 'stone', hp: 320, fuel: 0.7, building: false },
      );
    }
  }
  // Ammunition bunkers: earth-covered igloos with concrete faces.
  for (let k = 0; k < 9; k++) {
    const x = 11600 + k * 330;
    const y = 15500;
    const pts = box(x, y, 120, 80);
    b.special(
      'military',
      pts,
      (ctx) => {
        dropShadow(ctx, pts, 10);
        const g = ctx.createRadialGradient(x, y + 10, 10, x, y, 70);
        g.addColorStop(0, '#7fae5a');
        g.addColorStop(1, '#4f7a3a');
        ctx.fillStyle = g;
        ctx.beginPath();
        ctx.ellipse(x, y, 60, 40, 0, 0, Math.PI * 2);
        ctx.fill();
        ctx.fillStyle = '#b8b2a6';
        ctx.fillRect(x - 26, y - 44, 52, 10);
        ctx.fillStyle = '#5d6d7e';
        ctx.fillRect(x - 12, y - 44, 24, 8);
      },
      { mat: 'stone', hp: 700, fuel: 2.8, building: false },
    );
  }
  // The fuel depot.
  for (let k = 0; k < 4; k++) {
    const x = 15200 + (k % 2) * 140;
    const y = 15350 + Math.floor(k / 2) * 140;
    b.special(
      'military',
      box(x, y, 90, 90),
      (ctx) => {
        dropShadow(ctx, circlePts(x, y, 55), 20);
        const g = ctx.createRadialGradient(x - 18, y - 18, 6, x, y, 55);
        g.addColorStop(0, '#9aa08a');
        g.addColorStop(1, '#5a6048');
        ctx.fillStyle = g;
        ctx.beginPath();
        ctx.arc(x, y, 55, 0, Math.PI * 2);
        ctx.fill();
      },
      { mat: 'stone', hp: 700, fuel: 2.4, building: false },
    );
  }
}

/** A parked jet seen from above, nose along +x: a fighter, or an A-10. */
function drawJet(ctx: CanvasRenderingContext2D, fighter: boolean, shadow?: string): void {
  const grey = shadow ?? (fighter ? '#9aa2a8' : '#7d878e');
  const dark = shadow ?? '#59626a';
  ctx.fillStyle = grey;
  if (fighter) {
    ctx.beginPath();
    ctx.moveTo(24, 0);
    ctx.lineTo(4, -4);
    ctx.lineTo(-6, -18);
    ctx.lineTo(-10, -18);
    ctx.lineTo(-8, -4);
    ctx.lineTo(-18, -9);
    ctx.lineTo(-21, -9);
    ctx.lineTo(-20, 0);
    ctx.lineTo(-21, 9);
    ctx.lineTo(-18, 9);
    ctx.lineTo(-8, 4);
    ctx.lineTo(-10, 18);
    ctx.lineTo(-6, 18);
    ctx.lineTo(4, 4);
    ctx.closePath();
    ctx.fill();
  } else {
    ctx.fillRect(-20, -2.5, 44, 5);
    ctx.fillRect(-2, -26, 8, 52);
    ctx.fillRect(-20, -9, 6, 18);
    ctx.fillStyle = dark;
    ctx.fillRect(-14, -6, 9, 4);
    ctx.fillRect(-14, 2, 9, 4);
  }
  if (!shadow) {
    ctx.fillStyle = '#1d2a3c';
    ctx.beginPath();
    ctx.ellipse(12, 0, 4, 1.8, 0, 0, Math.PI * 2);
    ctx.fill();
  }
}

/** A tank seen from above, gun along +x. */
function drawTank(ctx: CanvasRenderingContext2D, shadow?: string): void {
  ctx.fillStyle = shadow ?? '#4b5320';
  ctx.fillRect(-14, -9, 28, 18);
  if (shadow) return;
  ctx.fillStyle = '#2b2b2e';
  ctx.fillRect(-14, -9, 28, 3.5);
  ctx.fillRect(-14, 5.5, 28, 3.5);
  ctx.fillStyle = '#5c6630';
  ctx.beginPath();
  ctx.arc(-1, 0, 6.5, 0, Math.PI * 2);
  ctx.fill();
  ctx.fillStyle = '#3a4019';
  ctx.fillRect(4, -1, 18, 2);
}

// --- The Strip.

function stripFeatures(b: Builder): void {
  const x = (COM_X[4] + COM_X[5]) / 2;
  const y = (COM_Y[1] + COM_Y[2]) / 2;
  const rx = 280;
  const ry = 330;
  const stadium = b.special(
    'commercial',
    box(x, y, rx * 1.7, ry * 1.7),
    (ctx) => {
      ctx.save();
      ctx.translate(16, 18);
      ctx.fillStyle = 'rgba(0, 0, 0, 0.3)';
      ctx.beginPath();
      ctx.ellipse(x, y, rx, ry, 0, 0, Math.PI * 2);
      ctx.fill();
      ctx.restore();
      // Tiers of seats in team colours, the roof ring, the pitch and its lines.
      const rings = ['#d5d8dc', '#2471a3', '#c0392b', '#2471a3', '#e5e1d3'];
      rings.forEach((c, k) => {
        ctx.fillStyle = c;
        ctx.beginPath();
        ctx.ellipse(x, y, rx - k * 26, ry - k * 26, 0, 0, Math.PI * 2);
        ctx.fill();
      });
      ctx.fillStyle = '#4e9a3c';
      ctx.fillRect(x - rx + 150, y - ry + 150, (rx - 150) * 2, (ry - 150) * 2);
      ctx.strokeStyle = 'rgba(255, 255, 255, 0.8)';
      ctx.lineWidth = 2;
      ctx.strokeRect(x - rx + 160, y - ry + 160, (rx - 160) * 2, (ry - 160) * 2);
      ctx.beginPath();
      ctx.moveTo(x - rx + 160, y);
      ctx.lineTo(x + rx - 160, y);
      ctx.stroke();
      ctx.beginPath();
      ctx.arc(x, y, 30, 0, Math.PI * 2);
      ctx.stroke();
    },
    { mat: 'stone', hp: 5000, building: true },
  );
  b.infra.push({ kind: 'shelter', name: 'Stadium Shelter', st: stadium });
}

// --- The backcountry.

function backcountryFeatures(b: Builder): void {
  // The reservoir behind its dam.
  const R = RESERVOIR;
  b.lake(R.x, R.y, R.rx, R.ry);
  const dam = box(R.x, R.y + R.ry + 40, 900, 60);
  const damSt = b.special(
    'rural',
    dam,
    (ctx) => {
      dropShadow(ctx, dam, 30);
      fill(ctx, dam, '#b8b2a6');
      ctx.fillStyle = '#8d877b';
      ctx.fillRect(R.x - 450, R.y + R.ry + 50, 900, 20);
      ctx.fillStyle = '#5dade2';
      ctx.fillRect(R.x - 40, R.y + R.ry + 10, 80, 60);
    },
    { mat: 'stone', hp: 6000, building: true },
  );
  b.infra.push({ kind: 'power', name: 'Harlan Dam Hydro', capacity: 0.25, st: damSt });
  // The wind farm on the south-east ridges.
  let placed = 0;
  for (let i = 0; i < 400 && placed < 18; i++) {
    const x = range(12800, 16100);
    const y = range(25800, 28900);
    if (!b.isFree(x, y, 30)) continue;
    b.claimBox(x, y, 0, 8, 8);
    const a = rand() * Math.PI * 2;
    b.special(
      'rural',
      box(x, y, 12, 12),
      (ctx) => {
        // A long shadow across the fields, then the nacelle and three blades.
        ctx.strokeStyle = 'rgba(0, 0, 0, 0.25)';
        ctx.lineWidth = 4;
        ctx.beginPath();
        ctx.moveTo(x, y);
        ctx.lineTo(x + 70, y + 80);
        ctx.stroke();
        ctx.fillStyle = '#f4f6f7';
        ctx.beginPath();
        ctx.arc(x, y, 5, 0, Math.PI * 2);
        ctx.fill();
        ctx.strokeStyle = '#ecf0f1';
        ctx.lineWidth = 3;
        ctx.beginPath();
        for (let k = 0; k < 3; k++) {
          const ba = a + (k * Math.PI * 2) / 3;
          ctx.moveTo(x, y);
          ctx.lineTo(x + Math.cos(ba) * 48, y + Math.sin(ba) * 48);
        }
        ctx.stroke();
      },
      { mat: 'stone', hp: 400, building: false, layer: b.layers.canopy },
    );
    placed++;
  }
  // Woods all over the backcountry, patchy and thinner than the old forests.
  const woods: [number, number, number, number, number][] = [
    [200, 9500, 1300, 13800, 0.5],
    [2000, 12000, 4800, 15600, 0.42],
    [200, 18500, 1200, 23500, 0.5],
    [2200, 22200, 5600, 24000, 0.4],
    [9300, 19000, 10800, 23500, 0.45],
    [14200, 19500, 16200, 23500, 0.5],
    [6000, 25500, 9000, 27300, 0.4],
    [9600, 26000, 12500, 27200, 0.38],
    [3000, 26500, 5500, 27500, 0.42],
    [5600, 15000, 8500, 16800, 0.4],
    [5400, 9300, 5500, 14600, 0.3],
    [11600, 16700, 16100, 17900, 0.4],
    [200, 26000, 2500, 29000, 0.45],
  ];
  for (const [x0, y0, x1, y1, d] of woods) b.forest(x0, y0, x1, y1, d);
}

// --- Critical infrastructure.

/** Somewhere free in a rectangle for a box of half-size hw × hd, or null. */
function freeSpot(b: Builder, rect: [number, number, number, number], hw: number, hd: number, tries = 700): Vec | null {
  const [x0, y0, x1, y1] = rect;
  for (let i = 0; i < tries; i++) {
    const x = range(x0 + hw, x1 - hw);
    const y = range(y0 + hd, y1 - hd);
    if (b.claimBox(x, y, 0, hw + 6, hd + 6)) return { x, y };
  }
  return null;
}

const rectOf = (d: District): [number, number, number, number] | null => {
  const r = DISTRICT_RECTS.find((e) => e[0] === d);
  return r ? [r[1], r[2], r[3], r[4]] : null;
};

/** Areas to look in for each district's substation and water tower (the old country and the backcountry have no rectangle). */
// (A function, not a constant: the district table isn't ready while this module loads.)
type Rect = [number, number, number, number];
const serviceAreas = (): [District, string, Rect, ...Rect[]][] => [
  ['downtown', 'Downtown', rectOf('downtown')!, [1640, 1850, 1885, 3300], [1880, 3110, 3250, 3360], [1700, 1700, 3300, 1950]],
  ['uptown', 'Uptown', rectOf('uptown')!],
  ['hood', 'The Hood', rectOf('hood')!, [90, 2450, 300, 3950], [280, 3870, 1420, 4120], [1380, 2500, 1480, 3900]],
  ['port', 'Port', rectOf('port')!],
  ['village', 'Millbrook', [1500, 4800, 2700, 5900]],
  ['industrial', 'Industrial Park', rectOf('industrial')!],
  ['country', 'County', [300, 4400, 3000, 6600]],
  ['docks', 'Docks', rectOf('docks')!],
  ['factory', 'Steelworks', rectOf('factory')!],
  ['rail', 'Rail Yard', [5600, 6950, 16000, 7250]],
  ['suburb', 'Maple Grove', rectOf('suburb')!],
  ['commercial', 'The Strip', rectOf('commercial')!],
  ['military', 'Fort Dunmore', [9300, 10300, 16000, 13000]],
  ['town', 'Harlan', [6000, 20500, 8200, 22300]],
  ['rural', 'Backcountry', [2500, 16500, 8500, 20000]],
];

function infrastructure(b: Builder): void {
  // Bayside Power Station: the old city's oil-fired plant, two tall stacks and its tanks.
  const plant = freeSpot(b, [3000, 6900, 5150, 8900], 130, 90) ?? freeSpot(b, [300, 4400, 5100, 6800], 130, 90);
  if (plant) {
    const { x, y } = plant;
    const pts = box(x, y, 260, 180);
    b.stacks.push({ x: x - 70, y: y - 50, steam: false }, { x: x - 20, y: y - 50, steam: false });
    b.infra.push({
      kind: 'power',
      name: 'Bayside Power Station',
      capacity: 0.25,
      st: b.special(
        'industrial',
        pts,
        (ctx) => {
          dropShadow(ctx, pts, 40);
          fill(ctx, box(x, y, 260, 180), '#9a958a');
          fill(ctx, box(x - 30, y + 10, 180, 110), '#7a8088');
          ctx.fillStyle = 'rgba(0, 0, 0, 0.18)';
          for (let k = -110; k < 60; k += 16) ctx.fillRect(x + k, y - 44, 8, 108);
          for (const sx of [-70, -20]) {
            ctx.fillStyle = '#b8b2a6';
            ctx.beginPath();
            ctx.arc(x + sx, y - 50, 14, 0, Math.PI * 2);
            ctx.fill();
            ctx.fillStyle = '#2b2b2e';
            ctx.beginPath();
            ctx.arc(x + sx, y - 50, 8, 0, Math.PI * 2);
            ctx.fill();
            ctx.fillStyle = '#c0392b';
            ctx.fillRect(x + sx - 14, y - 52, 28, 4);
          }
          for (const tx of [80, 110]) {
            ctx.fillStyle = '#d5d8dc';
            ctx.beginPath();
            ctx.arc(x + tx, y + 50, 20, 0, Math.PI * 2);
            ctx.fill();
          }
        },
        { mat: 'stone', hp: 2400, building: true, fuel: 1.2 },
      ),
    });
  }

  // A substation in every district: transformers in a fenced gravel yard.
  const firstFree = (rects: Rect[], hw: number, hd: number) => {
    for (const r of rects) {
      const at = freeSpot(b, r, hw, hd);
      if (at) return at;
    }
    return null;
  };
  for (const [d, name, ...rects] of serviceAreas()) {
    const at = firstFree(rects, 40, 32);
    if (!at) continue;
    const { x, y } = at;
    const pts = box(x, y, 80, 64);
    b.infra.push({
      kind: 'substation',
      name: `${name} Substation`,
      st: b.special(
        d,
        pts,
        (ctx) => {
          fill(ctx, pts, '#a8a49a');
          ctx.strokeStyle = '#6f6a62';
          ctx.lineWidth = 1;
          ctx.setLineDash([3, 2]);
          ctx.strokeRect(x - 40, y - 32, 80, 64);
          ctx.setLineDash([]);
          for (let k = 0; k < 3; k++) {
            const tx = x - 24 + k * 24;
            ctx.fillStyle = 'rgba(0, 0, 0, 0.3)';
            ctx.fillRect(tx - 6, y - 8, 15, 16);
            ctx.fillStyle = '#5d6d7e';
            ctx.fillRect(tx - 8, y - 10, 14, 16);
            ctx.fillStyle = '#d5d8dc';
            ctx.fillRect(tx - 6, y - 14, 2, 4);
            ctx.fillRect(tx - 1, y - 14, 2, 4);
            ctx.fillRect(tx + 4, y - 14, 2, 4);
          }
          ctx.strokeStyle = '#3a3a3e';
          ctx.lineWidth = 0.8;
          ctx.beginPath();
          ctx.moveTo(x - 36, y - 22);
          ctx.lineTo(x + 36, y - 22);
          ctx.moveTo(x - 36, y + 20);
          ctx.lineTo(x + 36, y + 20);
          ctx.stroke();
          ctx.fillStyle = '#ffd400';
          ctx.fillRect(x + 28, y + 22, 8, 6);
        },
        { mat: 'stone', hp: 420, building: false, fuel: 0.6 },
      ),
    });
  }

  // Water towers: a tank on legs, the district's name round it.
  for (const [d, name, ...rects] of serviceAreas()) {
    if (d === 'rail' || d === 'port') continue;
    const at = firstFree(rects, 22, 22);
    if (!at) continue;
    const { x, y } = at;
    b.infra.push({
      kind: 'watertower',
      name: `${name} Water Tower`,
      st: b.special(
        d,
        box(x, y, 36, 36),
        (ctx) => {
          // A tall tower casts a long shadow.
          ctx.fillStyle = 'rgba(0, 0, 0, 0.28)';
          ctx.beginPath();
          ctx.ellipse(x + 40, y + 46, 22, 20, 0, 0, Math.PI * 2);
          ctx.fill();
          ctx.strokeStyle = 'rgba(0, 0, 0, 0.28)';
          ctx.lineWidth = 3;
          ctx.beginPath();
          ctx.moveTo(x + 4, y + 5);
          ctx.lineTo(x + 34, y + 40);
          ctx.stroke();
          ctx.strokeStyle = '#5d6d7e';
          ctx.lineWidth = 2;
          ctx.beginPath();
          for (const [lx, ly] of [
            [-14, -14],
            [14, -14],
            [-14, 14],
            [14, 14],
          ]) {
            ctx.moveTo(x + lx, y + ly);
            ctx.lineTo(x + lx * 0.4, y + ly * 0.4);
          }
          ctx.stroke();
          const g = ctx.createRadialGradient(x - 6, y - 6, 2, x, y, 20);
          g.addColorStop(0, '#e8f4fb');
          g.addColorStop(1, '#8fb6cc');
          ctx.fillStyle = g;
          ctx.beginPath();
          ctx.arc(x, y, 18, 0, Math.PI * 2);
          ctx.fill();
          ctx.fillStyle = '#5d6d7e';
          ctx.beginPath();
          ctx.arc(x, y, 4, 0, Math.PI * 2);
          ctx.fill();
        },
        { mat: 'stone', hp: 360, building: false },
      ),
    });
  }

  // Waterworks: the lakeside plant for the old city and the reservoir plant below the dam.
  const works: [string, [number, number, number, number]][] = [
    ['Lakeside Waterworks', [200, 300, 1500, 1700]],
    ['Reservoir Waterworks', [10800, 22300, 13200, 23600]],
  ];
  for (const [name, rect] of works) {
    const at = freeSpot(b, rect, 110, 80);
    if (!at) continue;
    const { x, y } = at;
    const pts = box(x, y, 220, 160);
    b.infra.push({
      kind: 'waterworks',
      name,
      st: b.special(
        name.startsWith('Lake') ? 'country' : 'rural',
        pts,
        (ctx) => {
          dropShadow(ctx, pts, 10);
          fill(ctx, pts, '#b8b2a6');
          // Settling tanks and filter beds.
          for (const [cx, cy] of [
            [-60, -30],
            [0, -30],
            [60, -30],
          ]) {
            ctx.fillStyle = '#8d877b';
            ctx.beginPath();
            ctx.arc(x + cx, y + cy, 26, 0, Math.PI * 2);
            ctx.fill();
            ctx.fillStyle = '#4f8fb3';
            ctx.beginPath();
            ctx.arc(x + cx, y + cy, 22, 0, Math.PI * 2);
            ctx.fill();
          }
          ctx.fillStyle = '#3f86ad';
          ctx.fillRect(x - 96, y + 14, 120, 50);
          ctx.fillStyle = 'rgba(255, 255, 255, 0.3)';
          for (let k = 0; k < 4; k++) ctx.fillRect(x - 96, y + 22 + k * 12, 120, 2);
          ctx.fillStyle = '#7a8088';
          ctx.fillRect(x + 36, y + 10, 60, 56);
        },
        { mat: 'stone', hp: 1600, building: true },
      ),
    });
  }

  // Radio and phone masts: the city's eyes and ears.
  const masts: [District, string, [number, number, number, number]][] = [
    ['country', 'Ridge Mast', [1700, 300, 2500, 1300]],
    ['commercial', 'Strip Tower', rectOf('commercial')!],
    ['rural', 'Harlan Mast', [9000, 18000, 11000, 20000]],
  ];
  for (const [d, name, rect] of masts) {
    const at = freeSpot(b, rect, 16, 16);
    if (!at) continue;
    const { x, y } = at;
    b.infra.push({
      kind: 'comms',
      name,
      st: b.special(
        d,
        box(x, y, 26, 26),
        (ctx) => {
          ctx.strokeStyle = 'rgba(0, 0, 0, 0.3)';
          ctx.lineWidth = 4;
          ctx.beginPath();
          ctx.moveTo(x, y);
          ctx.lineTo(x + 120, y + 138);
          ctx.stroke();
          ctx.strokeStyle = '#c0392b';
          ctx.lineWidth = 1.4;
          ctx.beginPath();
          ctx.moveTo(x - 12, y + 10);
          ctx.lineTo(x, y - 12);
          ctx.lineTo(x + 12, y + 10);
          ctx.closePath();
          ctx.moveTo(x - 6, y - 1);
          ctx.lineTo(x + 6, y - 1);
          ctx.stroke();
          ctx.fillStyle = '#ecf0f1';
          ctx.beginPath();
          ctx.arc(x, y, 3, 0, Math.PI * 2);
          ctx.fill();
          ctx.fillStyle = '#ff3b3b';
          ctx.fillRect(x - 1, y - 14, 2, 2);
        },
        { mat: 'stone', hp: 300, building: false, layer: b.layers.canopy },
      ),
    });
  }

  // City Hall, where the emergency operations centre runs the response; and shelters in the towns.
  const nearest = (x: number, y: number, district: District) =>
    b.buildings.filter((st) => st.district === district && !st.station).sort((p, q) => Math.hypot(p.x - x, p.y - y) - Math.hypot(q.x - x, q.y - y))[0];
  const hall = nearest(2600, 2520, 'downtown');
  if (hall) b.infra.push({ kind: 'eoc', name: 'City Hall EOC', st: hall });
  for (const [x, y, d, name] of [
    [2050, 5340, 'village', 'Millbrook School Shelter'],
    [6900, 21360, 'town', 'Harlan Hall Shelter'],
    [7100, 11000, 'suburb', 'Maple Grove High Shelter'],
    [800, 3200, 'hood', 'Hood Community Shelter'],
  ] as [number, number, District, string][]) {
    const st = nearest(x, y, d);
    if (st) b.infra.push({ kind: 'shelter', name, st });
  }
}