fertig-classic-games/public/src/games/minimotorways/MiniMotorwaysLogic.js

1197 lines
42 KiB
JavaScript

// Mini Motorways — pure simulation logic. No Phaser imports; runs in Node for
// headless verification (server/scripts/verifyMiniMotorways.js) and in the
// browser scene. All distances are in grid cells, all times in milliseconds.
export const WORLD_W = 40;
export const WORLD_H = 24;
export const TERRAIN = { LAND: 0, WATER: 1, TREE: 2 };
export const COLOR_NAMES = ['red', 'blue', 'yellow', 'green', 'purple', 'orange'];
export const COLOR_HEX = {
red: 0xe4574c, blue: 0x4a90d9, yellow: 0xf0b429,
green: 0x55b86a, purple: 0x9b6dd6, orange: 0xee8a3c,
};
export const TUNE = {
WEEK_MS: 70000,
SUBSTEP_MS: 50,
CAR_CAP: 60,
HOUSE_CAP: 20,
BUILDING_CAP: 8,
CAR_SPEED: 2.5, // cells / second
HEADWAY: 0.65, // minimum gap behind the car ahead, in cells
DWELL_MS: 1000,
COOLDOWN_MS: 2000,
PIN_MS_BASE: 18000, // pin interval = max(MIN, BASE * DECAY^week)
PIN_MS_DECAY: 0.94,
PIN_MS_MIN: 3500,
PIN_GRACE_MS: 15000, // new buildings wait this long before pin #1
PIN_CAP: 12,
OVERFLOW_PINS: 8,
OVERFLOW_MS: 35000, // full ring → game over
OVERFLOW_DRAIN: 2, // ring drains at 2x fill rate while pins < 8
DELIVERY_RELIEF: 0.06, // each delivery knocks the ring down a touch
HOUSE_MS_BASE: 25000,
HOUSE_MS_DECAY: 0.85,
HOUSE_MS_MIN: 12000,
BUILDING_MS_BASE: 120000,
BUILDING_MS_DECAY: 0.88,
BUILDING_MS_MIN: 70000,
START_ROADS: 30,
WEEK_ROADS: 12,
UPGRADE_ROADS: 10,
MOTORWAY_COST: 2.5, // A* cost of the portal edge
MOTORWAY_MS: 1200, // real traversal time, ignores all traffic
MOTORWAY_MIN_DIST: 4, // portals must be at least this far apart
CONGESTION_COST: 0.4, // A* edge penalty per car on the target cell
LIGHT_PHASE_MS: 4000,
ROUNDABOUT_CAP: 2,
DISPATCH_MS: 500,
SECOND_CAR_WEEK: 2, // houses gain a second car from this week on
COLOR_UNLOCK_WEEKS: [0, 0, 3, 5, 8, 11],
GROWTH: [
{ week: 0, w: 20, h: 12 },
{ week: 3, w: 26, h: 16 },
{ week: 6, w: 32, h: 20 },
{ week: 9, w: 40, h: 24 },
],
};
// Six cities: palette + terrain generator parameters + personality.
export const CITIES = [
{
name: 'Marlow',
blurb: 'A gentle market town split by one slow river.',
palette: { land: 0xf4efe6, landAlt: 0xeae3d4, water: 0x8fcde4, accent: 0xf0b429, night: 0x8e9cc8, road: 0xffffff, roadEdge: 0xd8d0c0 },
gen: { rivers: 1, riverWidth: 1, lakes: 0, lakeSize: 0, treeDensity: 0.02 },
colorOrder: ['red', 'blue', 'yellow', 'green', 'purple', 'orange'],
upgradeWeights: { bridge: 1, motorway: 1, light: 1, roundabout: 1, roads: 1.5 },
},
{
name: 'Sandpoint',
blurb: 'Lake country — build around the water, not through it.',
palette: { land: 0xf2e8d5, landAlt: 0xe8dcc2, water: 0x5fb8b0, accent: 0xee8a3c, night: 0x96a0c4, road: 0xfffdf7, roadEdge: 0xd9cdb2 },
gen: { rivers: 0, riverWidth: 1, lakes: 3, lakeSize: 13, treeDensity: 0.015 },
colorOrder: ['orange', 'blue', 'green', 'red', 'yellow', 'purple'],
upgradeWeights: { bridge: 1.6, motorway: 1.2, light: 1, roundabout: 1, roads: 1.4 },
},
{
name: 'Twin Forks',
blurb: 'Two rivers braid through town. Bring bridges.',
palette: { land: 0xe9eef2, landAlt: 0xdde4ea, water: 0x7fb5d9, accent: 0x5a8fd6, night: 0x8893be, road: 0xffffff, roadEdge: 0xc9d2dc },
gen: { rivers: 2, riverWidth: 1, lakes: 0, lakeSize: 0, treeDensity: 0.02 },
colorOrder: ['blue', 'yellow', 'red', 'purple', 'orange', 'green'],
upgradeWeights: { bridge: 3, motorway: 1, light: 1, roundabout: 1, roads: 1.3 },
},
{
name: 'Cedar Falls',
blurb: 'Forest roads wind between the pines.',
palette: { land: 0xe8f0dd, landAlt: 0xdbe7cb, water: 0x74c4cf, accent: 0x4f9e58, night: 0x84a08e, road: 0xfdfff8, roadEdge: 0xc6d4b4 },
gen: { rivers: 1, riverWidth: 1, lakes: 1, lakeSize: 8, treeDensity: 0.06 },
colorOrder: ['green', 'red', 'purple', 'blue', 'orange', 'yellow'],
upgradeWeights: { bridge: 1.4, motorway: 1, light: 1, roundabout: 1.4, roads: 1.5 },
},
{
name: 'Saltmere',
blurb: 'A wide cold estuary cuts the city in half.',
palette: { land: 0xe8e8ec, landAlt: 0xdcdce2, water: 0x6f9fc8, accent: 0x8a77c9, night: 0x7d88b4, road: 0xffffff, roadEdge: 0xc8c8d2 },
gen: { rivers: 1, riverWidth: 3, lakes: 0, lakeSize: 0, treeDensity: 0.01 },
colorOrder: ['purple', 'yellow', 'blue', 'orange', 'green', 'red'],
upgradeWeights: { bridge: 2.5, motorway: 1.2, light: 1, roundabout: 1, roads: 1.3 },
},
{
name: 'Solano',
blurb: 'Dry, sprawling, and fast. Motorway territory.',
palette: { land: 0xf6e7d3, landAlt: 0xeedbc0, water: 0x66b2c4, accent: 0xd95f43, night: 0xa78f9e, road: 0xfffaf2, roadEdge: 0xe0cdaf },
gen: { rivers: 0, riverWidth: 1, lakes: 1, lakeSize: 6, treeDensity: 0.025 },
colorOrder: ['red', 'orange', 'purple', 'green', 'blue', 'yellow'],
upgradeWeights: { bridge: 0.6, motorway: 2.5, light: 1.2, roundabout: 1, roads: 1.4 },
},
];
export function mulberry32(seed) {
let a = seed >>> 0;
return () => {
a |= 0; a = (a + 0x6D2B79F5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
export const keyOf = (x, y) => y * WORLD_W + x;
export const xOf = (k) => k % WORLD_W;
export const yOf = (k) => Math.floor(k / WORLD_W);
const inBounds = (x, y) => x >= 0 && x < WORLD_W && y >= 0 && y < WORLD_H;
const SQRT2 = Math.SQRT2;
const DIRS = [
[1, 0], [-1, 0], [0, 1], [0, -1],
[1, 1], [1, -1], [-1, 1], [-1, -1],
];
export function octile(ax, ay, bx, by) {
const dx = Math.abs(ax - bx);
const dy = Math.abs(ay - by);
return Math.max(dx, dy) + (SQRT2 - 1) * Math.min(dx, dy);
}
function centeredRect(w, h) {
const x0 = Math.floor((WORLD_W - w) / 2);
const y0 = Math.floor((WORLD_H - h) / 2);
return { x0, y0, x1: x0 + w - 1, y1: y0 + h - 1, w, h };
}
const START_RECT = centeredRect(TUNE.GROWTH[0].w, TUNE.GROWTH[0].h);
// ── Terrain generation ─────────────────────────────────────────────────────────
function carveRiver(terrain, rng, width) {
// Biased random walk from one edge to the opposite one.
const vertical = rng() < 0.5;
let x; let y;
if (vertical) { x = 6 + Math.floor(rng() * (WORLD_W - 12)); y = 0; }
else { x = 0; y = 4 + Math.floor(rng() * (WORLD_H - 8)); }
while (inBounds(x, y)) {
for (let o = 0; o < width; o++) {
const wx = vertical ? x + o : x;
const wy = vertical ? y : y + o;
if (inBounds(wx, wy)) terrain[keyOf(wx, wy)] = TERRAIN.WATER;
}
if (vertical) {
y += 1;
if (rng() < 0.42) x += rng() < 0.5 ? -1 : 1;
x = Math.max(1, Math.min(WORLD_W - 1 - width, x));
} else {
x += 1;
if (rng() < 0.42) y += rng() < 0.5 ? -1 : 1;
y = Math.max(1, Math.min(WORLD_H - 1 - width, y));
}
}
}
function carveLake(terrain, rng, size) {
const cx = 4 + Math.floor(rng() * (WORLD_W - 8));
const cy = 3 + Math.floor(rng() * (WORLD_H - 6));
const blob = [keyOf(cx, cy)];
const inBlob = new Set(blob);
terrain[blob[0]] = TERRAIN.WATER;
while (blob.length < size) {
const from = blob[Math.floor(rng() * blob.length)];
const [dx, dy] = DIRS[Math.floor(rng() * 4)];
const nx = xOf(from) + dx; const ny = yOf(from) + dy;
if (!inBounds(nx, ny)) continue;
const k = keyOf(nx, ny);
if (inBlob.has(k)) continue;
inBlob.add(k); blob.push(k);
terrain[k] = TERRAIN.WATER;
}
}
function waterRunLengths(terrain, x, y) {
let h = 1; let v = 1;
for (let i = x - 1; i >= 0 && terrain[keyOf(i, y)] === TERRAIN.WATER; i--) h++;
for (let i = x + 1; i < WORLD_W && terrain[keyOf(i, y)] === TERRAIN.WATER; i++) h++;
for (let j = y - 1; j >= 0 && terrain[keyOf(x, j)] === TERRAIN.WATER; j--) v++;
for (let j = y + 1; j < WORLD_H && terrain[keyOf(x, j)] === TERRAIN.WATER; j++) v++;
return { h, v };
}
function startRectOk(terrain) {
let water = 0;
for (let y = START_RECT.y0; y <= START_RECT.y1; y++) {
for (let x = START_RECT.x0; x <= START_RECT.x1; x++) {
if (terrain[keyOf(x, y)] !== TERRAIN.WATER) continue;
water++;
// Every water cell crossing the start zone must be bridgeable (≤3 wide)
// along at least one axis.
const { h, v } = waterRunLengths(terrain, x, y);
if (h > 3 && v > 3) return false;
}
}
return water <= START_RECT.w * START_RECT.h * 0.2;
}
function forceFixStartRect(terrain) {
const cells = [];
for (let y = START_RECT.y0; y <= START_RECT.y1; y++) {
for (let x = START_RECT.x0; x <= START_RECT.x1; x++) {
if (terrain[keyOf(x, y)] === TERRAIN.WATER) cells.push([x, y]);
}
}
for (const [x, y] of cells) {
const { h, v } = waterRunLengths(terrain, x, y);
if (h > 3 && v > 3) terrain[keyOf(x, y)] = TERRAIN.LAND;
}
const cap = Math.floor(START_RECT.w * START_RECT.h * 0.2);
const cx = WORLD_W / 2; const cy = WORLD_H / 2;
let water = cells.filter(([x, y]) => terrain[keyOf(x, y)] === TERRAIN.WATER);
water.sort((a, b) => octile(a[0], a[1], cx, cy) - octile(b[0], b[1], cx, cy));
while (water.length > cap) {
const [x, y] = water.shift();
terrain[keyOf(x, y)] = TERRAIN.LAND;
}
}
function genTerrainOnce(city, rng) {
const terrain = new Uint8Array(WORLD_W * WORLD_H);
for (let i = 0; i < city.gen.rivers; i++) carveRiver(terrain, rng, city.gen.riverWidth);
for (let i = 0; i < city.gen.lakes; i++) carveLake(terrain, rng, city.gen.lakeSize);
for (let k = 0; k < terrain.length; k++) {
if (terrain[k] === TERRAIN.LAND && rng() < city.gen.treeDensity) terrain[k] = TERRAIN.TREE;
}
// Keep a small clearing at the very centre so the first structures always fit.
for (let y = 10; y <= 13; y++) {
for (let x = 17; x <= 22; x++) {
if (terrain[keyOf(x, y)] === TERRAIN.TREE) terrain[keyOf(x, y)] = TERRAIN.LAND;
}
}
return terrain;
}
export function generateCity(cityIndex, seed) {
const city = CITIES[cityIndex];
for (let attempt = 0; attempt < 10; attempt++) {
const rng = mulberry32((seed + attempt * 1000003) >>> 0);
const terrain = genTerrainOnce(city, rng);
if (startRectOk(terrain)) return { terrain };
}
const terrain = genTerrainOnce(city, mulberry32(seed >>> 0));
forceFixStartRect(terrain);
return { terrain };
}
// ── Simulation ─────────────────────────────────────────────────────────────────
export class Sim {
constructor(cityIndex, seed) {
this.cityIndex = cityIndex;
this.city = CITIES[cityIndex];
this.rng = mulberry32(seed >>> 0);
this.terrain = generateCity(cityIndex, seed).terrain;
this.roads = new Set();
this.bridgeCells = new Set();
this.bridges = []; // { id, cells: [k...] }
this.items = new Map(); // k -> { type: 'light' | 'roundabout' }
this.motorways = []; // { id, a, b }
this.portals = new Map(); // k -> twin portal k
this.houses = []; // { id, color, k, carIds: [] }
this.buildings = []; // { id, color, k, cells, pins, reserved, ring, pinT, graceT, overflowing }
this.cars = [];
this.nextId = 1;
this.stock = { roads: TUNE.START_ROADS, bridges: 0, motorways: 0, lights: 0, roundabouts: 0 };
this.score = 0;
this.week = 0;
this.weekT = 0;
this.time = 0;
this.paused = false;
this.gameOver = false;
this.gameOverInfo = null;
this.upgradeChoices = null;
this.netVersion = 0;
this.connCache = new Map();
this.cellOcc = new Map(); // k -> count of active cars on the cell
this.dispatchT = 0;
this.events = [];
this.growthIdx = 0;
this.activeRect = centeredRect(TUNE.GROWTH[0].w, TUNE.GROWTH[0].h);
this.colorsUnlocked = 0;
// Cities with water start with one bridge in hand so an early river spawn
// can't be an unwinnable death sentence.
if (this.terrain.includes(TERRAIN.WATER)) this.stock.bridges = 1;
this.startComponent = this.computeStartComponent();
this.houseT = this.houseInterval();
this.buildingT = this.buildingInterval();
// Week 0 opens with one colour, each seeded with a destination and homes.
this.unlockColors(1);
}
// ── Small helpers ────────────────────────────────────────────────────────────
emit(type, data = {}) { this.events.push({ type, ...data }); }
houseInterval() { return Math.max(TUNE.HOUSE_MS_MIN, TUNE.HOUSE_MS_BASE * TUNE.HOUSE_MS_DECAY ** this.week); }
buildingInterval() { return Math.max(TUNE.BUILDING_MS_MIN, TUNE.BUILDING_MS_BASE * TUNE.BUILDING_MS_DECAY ** this.week); }
pinInterval() { return Math.max(TUNE.PIN_MS_MIN, TUNE.PIN_MS_BASE * TUNE.PIN_MS_DECAY ** this.week); }
carsPerHouse() { return this.week >= TUNE.SECOND_CAR_WEEK ? 2 : 1; }
lightPhase() { return Math.floor(this.time / TUNE.LIGHT_PHASE_MS) % 2; }
computeStartComponent() {
// Flood fill over non-water from the centre: early spawns stay on the
// starting landmass so the player is never forced to bridge in week 0.
const seen = new Set();
let seed = null;
for (let r = 0; r < 10 && seed === null; r++) {
for (let dy = -r; dy <= r && seed === null; dy++) {
for (let dx = -r; dx <= r; dx++) {
const x = 20 + dx; const y = 12 + dy;
if (inBounds(x, y) && this.terrain[keyOf(x, y)] !== TERRAIN.WATER) { seed = keyOf(x, y); break; }
}
}
}
if (seed === null) return seen;
const stack = [seed];
seen.add(seed);
while (stack.length) {
const k = stack.pop();
const x = xOf(k); const y = yOf(k);
for (let d = 0; d < 4; d++) {
const nx = x + DIRS[d][0]; const ny = y + DIRS[d][1];
if (!inBounds(nx, ny)) continue;
const nk = keyOf(nx, ny);
if (seen.has(nk) || this.terrain[nk] === TERRAIN.WATER) continue;
seen.add(nk);
stack.push(nk);
}
}
return seen;
}
occupiedAt(k) {
for (const h of this.houses) if (h.k === k) return { type: 'house', ref: h };
for (const b of this.buildings) if (b.cells.includes(k)) return { type: 'building', ref: b };
return null;
}
buildOccupiedSet() {
const s = new Set();
for (const h of this.houses) s.add(h.k);
for (const b of this.buildings) for (const c of b.cells) s.add(c);
return s;
}
// ── Road network ─────────────────────────────────────────────────────────────
touchNetwork() { this.netVersion++; this.connCache.clear(); }
roadNeighbors(k) {
const x = xOf(k); const y = yOf(k);
const out = [];
for (const [dx, dy] of DIRS) {
const nx = x + dx; const ny = y + dy;
if (!inBounds(nx, ny)) continue;
const nk = keyOf(nx, ny);
if (!this.roads.has(nk)) continue;
if (dx !== 0 && dy !== 0) {
// Crossing rule: if both shared corners carry road, the two diagonals
// of this 2x2 block would cross — and the corners already connect the
// cells orthogonally — so the diagonal edge is suppressed.
const cornerA = keyOf(x + dx, y);
const cornerB = keyOf(x, y + dy);
if (this.roads.has(cornerA) && this.roads.has(cornerB)) continue;
}
out.push({ k: nk, cost: dx !== 0 && dy !== 0 ? SQRT2 : 1 });
}
const twin = this.portals.get(k);
if (twin !== undefined) out.push({ k: twin, cost: TUNE.MOTORWAY_COST, motorway: true });
return out;
}
connCount(k) {
let n = this.connCache.get(k);
if (n === undefined) {
n = this.roadNeighbors(k).length;
this.connCache.set(k, n);
}
return n;
}
canPlaceRoad(k) {
return this.stock.roads > 0
&& this.terrain[k] === TERRAIN.LAND
&& !this.roads.has(k)
&& !this.occupiedAt(k);
}
placeRoad(k) {
if (!this.canPlaceRoad(k)) return false;
this.stock.roads--;
this.roads.add(k);
this.touchNetwork();
return true;
}
eraseRoad(k) {
if (!this.roads.has(k)) return false;
if (this.portals.has(k)) return this.eraseMotorwayAt(k);
if (this.bridgeCells.has(k)) return this.eraseBridgeAt(k);
this.roads.delete(k);
const item = this.items.get(k);
if (item) {
this.items.delete(k);
if (item.type === 'light') this.stock.lights++;
else this.stock.roundabouts++;
}
this.stock.roads++;
this.touchNetwork();
this.flagReroutes([k]);
return true;
}
flagReroutes(removed) {
const gone = new Set(removed);
for (const car of this.cars) {
if (car.state !== 'toPickup' && car.state !== 'toHome') continue;
const from = Math.floor(car.pos);
for (let i = from; i < car.path.length; i++) {
if (gone.has(car.path[i])) { car.needsReroute = true; break; }
}
}
}
// ── Bridges / motorways / intersection items ─────────────────────────────────
// cells must be a straight orthogonal run of 1-3 water cells whose two
// extension cells (just beyond each end) are dry land.
canPlaceBridge(cells) {
if (this.stock.bridges < 1) return false;
if (!cells || cells.length < 1 || cells.length > 3) return false;
const xs = cells.map(xOf); const ys = cells.map(yOf);
const horiz = ys.every((y) => y === ys[0]);
const vert = xs.every((x) => x === xs[0]);
if (!horiz && !vert) return false;
const sorted = [...cells].sort((a, b) => a - b);
for (let i = 1; i < sorted.length; i++) {
const stepOk = horiz ? sorted[i] === sorted[i - 1] + 1 : sorted[i] === sorted[i - 1] + WORLD_W;
if (!stepOk) return false;
}
for (const k of cells) {
if (this.terrain[k] !== TERRAIN.WATER || this.roads.has(k)) return false;
}
const step = horiz ? 1 : WORLD_W;
const before = sorted[0] - step;
const after = sorted[sorted.length - 1] + step;
const dry = (k) => {
const x = xOf(k); const y = yOf(k);
return inBounds(x, y) && this.terrain[k] !== TERRAIN.WATER;
};
// Guard against wrap-around on horizontal runs at the map edge.
if (horiz && (xOf(sorted[0]) === 0 || xOf(sorted[sorted.length - 1]) === WORLD_W - 1)) return false;
if (!horiz && (yOf(sorted[0]) === 0 || yOf(sorted[sorted.length - 1]) === WORLD_H - 1)) return false;
return dry(before) && dry(after);
}
placeBridge(cells) {
if (!this.canPlaceBridge(cells)) return false;
this.stock.bridges--;
const bridge = { id: this.nextId++, cells: [...cells] };
this.bridges.push(bridge);
for (const k of cells) {
this.roads.add(k);
this.bridgeCells.add(k);
}
this.touchNetwork();
return true;
}
eraseBridgeAt(k) {
const idx = this.bridges.findIndex((b) => b.cells.includes(k));
if (idx < 0) return false;
const bridge = this.bridges[idx];
this.bridges.splice(idx, 1);
for (const c of bridge.cells) {
this.roads.delete(c);
this.bridgeCells.delete(c);
}
this.stock.bridges++;
this.touchNetwork();
this.flagReroutes(bridge.cells);
return true;
}
// Motorway portals sit on previously-empty land next to existing road.
canPlacePortal(k) {
if (this.terrain[k] !== TERRAIN.LAND || this.roads.has(k) || this.occupiedAt(k)) return false;
const x = xOf(k); const y = yOf(k);
for (const [dx, dy] of DIRS) {
const nx = x + dx; const ny = y + dy;
if (inBounds(nx, ny) && this.roads.has(keyOf(nx, ny))) return true;
}
return false;
}
canPlaceMotorway(a, b) {
return this.stock.motorways > 0
&& a !== b
&& this.canPlacePortal(a) && this.canPlacePortal(b)
&& octile(xOf(a), yOf(a), xOf(b), yOf(b)) >= TUNE.MOTORWAY_MIN_DIST;
}
placeMotorway(a, b) {
if (!this.canPlaceMotorway(a, b)) return false;
this.stock.motorways--;
this.motorways.push({ id: this.nextId++, a, b });
this.roads.add(a); this.roads.add(b);
this.portals.set(a, b); this.portals.set(b, a);
this.touchNetwork();
return true;
}
eraseMotorwayAt(k) {
const idx = this.motorways.findIndex((m) => m.a === k || m.b === k);
if (idx < 0) return false;
const m = this.motorways[idx];
this.motorways.splice(idx, 1);
this.portals.delete(m.a); this.portals.delete(m.b);
this.roads.delete(m.a); this.roads.delete(m.b);
this.stock.motorways++;
this.touchNetwork();
this.flagReroutes([m.a, m.b]);
return true;
}
canPlaceItem(k, type) {
const stockOk = type === 'light' ? this.stock.lights > 0 : this.stock.roundabouts > 0;
return stockOk && this.roads.has(k) && !this.items.has(k) && !this.portals.has(k)
&& this.connCount(k) >= 3;
}
placeItem(k, type) {
if (!this.canPlaceItem(k, type)) return false;
if (type === 'light') this.stock.lights--; else this.stock.roundabouts--;
this.items.set(k, { type });
return true;
}
eraseItem(k) {
const item = this.items.get(k);
if (!item) return false;
this.items.delete(k);
if (item.type === 'light') this.stock.lights++; else this.stock.roundabouts++;
return true;
}
// ── Pathfinding ──────────────────────────────────────────────────────────────
roadCellsAround(cells) {
const out = [];
const seen = new Set();
for (const k of Array.isArray(cells) ? cells : [cells]) {
const x = xOf(k); const y = yOf(k);
for (const [dx, dy] of DIRS) {
const nx = x + dx; const ny = y + dy;
if (!inBounds(nx, ny)) continue;
const nk = keyOf(nx, ny);
if (this.roads.has(nk) && !seen.has(nk)) { seen.add(nk); out.push(nk); }
}
}
return out;
}
// Multi-source / multi-goal A* over road cells. Returns [k...] or null.
findPath(starts, goals) {
if (!starts.length || !goals.length) return null;
const goalSet = new Set(goals);
const goalPts = goals.map((g) => [xOf(g), yOf(g)]);
// Octile distance is inadmissible once motorway teleports exist (a portal
// can cover 30 cells for cost 2.5), so fall back to Dijkstra then.
const h = this.portals.size > 0 ? () => 0 : (k) => {
const x = xOf(k); const y = yOf(k);
let best = Infinity;
for (const [gx, gy] of goalPts) {
const d = octile(x, y, gx, gy);
if (d < best) best = d;
}
return best;
};
const gScore = new Map();
const cameFrom = new Map();
const open = []; // binary min-heap of [f, k]
const push = (f, k) => {
open.push([f, k]);
let i = open.length - 1;
while (i > 0) {
const p = (i - 1) >> 1;
if (open[p][0] <= open[i][0]) break;
[open[p], open[i]] = [open[i], open[p]]; i = p;
}
};
const pop = () => {
const top = open[0];
const last = open.pop();
if (open.length) {
open[0] = last;
let i = 0;
for (;;) {
const l = 2 * i + 1; const r = l + 1;
let m = i;
if (l < open.length && open[l][0] < open[m][0]) m = l;
if (r < open.length && open[r][0] < open[m][0]) m = r;
if (m === i) break;
[open[m], open[i]] = [open[i], open[m]]; i = m;
}
}
return top;
};
for (const s of starts) {
if (!this.roads.has(s)) continue;
gScore.set(s, 0);
push(h(s), s);
}
while (open.length) {
const [, k] = pop();
if (goalSet.has(k)) {
const path = [k];
let cur = k;
while (cameFrom.has(cur)) { cur = cameFrom.get(cur); path.push(cur); }
return path.reverse();
}
const gk = gScore.get(k);
for (const nb of this.roadNeighbors(k)) {
const occ = this.cellOcc.get(nb.k) || 0;
const tentative = gk + nb.cost + TUNE.CONGESTION_COST * occ;
if (tentative < (gScore.get(nb.k) ?? Infinity)) {
gScore.set(nb.k, tentative);
cameFrom.set(nb.k, k);
push(tentative + h(nb.k), nb.k);
}
}
}
return null;
}
// Full trip path for a car leaving home: [houseCell, road..., roadByBuilding].
findTripPath(house, building) {
const starts = this.roadCellsAround(house.k);
const goals = this.roadCellsAround(building.cells);
const path = this.findPath(starts, goals);
return path ? [house.k, ...path] : null;
}
findHomePath(fromRoadCell, house) {
if (!this.roads.has(fromRoadCell)) return null;
const goals = this.roadCellsAround(house.k);
const path = this.findPath([fromRoadCell], goals);
return path ? [...path, house.k] : null;
}
// ── Spawning ─────────────────────────────────────────────────────────────────
randomFreeCell(needs2x2) {
const r = this.activeRect;
const occupied = this.buildOccupiedSet();
const fits = (x, y) => {
if (!inBounds(x, y)) return false;
const k = keyOf(x, y);
if (this.terrain[k] !== TERRAIN.LAND || this.roads.has(k) || occupied.has(k)) return false;
if (this.week < 2 && !this.startComponent.has(k)) return false;
// One cell of clearance from other structures so driveways stay open.
for (const [dx, dy] of DIRS) {
const nx = x + dx; const ny = y + dy;
if (inBounds(nx, ny) && occupied.has(keyOf(nx, ny))) return false;
}
return true;
};
for (let attempt = 0; attempt < 90; attempt++) {
const x = r.x0 + Math.floor(this.rng() * (r.w - (needs2x2 ? 1 : 0)));
const y = r.y0 + Math.floor(this.rng() * (r.h - (needs2x2 ? 1 : 0)));
if (needs2x2) {
if (fits(x, y) && fits(x + 1, y) && fits(x, y + 1) && fits(x + 1, y + 1)) return keyOf(x, y);
} else if (fits(x, y)) {
return keyOf(x, y);
}
}
return null;
}
spawnHouse(color, force = false) {
if (!force && this.houses.length >= TUNE.HOUSE_CAP) return null;
const k = this.randomFreeCell(false);
if (k === null) return null;
const house = { id: this.nextId++, color, k, carIds: [] };
this.houses.push(house);
this.emit('houseSpawn', { id: house.id, k, color });
return house;
}
spawnBuilding(color) {
if (this.buildings.length >= TUNE.BUILDING_CAP) return null;
const k = this.randomFreeCell(true);
if (k === null) return null;
const cells = [k, k + 1, k + WORLD_W, k + WORLD_W + 1];
const building = {
id: this.nextId++, color, k, cells,
pins: 0, reserved: 0, ring: 0, overflowing: false,
pinT: this.pinInterval(), graceT: TUNE.PIN_GRACE_MS,
};
this.buildings.push(building);
this.emit('buildingSpawn', { id: building.id, k, color });
return building;
}
unlockColors(target) {
while (this.colorsUnlocked < Math.min(target, this.city.colorOrder.length)) {
const color = this.city.colorOrder[this.colorsUnlocked];
this.colorsUnlocked++;
this.spawnBuilding(color);
// Bypass the house cap: a fresh colour must never start supply-starved.
this.spawnHouse(color, true);
this.spawnHouse(color, true);
this.emit('colorUnlock', { color });
}
}
pickSpawnColor(forBuilding) {
const unlocked = this.city.colorOrder.slice(0, this.colorsUnlocked);
const stats = unlocked.map((color) => ({
color,
houses: this.houses.filter((h) => h.color === color).length,
buildings: this.buildings.filter((b) => b.color === color).length,
}));
if (forBuilding) {
// Destinations follow supply: favour colours with spare houses per stop.
stats.sort((a, b) => (b.houses / (b.buildings + 1)) - (a.houses / (a.buildings + 1)));
} else {
// Houses go where demand is under-served.
stats.sort((a, b) => (a.houses / Math.max(1, a.buildings)) - (b.houses / Math.max(1, b.buildings)));
}
const top = stats.slice(0, 2);
return top[Math.floor(this.rng() * top.length)].color;
}
// ── Weekly cycle ─────────────────────────────────────────────────────────────
activeRectHasWater() {
const r = this.activeRect;
for (let y = r.y0; y <= r.y1; y++) {
for (let x = r.x0; x <= r.x1; x++) {
if (this.terrain[keyOf(x, y)] === TERRAIN.WATER) return true;
}
}
return false;
}
pickUpgradeChoices() {
const weights = { ...this.city.upgradeWeights };
if (!this.activeRectHasWater()) delete weights.bridge;
const choices = [];
for (let pick = 0; pick < 2; pick++) {
const entries = Object.entries(weights).filter(([key]) => !choices.includes(key));
let total = 0;
for (const [, w] of entries) total += w;
let roll = this.rng() * total;
for (const [key, w] of entries) {
roll -= w;
if (roll <= 0) { choices.push(key); break; }
}
if (choices.length < pick + 1) choices.push(entries[entries.length - 1][0]);
}
return choices;
}
chooseUpgrade(index) {
if (!this.upgradeChoices) return;
const pick = this.upgradeChoices[index] ?? this.upgradeChoices[0];
if (pick === 'bridge') this.stock.bridges++;
else if (pick === 'motorway') this.stock.motorways++;
else if (pick === 'light') this.stock.lights++;
else if (pick === 'roundabout') this.stock.roundabouts++;
else this.stock.roads += TUNE.UPGRADE_ROADS;
this.upgradeChoices = null;
this.paused = false;
}
rollWeek() {
this.week++;
this.stock.roads += TUNE.WEEK_ROADS;
const growth = TUNE.GROWTH.findIndex((g) => g.week === this.week);
if (growth > this.growthIdx) {
this.growthIdx = growth;
this.activeRect = centeredRect(TUNE.GROWTH[growth].w, TUNE.GROWTH[growth].h);
this.emit('growth', { rect: { ...this.activeRect }, idx: growth });
}
const unlockTarget = TUNE.COLOR_UNLOCK_WEEKS.filter((w) => w <= this.week).length;
this.unlockColors(unlockTarget);
this.upgradeChoices = this.pickUpgradeChoices();
this.paused = true;
this.emit('weekEnd', { week: this.week, choices: [...this.upgradeChoices] });
}
// ── Cars ─────────────────────────────────────────────────────────────────────
setCarOcc(car, k) {
if (car.occK === k) return;
if (car.occK !== null && car.occK !== undefined) {
const n = (this.cellOcc.get(car.occK) || 1) - 1;
if (n <= 0) this.cellOcc.delete(car.occK); else this.cellOcc.set(car.occK, n);
}
car.occK = k;
if (k !== null) this.cellOcc.set(k, (this.cellOcc.get(k) || 0) + 1);
}
syncCarXY(car) {
if (car.mw) {
const a = car.path[car.mw.fromIdx]; const b = car.path[car.mw.fromIdx + 1];
car.x = xOf(a) + (xOf(b) - xOf(a)) * car.mw.t;
car.y = yOf(a) + (yOf(b) - yOf(a)) * car.mw.t;
return;
}
const i = Math.min(Math.floor(car.pos), car.path.length - 1);
const j = Math.min(i + 1, car.path.length - 1);
const f = car.pos - i;
const a = car.path[i]; const b = car.path[j];
car.x = xOf(a) + (xOf(b) - xOf(a)) * f;
car.y = yOf(a) + (yOf(b) - yOf(a)) * f;
if (a !== b) car.heading = Math.atan2(yOf(b) - yOf(a), xOf(b) - xOf(a));
}
createCar(house) {
const car = {
id: this.nextId++, color: house.color, houseId: house.id,
state: 'idle', path: null, pos: 0, x: xOf(house.k), y: yOf(house.k),
heading: 0, dwellT: 0, cooldownT: 0, targetId: null,
needsReroute: false, mw: null, occK: null,
};
this.cars.push(car);
house.carIds.push(car.id);
return car;
}
houseById(id) { return this.houses.find((h) => h.id === id); }
buildingById(id) { return this.buildings.find((b) => b.id === id); }
carById(id) { return this.cars.find((c) => c.id === id); }
dispatch() {
const wanting = this.buildings
.filter((b) => b.pins - b.reserved > 0)
.sort((a, b) => (b.ring - a.ring) || (b.pins - a.pins));
for (const building of wanting) {
const bx = xOf(building.k); const by = yOf(building.k);
const homes = this.houses
.filter((h) => h.color === building.color)
.sort((a, b) => octile(xOf(a.k), yOf(a.k), bx, by) - octile(xOf(b.k), yOf(b.k), bx, by));
let assignments = 0;
let failures = 0;
for (const house of homes) {
if (assignments >= 3 || failures >= 8) break;
if (building.pins - building.reserved <= 0) break;
let car = house.carIds.map((id) => this.carById(id)).find((c) => c && c.state === 'idle');
if (!car && house.carIds.length < this.carsPerHouse() && this.cars.length < TUNE.CAR_CAP) {
car = this.createCar(house);
}
if (!car) continue;
const path = this.findTripPath(house, building);
if (!path) { failures++; continue; }
car.state = 'toPickup';
car.path = path;
car.pos = 0;
car.targetId = building.id;
car.needsReroute = false;
car.mw = null;
building.reserved++;
assignments++;
this.setCarOcc(car, path[0]);
this.syncCarXY(car);
}
}
}
releaseReservation(car) {
if (car.targetId === null) return;
const b = this.buildingById(car.targetId);
if (b && b.reserved > 0) b.reserved--;
car.targetId = null;
}
parkAtHome(car) {
const house = this.houseById(car.houseId);
car.state = 'cooldown';
car.cooldownT = TUNE.COOLDOWN_MS;
car.path = null;
car.pos = 0;
car.mw = null;
car.needsReroute = false;
if (house) { car.x = xOf(house.k); car.y = yOf(house.k); }
this.setCarOcc(car, null);
}
goHome(car) {
const house = this.houseById(car.houseId);
const here = car.path[car.path.length - 1];
const path = house && this.roads.has(here) ? this.findHomePath(here, house) : null;
if (!path) {
this.emit('stranded', { carId: car.id, x: car.x, y: car.y });
this.parkAtHome(car);
return;
}
car.state = 'toHome';
car.path = path;
car.pos = 0;
car.mw = null;
car.needsReroute = false;
this.setCarOcc(car, path[0]);
this.syncCarXY(car);
}
reroute(car) {
car.needsReroute = false;
const idx = Math.min(Math.round(car.pos), car.path.length - 1);
let anchor = car.path[idx];
if (!this.roads.has(anchor)) {
const back = car.path.slice(0, idx).reverse().find((k) => this.roads.has(k));
if (back === undefined) {
if (car.state === 'toPickup') this.releaseReservation(car);
this.emit('stranded', { carId: car.id, x: car.x, y: car.y });
this.parkAtHome(car);
return;
}
anchor = back;
}
let path = null;
if (car.state === 'toPickup') {
const building = this.buildingById(car.targetId);
if (building) {
const goals = this.roadCellsAround(building.cells);
const tail = this.findPath([anchor], goals);
if (tail) path = tail;
}
if (!path) {
this.releaseReservation(car);
const house = this.houseById(car.houseId);
const home = house ? this.findHomePath(anchor, house) : null;
if (home) { car.state = 'toHome'; path = home; }
}
} else {
const house = this.houseById(car.houseId);
if (house) path = this.findHomePath(anchor, house);
}
if (!path) {
this.emit('stranded', { carId: car.id, x: car.x, y: car.y });
this.parkAtHome(car);
return;
}
car.path = path;
car.pos = 0;
car.mw = null;
this.setCarOcc(car, path[0]);
this.syncCarXY(car);
}
canEnterCell(car, k, fromK) {
if (this.connCount(k) < 3) return true;
const item = this.items.get(k);
const occ = (this.cellOcc.get(k) || 0);
if (item?.type === 'roundabout') return occ < TUNE.ROUNDABOUT_CAP;
if (item?.type === 'light') {
const dx = xOf(k) - xOf(fromK);
const dy = yOf(k) - yOf(fromK);
if (dx !== 0 && dy !== 0) return true; // diagonal approaches filter in
const axis = dy === 0 ? 0 : 1;
return axis === this.lightPhase();
}
return occ === 0;
}
headwayLimit(car, want) {
let allowed = want;
const hx = Math.cos(car.heading); const hy = Math.sin(car.heading);
for (const other of this.cars) {
if (other === car || other.mw) continue;
if (other.state !== 'toPickup' && other.state !== 'toHome' && other.state !== 'dwell') continue;
const ddx = other.x - car.x; const ddy = other.y - car.y;
const d2 = ddx * ddx + ddy * ddy;
if (d2 > 4) continue;
const front = ddx * hx + ddy * hy;
if (front <= 0.05) continue;
const lat = Math.abs(-ddx * hy + ddy * hx);
if (lat > 0.45) continue;
if (other.state !== 'dwell') {
// Opposite-direction cars pass through each other — this is what keeps
// a single road usable both ways without lane simulation.
const dot = hx * Math.cos(other.heading) + hy * Math.sin(other.heading);
if (dot < 0.3) continue;
}
allowed = Math.min(allowed, front - TUNE.HEADWAY);
}
return Math.max(0, allowed);
}
moveCar(car, dtMs) {
if (car.mw) {
car.mw.t += dtMs / TUNE.MOTORWAY_MS;
if (car.mw.t >= 1) {
car.pos = car.mw.fromIdx + 1;
car.mw = null;
this.setCarOcc(car, car.path[Math.round(car.pos)]);
}
this.syncCarXY(car);
return;
}
const want = TUNE.CAR_SPEED * (dtMs / 1000);
let allowed = this.headwayLimit(car, want);
if (allowed <= 0.0001) return;
const curIdx = Math.round(car.pos);
const boundary = curIdx + 0.5;
let target = car.pos + allowed;
if (car.pos < boundary && target >= boundary && curIdx + 1 < car.path.length) {
const fromK = car.path[curIdx];
const nextK = car.path[curIdx + 1];
const isPortalJump = this.portals.get(fromK) === nextK
&& octile(xOf(fromK), yOf(fromK), xOf(nextK), yOf(nextK)) > SQRT2 + 0.01;
if (isPortalJump) {
// Snap to the portal mouth, then fly.
car.pos = curIdx;
car.mw = { fromIdx: curIdx, t: 0 };
this.setCarOcc(car, null);
this.syncCarXY(car);
return;
}
if (!this.canEnterCell(car, nextK, fromK)) target = boundary - 0.01;
}
car.pos = Math.min(target, car.path.length - 1);
const occIdx = Math.round(car.pos);
this.setCarOcc(car, car.path[Math.min(occIdx, car.path.length - 1)]);
this.syncCarXY(car);
if (car.pos >= car.path.length - 1 - 0.0001) {
if (car.state === 'toPickup') {
car.state = 'dwell';
car.dwellT = TUNE.DWELL_MS;
} else if (car.state === 'toHome') {
this.parkAtHome(car);
car.state = 'cooldown';
}
}
}
updateCar(car, dtMs) {
switch (car.state) {
case 'idle':
return;
case 'cooldown':
car.cooldownT -= dtMs;
if (car.cooldownT <= 0) car.state = 'idle';
return;
case 'dwell': {
car.dwellT -= dtMs;
if (car.dwellT > 0) return;
const building = this.buildingById(car.targetId);
if (building) {
if (building.pins > 0) building.pins--;
if (building.reserved > 0) building.reserved--;
building.ring = Math.max(0, building.ring - TUNE.DELIVERY_RELIEF);
this.score++;
this.emit('delivered', {
carId: car.id, buildingId: building.id, color: car.color,
x: car.x, y: car.y, score: this.score,
});
}
car.targetId = null;
this.goHome(car);
return;
}
case 'toPickup':
case 'toHome':
if (car.needsReroute) this.reroute(car);
if (car.state === 'toPickup' || car.state === 'toHome') this.moveCar(car, dtMs);
}
}
// ── Pins / overflow ──────────────────────────────────────────────────────────
updateBuilding(building, dtMs) {
if (building.graceT > 0) {
building.graceT -= dtMs;
} else {
building.pinT -= dtMs;
if (building.pinT <= 0) {
building.pinT += this.pinInterval();
if (building.pins < TUNE.PIN_CAP) {
building.pins++;
this.emit('pinAdded', { buildingId: building.id, pins: building.pins });
}
}
}
if (building.pins >= TUNE.OVERFLOW_PINS) {
if (!building.overflowing) {
building.overflowing = true;
this.emit('overflowStart', { buildingId: building.id });
}
building.ring += dtMs / TUNE.OVERFLOW_MS;
if (building.ring >= 1) {
this.gameOver = true;
this.gameOverInfo = { buildingId: building.id, k: building.k, week: this.week, score: this.score };
this.emit('gameOver', { ...this.gameOverInfo });
}
} else if (building.overflowing) {
building.ring -= (dtMs / TUNE.OVERFLOW_MS) * TUNE.OVERFLOW_DRAIN;
if (building.ring <= 0) {
building.ring = 0;
building.overflowing = false;
this.emit('overflowEnd', { buildingId: building.id });
}
}
}
// ── Main step ────────────────────────────────────────────────────────────────
step(dtMs) {
this.events = [];
if (this.paused || this.gameOver) return this.events;
let remaining = dtMs;
while (remaining > 0 && !this.paused && !this.gameOver) {
const dt = Math.min(TUNE.SUBSTEP_MS, remaining);
remaining -= dt;
this.time += dt;
// Weekly clock.
this.weekT += dt;
if (this.weekT >= TUNE.WEEK_MS) {
this.weekT -= TUNE.WEEK_MS;
this.rollWeek(); // pauses the sim for the upgrade choice
}
// Spawning.
this.houseT -= dt;
if (this.houseT <= 0) {
this.houseT = this.houseInterval();
this.spawnHouse(this.pickSpawnColor(false));
}
this.buildingT -= dt;
if (this.buildingT <= 0) {
this.buildingT = this.buildingInterval();
this.spawnBuilding(this.pickSpawnColor(true));
}
// Demand.
for (const building of this.buildings) {
this.updateBuilding(building, dt);
if (this.gameOver) return this.events;
}
// Dispatch.
this.dispatchT -= dt;
if (this.dispatchT <= 0) {
this.dispatchT = TUNE.DISPATCH_MS;
this.dispatch();
}
// Movement.
for (const car of this.cars) this.updateCar(car, dt);
}
return this.events;
}
}