Files
polycity/tests/smoke.mjs
T
PolyCity 209d8efdf5 Professional City Advisor rewrite + wall-clock simulation
Advisor v2:
- Phased policy (founding/village/town/city) with scaling reserves
- Maximal-coverage siting: services score candidate spots by count of
  previously-uncovered developed buildings in radius
- RCI gradient genesis: homes north, commerce buffering, industry
  set back 2 extra tiles; per-tile separation filters on new districts
- District placement scored by runway length and distance from housing
- Power sized to PROJECTED demand (history-smoothed growth x14 months)
  with tech diversity cap (<=3 per type) and home-distance scoring
- Collector roads: cluster orphaned plots, one straight L-path
- Budget discipline: one big-ticket purchase max per pass

Simulation core:
- City clock now advances in wall time (tick-guard bounded), decoupled
  from render fps — slow devices no longer freeze the city
- Smoke test polls in game-time (monthIndex targets), not wall time
2026-08-23 04:59:48 +00:00

214 lines
8.8 KiB
JavaScript

/**
* 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-linux/headless_shell',
'chrome-linux64/chrome', '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(4175, r));
LOG('static server on :4175');
const errors = [];
let closing = false;
const exe = findChromium();
LOG('browser: ' + exe);
const browser = await chromium.launch({ executablePath: exe, args: ['--no-sandbox', '--enable-unsafe-swiftshader',
'--disable-backgrounding-occluded-windows', '--disable-renderer-backgrounding',
'--disable-background-timer-throttling', '--run-all-compositor-stages-before-draw'] });
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:4175/', { waitUntil: 'load', timeout: 20000 }));
LOG('page loaded');
await sleep(3200);
let shotsBroken = false;
async function shot(name) {
if (shotsBroken) return;
const okS = await safe('shot-' + name, () => page.screenshot({ path: join(SHOTS, name), timeout: 12000 }), 14000);
if (!okS && okS !== undefined) {}
if (okS === null) { shotsBroken = true; LOG(' (screenshots stalled on this browser — skipping rest)'); }
}
await shot('01-boot.png');
await safe('help-close', () => page.evaluate(() => {
const m = document.querySelector('#helpClose');
if (m) m.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', () => shot(join(SHOTS,'x') || { path: join(SHOTS, '02-built.png'), timeout: 12000 }));
// ---- simulate ~14 months at fast speed ----
await safe('speed2', () => page.evaluate(() => window.POLYCITY.setSpeed(2)));
// wait in GAME time, not wall time: environments render at wildly
// different speeds, so poll until the city has lived >= 8 months
for (let k = 0; k < 24; 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,
m: window.POLYCITY.city.monthIndex
})), 8000);
LOG(`probe${k}: month=${s?.m ?? '?'} pop=${s?.pop ?? '?'} dev=${s?.dev ?? '?'}`);
if (s && s.m >= 8 && s.pop > 40) { LOG('sim target reached'); break; }
}
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', () => shot(join(SHOTS,'x') || { 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 name of ['budget', 'stats', 'menu']) {
await safe('panel-' + name, async () => {
await page.evaluate((n) => {
const u = window.POLYCITY.ui;
if (n === 'budget') u.toggleSidePanel('budget');
else if (n === 'stats') u.toggleSidePanel('stats');
else u.menuModal();
}, name);
await sleep(400);
await shot(join(SHOTS,'x') || { path: join(SHOTS, `08-${name}.png`), timeout: 12000 });
});
}
await safe('final-shot', () => shot(join(SHOTS,'x') || { 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 > 40)) { 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('WARN: traffic agents idle on this browser');
}
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);