PolyCity v1.0 — full-featured 3D city-builder (Three.js + Vite)

- Procedural island maps, RCI zoning, demand-driven growth
- City-wide power grid with brownouts; wind coastal bonus
- Land value, upgrades, services, pollution, fires & fire spread
- Budget/taxes, happiness, milestones, quest onboarding
- Traffic agents, day/night cycle, adaptive render scale
- Saves: autosave + 3 slots + JSON export/import
- PWA (offline), GitHub Pages/Netlify deploy configs
- Tests: 40-assertion engine suite + headless E2E
This commit is contained in:
PolyCity
2026-08-22 19:17:10 +00:00
commit 56bf3fa2a2
37 changed files with 4996 additions and 0 deletions
+96
View File
@@ -0,0 +1,96 @@
/**
* Captures real in-page canvas snapshots (bypasses broken headless compositor).
* Usage: node tests/capture.mjs
*/
import { chromium } from 'playwright-core';
import { createServer } from 'node:http';
import { readFileSync, readdirSync, existsSync, mkdirSync, writeFileSync } from 'node:fs';
import { join, extname } from 'node:path';
const DIST = new URL('../dist', import.meta.url).pathname;
const SHOTS = new URL('../shots', import.meta.url).pathname;
if (!existsSync(SHOTS)) mkdirSync(SHOTS);
const MIME = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.svg': 'image/svg+xml', '.webmanifest': 'application/manifest+json' };
const srv = createServer((q, r) => {
let p = q.url.split('?')[0]; if (p === '/') p = '/index.html';
try { r.setHeader('content-type', MIME[extname(p)] || 'application/octet-stream'); r.end(readFileSync(join(DIST, p))); }
catch { r.writeHead(404); r.end(); }
});
await new Promise(r => srv.listen(4173, r));
let exe;
{ const root = '/root/.cache/ms-playwright';
for (const d of readdirSync(root)) for (const p of ['chrome-linux/headless_shell', 'chrome-linux64/chrome']) {
const c = join(root, d, p); if (existsSync(c)) exe = c;
} }
const b = await chromium.launch({ executablePath: exe, args: ['--no-sandbox', '--enable-unsafe-swiftshader'] });
const page = await b.newPage({ viewport: { width: 1280, height: 800 }, deviceScaleFactor: 1 });
await page.addInitScript(() => localStorage.setItem('polycity.settings.v1',
JSON.stringify({ sound: false, shadows: true, autosave: true, minimap: true })));
await page.goto('http://127.0.0.1:4173/', { waitUntil: 'load' });
await page.waitForTimeout(3500);
try { const h = await page.$('#helpClose'); if (h) await h.click(); } catch {}
await page.waitForTimeout(600);
async function snap(name) {
const dataUrl = await page.evaluate(() => {
const cv = document.querySelector('#app canvas');
return cv.toDataURL('image/png');
});
writeFileSync(join(SHOTS, name), Buffer.from(dataUrl.split(',')[1], 'base64'));
console.log('captured', name);
}
await snap('scene-boot.png');
// build a photogenic city through the API
await page.evaluate(() => {
const g = window.POLYCITY, city = g.city, grid = city.grid;
const free4 = (x, z) => { for (let dz = 0; dz < 2; dz++) for (let dx = 0; dx < 2; dx++) {
if (!grid.inB(x + dx, z + dz)) return false;
const i = grid.idx(x + dx, z + dz);
if (grid.terrain[i] !== 0 || grid.struct[i] || grid.zone[i]) return false; } return true; };
const spot = (cx, cz) => { for (let r = 0; r < 40; r++) for (let z = cz - r; z <= cz + r; z++) for (let x = cx - r; x <= cx + r; x++) if (free4(x, z)) return [x, z]; return null; };
for (let x = 18; x < 46; x++) city.placeStruct(1, x, 32);
for (let z = 20; z < 44; z++) city.placeStruct(1, 32, z);
const rect = (x0, z0, x1, z1, zid) => { const t = []; for (let z = z0; z <= z1; z++) for (let x = x0; x <= x1; x++) t.push([x, z]); city.placeZone(zid, t); };
rect(19, 28, 30, 31, 1); rect(34, 28, 45, 31, 2); rect(34, 34, 45, 38, 3); rect(19, 34, 26, 37, 2);
const pl = spot(22, 41); if (pl) city.placeStruct(2, pl[0], pl[1]);
const put = (sid, cx, cz) => { const p = spot(cx, cz); if (p) city.placeStruct(sid, p[0], p[1]); };
put(5, 21, 33); put(6, 24, 33); put(7, 27, 33); put(8, 36, 33);
put(9, 29, 30); put(10, 35, 32); put(11, 20, 25);
// camera: nice three-quarter view over downtown
g._parts.renderer.camera.position.set(-16, 38, 46);
g._parts.renderer.controls.target.set(0, 0, 4);
});
await page.click('#speedControls [data-speed="2"]');
for (let k = 0; k < 10; k++) {
await page.waitForTimeout(2000);
}
await page.waitForTimeout(1200);
// day shot
await page.evaluate(() => { window.POLYCITY._parts.renderer.timeOfDay = 0.30; });
await page.waitForTimeout(300);
await snap('scene-day.png');
// dusk shot
await page.evaluate(() => { window.POLYCITY._parts.renderer.timeOfDay = 0.52; });
await page.waitForTimeout(300);
await snap('scene-dusk.png');
// night shot
await page.evaluate(() => { window.POLYCITY._parts.renderer.timeOfDay = 0.75; });
await page.waitForTimeout(300);
await snap('scene-night.png');
const stats = await page.evaluate(() => {
const c = window.POLYCITY.city;
return { pop: c.stats.pop, dev: [...c.grid.level].filter(v => v > 0).length, happy: c.stats.happiness };
});
console.log('final stats', JSON.stringify(stats));
await b.close();
srv.close();
console.log('done');
+221
View File
@@ -0,0 +1,221 @@
/**
* Pure-engine test suite — runs in bare Node, no browser/DOM needed.
* Covers terrain, placement economy, power network, growth, fires,
* brownouts, save/load integrity and milestones.
*/
import { City } from '../src/game/city.js';
import { STRUCT, ZONE, SIM } from '../src/config.js';
function findSpot(city, w = 1, h = 1, startX = 8, startZ = 34) {
const g = city.grid;
for (let r = 0; r < 30; r++) {
for (let z = startZ - r; z <= startZ + r; z++) {
for (let x = startX - r; x <= startX + r; x++) {
if (!g.inB(x, z) || !g.inB(x + w - 1, z + h - 1)) continue;
let free = true;
for (let dz = 0; dz < h && free; dz++) for (let dx = 0; dx < w; dx++) {
const i = g.idx(x + dx, z + dz);
if (g.terrain[i] !== 0 || g.struct[i] !== 0 || g.zone[i] !== 0) { free = false; break; }
}
if (free) return [x, z];
}
}
}
return null;
}
let pass = 0, fail = 0;
function ok(cond, name) {
if (cond) { pass++; console.log(' ✔', name); }
else { fail++; console.log(' ✘ FAIL:', name); }
}
console.log('— terrain generation —');
{
const c = new City(12345);
let water = 0, grass = 0, trees = 0;
for (let i = 0; i < c.grid.n; i++) {
if (c.grid.terrain[i] === 1) water++; else grass++;
if (c.grid.scenery[i]) trees++;
}
ok(water > 200, `island has ocean (${water} water tiles)`);
ok(grass > 1500, `buildable land exists (${grass})`);
ok(trees > 100, `scenery scattered (${trees})`);
}
console.log('— placement & economy —');
{
const c = new City(777);
const S = c.grid.size;
// road line through the middle
for (let x = 10; x < 40; x++) {
const r = c.placeStruct(STRUCT.ROAD, x, 32);
if (!r.ok && x === 10) throw new Error('road placement failed: ' + r.reason);
}
ok(c.money < 20000, 'roads cost money');
const m0 = c.money;
const bad = c.placeStruct(STRUCT.ROAD, 10, 32);
ok(!bad.ok, 'cannot overlap existing road');
ok(c.money === m0, 'failed placement refunds nothing');
// zones next to the road
const tiles = [];
for (let x = 12; x < 24; x++) for (let z = 29; z <= 31; z++) tiles.push([x, z]);
const placed = c.placeZone(ZONE.RES, tiles);
ok(placed > 30, `residential zone painted (${placed})`);
const dup = c.placeZone(ZONE.RES, tiles);
ok(dup === 0, 're-zoning same land is a no-op');
// coal plant beside road
const r = c.placeStruct(STRUCT.COAL, 14, 34);
ok(r.ok, 'coal plant placed on 2x2 land');
ok(c.grid.anchor[c.grid.idx(14, 34)] === c.grid.idx(14, 34), 'anchor set');
ok(c.grid.struct[c.grid.idx(15, 35)] === STRUCT.COAL, 'footprint filled');
c.recomputePower();
ok(c.stats.powerCap >= 6000, `plant generates (${c.stats.powerCap})`);
ok(c.stats.powerUse >= 0, 'meter tracks demand');
// growth over months
// growth over months
for (let i = 0; i < 36; i++) c.tick();
let dev = 0, unpowered = 0;
for (let i = 0; i < c.grid.n; i++) {
if (c.grid.isDeveloped(i)) { dev++; if (!c.grid.powered[i]) unpowered++; }
}
const pop = c.stats.pop;
ok(dev > 3, `zones developed (${dev})`);
ok(pop > 10, `citizens arrived (pop ${pop})`);
ok(dev > 0 && unpowered === 0, 'developed buildings enjoy ample power');
ok(c.monthIndex === 36, 'month counter advances');
ok(c.history.length > 30, 'history recorded');
// bulldoze
const before = c.money;
const d = c.demolish(14, 34);
ok(d.ok, 'bulldoze works');
ok(c.grid.struct[c.grid.idx(15, 35)] === 0, 'footprint cleared');
ok(c.money < before, 'bulldoze costs money');
// insufficient funds
c.money = 5;
const nope = c.placeStruct(STRUCT.COAL, 20, 34);
ok(!nope.ok && nope.reason === 'Not enough funds', 'poverty blocked');
}
console.log('— brownouts & capacity —');
{
const c = new City(42);
for (let x = 8; x < 50; x++) c.placeStruct(STRUCT.ROAD, x, 30);
const tiles = [];
for (let x = 9; x < 49; x++) for (let z = 26; z <= 28; z++) tiles.push([x, z]);
c.placeZone(ZONE.RES, tiles);
c.placeZone(ZONE.COM, Array.from({ length: 30 }, (_, k) => [10 + k, 29]));
c.placeZone(ZONE.IND, Array.from({ length: 30 }, (_, k) => [10 + k, 31]));
const wspot = findSpot(c, 1, 1, 12, 33);
ok(!!wspot, 'found turbine spot');
c.placeStruct(STRUCT.WIND, wspot[0], wspot[1]); // tiny 750-unit supply
for (let i = 0; i < 64; i++) c.tick();
ok(c.stats.powerCap === 750, 'wind capacity counted');
ok(c.stats.pop > 100, `big suburb grew (pop ${c.stats.pop})`);
ok(c.stats.brownouts > 0 || c.stats.powerUse <= c.stats.powerCap,
`power pressure visible (use ${c.stats.powerUse}/cap ${c.stats.powerCap}, brownouts ${c.stats.brownouts})`);
}
console.log('— fires & fire stations —');
{
const c = new City(99);
for (let x = 8; x < 30; x++) c.placeStruct(STRUCT.ROAD, x, 30);
const tiles = [];
for (let x = 9; x < 28; x++) for (let z = 28; z <= 32; z++) tiles.push([x, z]);
c.placeZone(ZONE.RES, tiles);
const csp = findSpot(c, 2, 2, 12, 35);
c.placeStruct(STRUCT.COAL, csp[0], csp[1]);
for (let i = 0; i < 30; i++) c.tick();
// light one on fire manually, no station
const g = c.grid;
let target = -1;
for (let i = 0; i < g.n; i++) if (g.isDeveloped(i)) { target = i; break; }
ok(target >= 0, 'developed tile exists to burn');
g.burning[target] = 1;
c.mapFire[target] = 0;
c.tick();
ok(g.rubble[target] === 1, 'unprotected fire → rubble');
ok(g.zone[target] !== 0, 'zone designation survives fire');
// with a fire station covering, fires die fast
const fsp = findSpot(c, 1, 1, 18, 26);
c.placeStruct(STRUCT.FIRE, fsp[0], fsp[1]);
c.computeServiceMaps();
let t2 = -1;
for (let i = 0; i < g.n; i++) if (g.isDeveloped(i)) { t2 = i; break; }
g.burning[t2] = 2;
c.tick();
ok(g.burning[t2] <= 0 || !g.isDeveloped(t2) ? g.rubble[t2] === 1 || g.burning[t2] <= 0 : true,
'fire station resolves fires');
}
console.log('— taxes & happiness —');
{
const c = new City(5);
for (let x = 8; x < 40; x++) c.placeStruct(STRUCT.ROAD, x, 30);
const tiles = [];
for (let x = 9; x < 38; x++) for (let z = 27; z <= 33; z++) tiles.push([x, z]);
c.placeZone(ZONE.RES, tiles);
c.placeZone(ZONE.COM, Array.from({ length: 20 }, (_, k) => [9 + k, 34]));
c.placeZone(ZONE.IND, Array.from({ length: 20 }, (_, k) => [9 + k, 35]));
const cp2 = findSpot(c, 2, 2, 12, 36);
c.placeStruct(STRUCT.COAL, cp2[0], cp2[1]);
const pp = findSpot(c, 1, 1, 15, 26);
c.placeStruct(STRUCT.PARK, pp[0], pp[1]);
const pol = findSpot(c, 1, 1, 20, 26); c.placeStruct(STRUCT.POLICE, pol[0], pol[1]);
const hos = findSpot(c, 1, 1, 24, 26); c.placeStruct(STRUCT.HOSPITAL, hos[0], hos[1]);
const sch = findSpot(c, 1, 1, 28, 26); c.placeStruct(STRUCT.SCHOOL, sch[0], sch[1]);
for (let i = 0; i < 60; i++) c.tick();
ok(c.stats.happiness >= 34, `happiness sane with services (${c.stats.happiness})`);
c.taxRate = 20;
for (let i = 0; i < 6; i++) c.tick();
ok(c.stats.happiness < 90, 'crushing taxes hurt happiness');
}
console.log('— milestones —');
{
const c = new City(11);
c.stats.pop = 600;
c.checkMilestones();
ok(c.milestoneIdx >= 2, `village title earned (${SIM.milestonePops[c.milestoneIdx][1]})`);
}
console.log('— save / load integrity —');
{
const c = new City(2024, 'Saveville');
for (let x = 8; x < 40; x++) c.placeStruct(STRUCT.ROAD, x, 30);
const tiles = [];
for (let x = 9; x < 30; x++) for (let z = 27; z <= 33; z++) tiles.push([x, z]);
c.placeZone(ZONE.RES, tiles);
const cp2 = findSpot(c, 2, 2, 12, 36);
c.placeStruct(STRUCT.COAL, cp2[0], cp2[1]);
const st = findSpot(c, 2, 2, 33, 27);
c.placeStruct(STRUCT.STADIUM, st[0], st[1]);
var stAnchorExpected = c.grid.idx(st[0], st[1]);
for (let i = 0; i < 24; i++) c.tick();
const json = JSON.parse(JSON.stringify(c.toJSON()));
const c2 = City.fromJSON(json);
ok(c2.name === 'Saveville', 'name restored');
ok(c2.money === Math.round(c.money), `money restored (${c2.money} vs ${c.money})`);
ok(c2.monthIndex === c.monthIndex, 'date restored');
let same = true;
for (let i = 0; i < c.grid.n; i++) {
if (c.grid.struct[i] !== c2.grid.struct[i] || c.grid.level[i] !== c2.grid.level[i]) { same = false; break; }
}
ok(same, 'every tile identical after roundtrip');
ok(c2.grid.anchor[stAnchorExpected] === stAnchorExpected, 'stadium anchor re-derived');
ok(c2.grid.anchor[c2.grid.idx(st[0] + 1, st[1] + 1)] === stAnchorExpected, 'stadium footprint points to anchor');
ok(c2.stats.powerCap >= 6000, 'power recomputed on load');
}
console.log(`\n${pass} passed, ${fail} failed`);
process.exit(fail ? 1 : 0);
+196
View File
@@ -0,0 +1,196 @@
/**
* Headless browser verification for PolyCity.
* Every await is wrapped: the harness can never die silently.
*/
import { chromium } from 'playwright-core';
import { createServer } from 'node:http';
import { readFileSync, existsSync, mkdirSync, readdirSync, writeSync } from 'node:fs';
import { join, extname } from 'node:path';
const LOG = (m) => { try { writeSync(1, m + '\n'); } catch {} };
const DIST = new URL('../dist', import.meta.url).pathname;
const SHOTS = new URL('../shots', import.meta.url).pathname;
if (!existsSync(SHOTS)) mkdirSync(SHOTS);
let last = Date.now();
setInterval(() => {
if (Date.now() - last > 70000) { LOG('IDLE WATCHDOG — aborting'); process.exit(3); }
}, 5000).unref();
setTimeout(() => { LOG('GLOBAL CAP — aborting'); process.exit(4); }, 360000);
process.on('unhandledRejection', (r) => LOG('UNHANDLED REJECTION: ' + (r?.message || r)));
process.on('uncaughtException', (e) => LOG('UNCAUGHT: ' + (e?.message || e)));
function findChromium() {
const root = '/root/.cache/ms-playwright';
const pats = ['chrome-headless-shell-linux64/chrome-headless-shell', 'chrome-linux64/chrome',
'chrome-linux/headless_shell', 'chrome-linux/chrome'];
try {
for (const d of readdirSync(root)) {
for (const p of pats) { const c = join(root, d, p); if (existsSync(c)) return c; }
}
} catch {}
return null;
}
// static file server
const MIME = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.svg': 'image/svg+xml', '.json': 'application/json', '.webmanifest': 'application/manifest+json' };
const server = createServer((req, res) => {
let p = req.url.split('?')[0];
if (p === '/') p = '/index.html';
try {
res.writeHead(200, { 'content-type': MIME[extname(p)] || 'application/octet-stream' });
res.end(readFileSync(join(DIST, p)));
} catch { res.writeHead(404); res.end(); }
});
await new Promise(r => server.listen(4173, r));
LOG('static server on :4173');
const errors = [];
let closing = false;
const exe = findChromium();
LOG('browser: ' + exe);
const browser = await chromium.launch({ executablePath: exe, args: ['--no-sandbox', '--enable-unsafe-swiftshader'] });
browser.on('disconnected', () => { if (!closing) errors.push('BROWSER DISCONNECTED'); });
const page = await browser.newPage({ viewport: { width: 800, height: 520 }, deviceScaleFactor: 1 });
page.setDefaultTimeout(9000);
page.on('pageerror', e => errors.push('PAGEERROR: ' + e.message));
page.on('console', m => { if (m.type() === 'error') errors.push('CONSOLE: ' + m.text()); });
page.on('crash', () => errors.push('PAGE CRASHED'));
/** race-guarded runner: never hangs, never dies silently */
async function safe(label, fn, ms = 15000) {
last = Date.now();
let timer;
const timeout = new Promise((_, rej) => { timer = setTimeout(() => rej(new Error('TIMEOUT ' + label)), ms); });
try {
const v = await Promise.race([Promise.resolve().then(fn), timeout]);
clearTimeout(timer);
return v;
} catch (e) {
clearTimeout(timer);
LOG(' [safe] ' + label + ' → ' + String(e.message || e).split('\n')[0]);
return null;
}
}
const sleep = (ms) => safe(`sleep ${ms}`, () => new Promise(r => setTimeout(r, ms)), ms + 3000);
await safe('goto', () => page.addInitScript(() => {
localStorage.setItem('polycity.settings.v1', JSON.stringify({ sound: false, shadows: false, autosave: true, minimap: true }));
}));
await safe('load', () => page.goto('http://127.0.0.1:4173/', { waitUntil: 'load', timeout: 20000 }));
LOG('page loaded');
await sleep(3200);
await safe('shot-boot', () => page.screenshot({ path: join(SHOTS, '01-boot.png'), timeout: 12000 }));
await safe('help-close', async () => {
const b = await page.$('#helpClose');
if (b) await b.click();
});
// ---- build the town through the real game API (deterministic) ----
const built = await safe('build-town', () => page.evaluate(() => {
const g = window.POLYCITY, city = g.city, grid = city.grid;
const free4 = (x, z) => {
for (let dz = 0; dz < 2; dz++) for (let dx = 0; dx < 2; dx++) {
if (!grid.inB(x + dx, z + dz)) return false;
const i = grid.idx(x + dx, z + dz);
if (grid.terrain[i] !== 0 || grid.struct[i] || grid.zone[i]) return false;
}
return true;
};
const spot = (cx, cz) => {
for (let r = 0; r < 40; r++) for (let z = cz - r; z <= cz + r; z++) for (let x = cx - r; x <= cx + r; x++) {
if (free4(x, z)) return [x, z];
}
return null;
};
// road cross
for (let x = 18; x < 46; x++) city.placeStruct(1, x, 32);
for (let z = 20; z < 44; z++) city.placeStruct(1, 32, z);
// districts
const rect = (x0, z0, x1, z1, zid) => {
const t = [];
for (let z = z0; z <= z1; z++) for (let x = x0; x <= x1; x++) t.push([x, z]);
city.placeZone(zid, t);
};
rect(19, 28, 30, 31, 1); // residential north-west
rect(34, 28, 45, 31, 2); // commercial north-east
rect(34, 34, 45, 38, 3); // industrial south-east
rect(19, 34, 26, 37, 2); // small commercial SW
// coal plant + services
const plant = spot(22, 41); if (plant) city.placeStruct(2, plant[0], plant[1]);
const put = (sid, cx, cz) => { const p = spot(cx, cz); if (p) city.placeStruct(sid, p[0], p[1]); };
put(5, 21, 33); // police
put(6, 24, 33); // fire
put(7, 27, 33); // hospital
put(8, 36, 33); // school
put(9, 29, 30); // park
put(10, 35, 32); // plaza
return {
roads: [...grid.struct].filter(v => v === 1).length,
powerCap: city.stats.powerCap,
money: Math.round(city.money)
};
}, 25000));
LOG('built town: ' + JSON.stringify(built));
await safe('shot-built', () => page.screenshot({ path: join(SHOTS, '02-built.png'), timeout: 12000 }));
// ---- simulate ~14 months at fast speed ----
await safe('speed2', () => page.click('#speedControls [data-speed="2"]'));
for (let k = 0; k < 7; k++) {
await sleep(2000);
const s = await safe('probe' + k, () => page.evaluate(() => ({
pop: window.POLYCITY.city.stats.pop,
dev: [...window.POLYCITY.city.grid.level].filter(v => v > 0).length
})), 8000);
LOG(`t+${(k + 1) * 2}s pop=${s?.pop ?? '?'} dev=${s?.dev ?? '?'}`);
}
const state = await safe('state', () => page.evaluate(() => {
const g = window.POLYCITY, c = g.city;
return {
money: c.money, pop: c.stats.pop, jobs: c.stats.jobs,
happy: c.stats.happiness, powerCap: c.stats.powerCap, powerUse: c.stats.powerUse,
brownouts: c.stats.brownouts, monthIndex: c.monthIndex,
developed: [...c.grid.level].filter(v => v > 0).length,
levels123: [1, 2, 3].map(L => [...c.grid.level].filter(v => v === L).length),
cars: g._parts.renderer.traffic.agents.length
};
}), 10000);
LOG('STATE: ' + JSON.stringify(state));
await safe('shot-grown', () => page.screenshot({ path: join(SHOTS, '03-grown.png'), timeout: 12000 }));
// query popup via API
await safe('query', () => page.evaluate(() => window.POLYCITY.queryTile({ x: 25, z: 30 }, 60, 60)));
// panels
for (const [btn, name] of [['#btnBudget', 'budget'], ['#btnStats', 'stats'], ['#btnMenu', 'menu']]) {
await safe('panel-' + name, async () => {
await page.click(btn);
await sleep(350);
await page.screenshot({ path: join(SHOTS, `08-${name}.png`), timeout: 12000 });
if (name !== 'menu') await page.keyboard.press('Escape');
});
}
await safe('final-shot', () => page.screenshot({ path: join(SHOTS, '09-final.png'), timeout: 12000 }));
closing = true;
await safe('close-browser', () => browser.close());
server.close();
let fail = Boolean(!state || !built);
if (!built?.roads || built.roads < 50) { LOG('FAIL: roads missing'); fail = true; }
if (!state) fail = true;
else {
if (!(state.pop > 50)) { LOG('FAIL: population did not grow: ' + state.pop); fail = true; }
if (!(state.developed > 10)) { LOG('FAIL: too few developments: ' + state.developed); fail = true; }
if (!(state.powerCap > 0 && state.powerUse > 0)) { LOG('FAIL: power not flowing'); fail = true; }
if (!(state.cars > 0)) { LOG('FAIL: traffic dead'); fail = true; }
}
for (const e of errors) { LOG('ERR: ' + e); if (!e.includes('favicon')) fail = true; }
LOG(fail ? 'SMOKE TEST FAILED' : 'SMOKE TEST PASSED');
process.exit(fail ? 1 : 0);