IMPERIUM ARCANUM — web-based Caesar 3-like city builder with heroes, magic & monsters

- Full classic layer: roads/walkers, 11-tier housing evolution, food chain,
  industry chains, trade routes, taxes/wages, ratings, fires & collapse
- Fantasy layer: heroes (XP/levels/auras), mana & 7 spells, monsters/invasions,
  walls/towers/legions/golems, campaign of 5 scenarios + endless sandbox
- Canvas2D isometric engine with procedural sprites, zero runtime deps
- Save/load slots + autosave, WebAudio synth music/SFX, HUD/menus/minimap
- Tests: headless sim smoke test + Playwright E2E (build/combat/save)
This commit is contained in:
deepseek
2026-08-23 07:00:05 +00:00
commit b642726428
38 changed files with 7288 additions and 0 deletions
+35
View File
@@ -0,0 +1,35 @@
// Browser smoke test: load game, start sandbox, watch for console errors.
import { chromium } from 'playwright-core';
const exe = '/root/.cache/ms-playwright/chromium_headless_shell-1148/chrome-linux/headless_shell';
const errors = [];
const browser = await chromium.launch({ executablePath: exe, args: ['--no-sandbox', '--disable-gpu'] });
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
page.on('console', m => { if (m.type() === 'error') errors.push(m.text()); });
page.on('pageerror', e => errors.push('PAGEERROR: ' + e.message));
await page.goto('http://127.0.0.1:5173/', { waitUntil: 'networkidle' });
await page.waitForTimeout(1200);
await page.screenshot({ path: '/tmp/shot_menu.png' });
// start sandbox
await page.click('#mm-sandbox');
await page.waitForTimeout(2500);
await page.screenshot({ path: '/tmp/shot_game.png' });
// try placing a road via clicks on canvas (center-ish drag)
const cv = await page.$('#world');
const box = await cv.boundingBox();
console.log('canvas at', box);
// select housing tab then road card
const tabs = await page.$$('#cattabs .cattab');
console.log('tabs:', tabs.length);
// speed up time & let it run
for (let i = 0; i < 6; i++) { await page.mouse.click(box.x + box.width / 2, box.y + 60); await page.waitForTimeout(300); }
await page.screenshot({ path: '/tmp/shot_later.png' });
console.log('CONSOLE ERRORS:', errors.length);
errors.slice(0, 12).forEach(e => console.log(' -', e.slice(0, 220)));
await browser.close();
process.exit(errors.length ? 1 : 0);
+8
View File
@@ -0,0 +1,8 @@
import pkg from '../node_modules/.pnpm/esbuild@0.21.5/node_modules/esbuild/lib/main.js';
const { build } = pkg;
await build({
entryPoints: ['scripts/simtest.ts'],
bundle: true, format: 'esm', platform: 'node',
outfile: '/tmp/simtest.mjs', logLevel: 'warning',
});
console.log('bundled ok');
+126
View File
@@ -0,0 +1,126 @@
// Combat / heroes / magic / save-load E2E
import { chromium } from 'playwright-core';
const exe = '/root/.cache/ms-playwright/chromium_headless_shell-1148/chrome-linux/headless_shell';
const BASE = process.env.BASE || 'http://127.0.0.1:5173/';
const errors = [];
const browser = await chromium.launch({ executablePath: exe, args: ['--no-sandbox'] });
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
page.on('console', m => { if (m.type() === 'error') errors.push(m.text()); });
page.on('pageerror', e => errors.push('PAGEERROR: ' + e.message));
await page.goto(BASE, { waitUntil: 'networkidle' });
await page.waitForTimeout(600);
await page.click('#mm-sandbox');
await page.waitForTimeout(900);
// minimal town + defenses + hero hall
const setup = await page.evaluate(() => {
const I = window.__IA;
const c = I.app.city;
let ox = -1, oy = -1;
outer:
for (let y = 8; y < c.H - 14; y++) for (let x = 4; x < c.W - 24; x++) {
let ok = true;
for (let dy = -2; dy < 9 && ok; dy++) for (let dx = -2; dx < 20; dx++) {
const t = c.tiles[(y + dy) * c.W + (x + dx)];
if (!t || t.terrain === 'water') { ok = false; break; }
}
if (ok) { ox = x; oy = y; break outer; }
}
for (let i = 0; i < 14; i++) I.place('road', ox + i, oy);
for (let i = 1; i < 13; i++) I.place('house', ox + i, oy - 1);
I.place('well', ox + 2, oy + 1);
I.place('market', ox + 4, oy + 1);
I.place('granary', ox + 7, oy + 1);
const placeNear = (key, cx, cy, R = 7) => {
for (let dy = -R; dy <= R; dy++) for (let dx = -R; dx <= R; dx++) {
if (I.place(key, cx + dx, cy + dy).ok) return true;
}
return false;
};
for (let i = 0; i < 8; i++) I.place('wall', ox + 2 + i, oy + 4);
I.place('tower', ox + 4, oy + 5);
placeNear('fort', ox + 11, oy + 5);
placeNear('heroHall', ox + 13, oy - 3);
I.give('weapons', 60);
I.give('iron', 100);
return { ox, oy, hall: [...c.buildings.values()].some(b => b.def === 'heroHall'), fort: [...c.buildings.values()].some(b => b.def === 'fort') };
});
console.log('setup:', JSON.stringify(setup));
// run to month 8 so fort can recruit & pop exists
await page.evaluate(() => { const I = window.__IA; for (let i = 0; i < 8 * 20; i++) I.step(0.5); });
const mid = await page.evaluate(() => {
const c = window.__IA.app.city;
return { pop: c.population, legions: [...c.walkers.values()].filter(w => w.unit?.kind === 'soldiers').length };
});
console.log('mid:', JSON.stringify(mid));
// summon hero
const heroOk = await page.evaluate(() => {
const I = window.__IA;
I.app.city.funds += 5000; // ensure affordable for test
return I.summon('champion');
});
console.log('summoned:', heroOk);
// spawn a hydra in front of the walls and let combat resolve
await page.evaluate((pos) => {
const I = window.__IA;
I.spawnWave('hydra', 1, 1);
// teleport hydra near the town for a quick fight
const foe = [...I.app.city.walkers.values()].find(w => w.unit?.side === 'foe');
if (foe) { foe.x = pos.ox + 6; foe.y = pos.oy + 6; }
}, setup);
// cast firestorm at the foe location (grant spell first)
const castRes = await page.evaluate((pos) => {
const I = window.__IA;
I.grantSpell('firestorm');
I.app.city.mana = 500;
return I.cast('firestorm', pos.ox + 7, pos.oy + 7);
}, setup);
console.log('firestorm cast:', castRes);
// step until hydra dies (max ~60s game time)
const fight = await page.evaluate(() => {
const I = window.__IA;
let slain0 = I.app.city.stats.monstersSlain;
for (let i = 0; i < 120; i++) {
I.step(0.5);
const foes = [...I.app.city.walkers.values()].filter(w => w.unit?.side === 'foe');
if (foes.length === 0) break;
}
return {
slain: I.app.city.stats.monstersSlain - slain0,
foesLeft: [...I.app.city.walkers.values()].filter(w => w.unit?.side === 'foe').length,
heroAlive: [...I.app.city.walkers.values()].some(w => w.kind === 'hero'),
golems: [...c_walkers(I)].length,
};
function c_walkers(I) { return []; }
});
console.log('fight:', JSON.stringify(fight));
// SAVE / LOAD round-trip
const before = await page.evaluate(() => {
const I = window.__IA;
I.save();
return { month: I.app.city.month, pop: I.app.city.population };
});
await page.reload({ waitUntil: 'networkidle' });
await page.waitForTimeout(700);
const after = await page.evaluate(() => {
const I = window.__IA;
const ok = I.loadAutosave();
return { ok, month: I.app?.city.month ?? -1, pop: I.app?.city.population ?? -1, before: null };
});
console.log(`save/load: before=${JSON.stringify(before)} after=${JSON.stringify(after)}`);
console.log('CONSOLE ERRORS:', errors.length);
errors.slice(0, 8).forEach(e => console.log(' -', e.slice(0, 180)));
const pass = heroOk && castRes && fight.slain >= 1 && after.ok && Math.abs(after.month - before.month) <= 1 && errors.length === 0;
console.log(pass ? '✅ COMBAT/MAGIC/SAVE E2E PASS' : '❌ FAIL');
await browser.close();
process.exit(pass ? 0 : 1);
+42
View File
@@ -0,0 +1,42 @@
import { CAMPAIGN } from '../src/data/scenarios';
import { createCity, tickCity, placeBuilding } from '../src/sim/city';
import { ensureSimHooks } from '../src/sim/hooks';
const c = createCity(CAMPAIGN[0]);
ensureSimHooks();
let ox=-1, oy=-1;
outer: for (let y=8;y<c.H-12;y++) for (let x=8;x<c.W-14;x++){
let ok=true;
for (let dy=-2; dy<8 && ok; dy++) for (let dx=-2;dx<26;dx++){
const t=c.tiles[(y+dy)*c.W+(x+dx)];
if(!t||t.terrain==='water'||t.terrain==='tree'||t.terrain==='rock'){ok=false;break;}
}
if(ok){ox=x;oy=y;break outer;}
}
for(let i=0;i<22;i++) placeBuilding(c,'road',ox+i,oy);
for(let i=1;i<21;i++) placeBuilding(c,'house',ox+i,oy-1);
placeBuilding(c,'well',ox+2,oy+1);
placeBuilding(c,'market',ox+4,oy+1);
placeBuilding(c,'granary',ox+8,oy+1);
placeBuilding(c,'prefecture',ox+12,oy+1);
let farmPlaced=false;
for (const [fdx,fdy] of [[15,1],[14,1],[16,4],[13,4]] as const){
if(placeBuilding(c,'farmWheat',ox+fdx,oy+fdy).ok){farmPlaced=true;console.log('farm at',fdx,fdy);break;}
}
const dump=(label:string)=>{
const f=[...c.buildings.values()].find(b=>b.def==='farmWheat')!;
const g=[...c.buildings.values()].find(b=>b.def==='granary')!;
const m=[...c.buildings.values()].find(b=>b.def==='market')!;
console.log(`--- ${label} (month ${c.month})`);
console.log(`farm workers=${f.workersLive} progress=${f.progress.toFixed(2)} wheat=${f.inv['wheat']??0}`);
console.log(`granary wheat=${g.inv['wheat']??0}`);
console.log(`market workers=${m.workersLive} inv=`, JSON.stringify(m.inv));
const wk=[...c.walkers.values()];
console.log('walkers:', wk.map(w=>`${w.kind}${w.cargo?'('+JSON.stringify(w.cargo)+')':''}`).slice(0,12).join(', '));
const hs=[...c.buildings.values()].filter(b=>b.def==='house');
const fed=hs.filter(h=>((h.goodsStock?.['wheat']??0)+(h.goodsStock?.['fruit']??0))>0).length;
console.log(`houses=${hs.length} residents=${hs.reduce((a,b)=>a+b.residents,0)} fed=${fed}`);
};
dump('start');
for (let i=0;i<20*20;i++){ tickCity(c,CAMPAIGN[0],0.5); if(i===199||i===399) dump(`t+${Math.round(i*0.5)}s`); }
+89
View File
@@ -0,0 +1,89 @@
// Deep E2E: start sandbox, build a working town via __IA hook, fast-forward months, assert state.
import { chromium } from 'playwright-core';
const exe = '/root/.cache/ms-playwright/chromium_headless_shell-1148/chrome-linux/headless_shell';
const errors = [];
const browser = await chromium.launch({ executablePath: exe, args: ['--no-sandbox', '--disable-gpu'] });
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
page.on('console', m => { if (m.type() === 'error') errors.push(m.text()); });
page.on('pageerror', e => errors.push('PAGEERROR: ' + e.message));
const BASE = process.env.BASE || 'http://127.0.0.1:5173/';
await page.goto(BASE, { waitUntil: 'networkidle' });
await page.waitForTimeout(800);
await page.click('#mm-sandbox');
await page.waitForTimeout(1200);
// Build town programmatically
const result = await page.evaluate(() => {
const I = window.__IA;
const c = I.app.city;
// find flat grass area
let ox = -1, oy = -1;
outer:
for (let y = 8; y < c.H - 12; y++) for (let x = 4; x < c.W - 24; x++) {
let ok = true, fertileNear = false;
for (let dy = -2; dy < 9 && ok; dy++) for (let dx = -2; dx < 21; dx++) {
const t = c.tiles[(y + dy) * c.W + (x + dx)];
if (!t || t.terrain === 'water') { ok = false; break; }
if (t.terrain === 'fertile' && t.fert > 30) fertileNear = true;
}
if (ok && fertileNear) { ox = x; oy = y; break outer; }
}
if (ox < 0) return { fail: 'no spot' };
for (let i = 0; i < 20; i++) I.place('road', ox + i, oy);
for (let i = 1; i < 19; i++) I.place('house', ox + i, oy - 1);
I.place('well', ox + 2, oy + 1);
I.place('market', ox + 4, oy + 1);
I.place('granary', ox + 7, oy + 1);
I.place('prefecture', ox + 11, oy + 1);
I.place('forum', ox + 12, oy - 2) || I.place('forum', ox + 13, oy - 3);
// farm directly below the road row -> top edge touches road => connected
const fr = I.place('farmWheat', ox + 15, oy + 1);
const farmPlaced = !!fr.ok;
if (!farmPlaced) {
for (const [fdx, fdy] of [[13, 1], [17, 1], [15, -4], [12, 1], [18, 1]]) {
if (I.place('farmWheat', ox + fdx, oy + fdy).ok) { farmPlaced = true; break; }
}
}
if (!farmPlaced) return { fail: 'no road-connected farm spot' };
return { ox, oy, funds: Math.round(c.funds), pop: c.population };
});
console.log('build result:', JSON.stringify(result));
if (result.fail) { console.log('BUILD FAILED'); process.exit(1); }
// Fast-forward 30 months synchronously
const ff = await page.evaluate(() => {
const I = window.__IA;
for (let i = 0; i < 30 * 20; i++) I.step(0.5);
const c = I.app.city;
const houses = [...c.buildings.values()].filter(b => b.def === 'house');
return {
month: c.month, pop: c.population, funds: Math.round(c.funds),
employed: c.employed, laborers: c.laborers,
sentiment: c.sentiment, ratings: c.ratings,
avgLevel: (houses.reduce((a, b) => a + b.houseLevel, 0) / Math.max(1, houses.length)).toFixed(2),
walkers: c.walkers.size,
granaryWheat: [...c.buildings.values()].filter(b => b.def === 'granary').reduce((a, b) => a + (b.inv.wheat ?? 0), 0),
};
});
console.log('after 30mo:', JSON.stringify(ff));
// Now test spells & heroes UI presence
const ui = await page.evaluate(() => ({
spellCount: document.querySelectorAll('#spellbar .spell').length,
catTabs: document.querySelectorAll('#cattabs .cattab').length,
minimap: !!document.getElementById('minimap'),
topFunds: document.getElementById('t-funds')?.textContent,
}));
console.log('ui:', JSON.stringify(ui));
await page.screenshot({ path: '/tmp/shot_town.png' });
console.log('CONSOLE ERRORS:', errors.length);
errors.slice(0, 10).forEach(e => console.log(' -', e.slice(0, 200)));
const pass = ff.pop > 40 && ff.month >= 28 && ui.spellCount === 7 && errors.length === 0;
console.log(pass ? '✅ E2E PASS' : '❌ E2E FAIL');
await browser.close();
process.exit(pass ? 0 : 1);
+98
View File
@@ -0,0 +1,98 @@
// Reproduce: real mouse clicks to build a road + house
import { chromium } from 'playwright-core';
const exe = '/root/.cache/ms-playwright/chromium_headless_shell-1148/chrome-linux/headless_shell';
const BASE = process.env.BASE || 'http://127.0.0.1:4174/';
const errors = [];
const browser = await chromium.launch({ executablePath: exe, args: ['--no-sandbox'] });
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
page.on('console', m => { if (m.type() === 'error') errors.push(m.text()); });
page.on('pageerror', e => errors.push('PAGEERROR: ' + e.message));
await page.goto(BASE, { waitUntil: 'networkidle' });
await page.waitForTimeout(600);
await page.click('#mm-sandbox');
await page.waitForTimeout(900);
// helper: convert tile -> screen px using exposed camera
async function tileToScreen(tx, ty) {
return page.evaluate(([x, y]) => {
const app = window.__IA.app;
const [wx, wy] = app.cam.tileToWorld(x, y);
const [sx, sy] = app.cam.worldToScreen(wx, wy);
return [sx, sy];
}, [tx, ty]);
}
async function clickTile(x, y) {
const [sx, sy] = await tileToScreen(x, y);
await page.mouse.move(sx, sy);
await page.waitForTimeout(80);
await page.mouse.click(sx, sy);
await page.waitForTimeout(60);
}
// 1) pick road tool via UI card
await page.click('#cattabs .cattab[data-cat="housing"]');
await page.waitForTimeout(200);
const cards = await page.$$('#buildcards .bcard');
console.log('cards:', cards.length);
for (const c of cards) console.log(' card:', (await c.textContent()).trim().slice(0, 30));
// find road card by title text
let roadCard = null;
for (const c of cards) { if ((await c.textContent()).includes('Road')) { roadCard = c; break; } }
await roadCard.click();
await page.waitForTimeout(150);
const toolState1 = await page.evaluate(() => JSON.stringify(window.__IA.app.tool));
console.log('tool after road select:', toolState1);
// center camera on map middle
await page.evaluate(() => {
const app = window.__IA.app;
const mid = app.cam.tileToWorld(app.city.W / 2, app.city.H / 2);
app.cam.cx = mid[0]; app.cam.cy = mid[1];
});
await page.waitForTimeout(100);
// place road at center tile
const c0 = Math.floor(await page.evaluate(() => window.__IA.app.city.W / 2));
await clickTile(c0, c0);
const afterRoad = await page.evaluate(([x, y]) => {
const t = window.__IA.app.city.tiles[y * window.__IA.app.city.W + x];
return { road: t.road, funds: window.__IA.app.city.funds };
}, [c0, c0]);
console.log('after road click:', JSON.stringify(afterRoad));
// 2) select house card and click NEXT to the road
let houseCard = null;
for (const c of await page.$$('#buildcards .bcard')) { if ((await c.textContent()).includes('House')) { houseCard = c; break; } }
await houseCard.click();
await page.waitForTimeout(150);
console.log('tool after house select:', await page.evaluate(() => JSON.stringify(window.__IA.app.tool)));
await clickTile(c0, c0 - 1); // directly above road
const afterHouse = await page.evaluate(([x, y]) => {
const c = window.__IA.app.city;
const t = c.tiles[y * c.W + x];
const b = t.bld ? c.buildings.get(t.bld) : null;
return { bld: t.bld, def: b?.def ?? null, funds: Math.round(c.funds) };
}, [c0, c0 - 1]);
console.log('after house click:', JSON.stringify(afterHouse));
// ghost validity debug at that spot
const ghostDbg = await page.evaluate(([x, y]) => {
const app = window.__IA.app;
// replicate canPlace pieces
const c = app.city;
const t = c.tiles[y * c.W + x];
let roadAdj = false;
for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) {
const n = c.tiles[(y + dy) * c.W + (x + dx)];
if (n && (n.road || n.plaza)) roadAdj = true;
}
return { terrain: t.terrain, bldHere: t.bld, roadAdj, tool: app.tool, ghost: app.view.ghost };
}, [c0, c0 - 1]);
console.log('ghost debug:', JSON.stringify(ghostDbg));
await page.screenshot({ path: '/tmp/build_repro.png' });
console.log('errors:', errors.length ? errors.slice(0, 5) : 0);
await browser.close();
+107
View File
@@ -0,0 +1,107 @@
// Headless simulation smoke test (run via esbuild bundle + node)
import { CAMPAIGN } from '../src/data/scenarios';
import { createCity, tickCity, placeBuilding } from '../src/sim/city';
import { canPlace } from '../src/sim/city';
import { ensureSimHooks } from '../src/sim/hooks';
function fail(msg: string): never {
console.error('❌ FAIL:', msg);
process.exit(1);
}
const sc = CAMPAIGN[0];
const c = createCity(sc);
ensureSimHooks();
// find a decent flat spot near center: scan for grass area
let ox = -1, oy = -1;
outer:
for (let y = 8; y < c.H - 12; y++) {
for (let x = 8; x < c.W - 14; x++) {
let ok = true;
for (let dy = -2; dy < 8 && ok; dy++) for (let dx = -2; dx < 16; dx++) {
const t = c.tiles[(y + dy) * c.W + (x + dx)];
if (!t || t.terrain === 'water' || t.terrain === 'tree' || t.terrain === 'rock') { ok = false; break; }
}
if (ok) { ox = x; oy = y; break outer; }
}
}
if (ox < 0) fail('no buildable spot found');
console.log(`build spot at ${ox},${oy}`);
// lay a long main road east-west
for (let i = 0; i < 22; i++) {
if (!placeBuilding(c, 'road', ox + i, oy).ok) fail('road placement failed at +' + i);
}
// houses along north side
for (let i = 1; i < 21; i++) {
const r = placeBuilding(c, 'house', ox + i, oy - 1);
if (!r.ok) console.warn('house skip', ox + i, oy - 1);
}
// south side: services
const mk = (k: string, dx: number, dy: number) => {
const r = placeBuilding(c, k, ox + dx, oy + dy);
if (!r.ok && !['farmWheat', 'fishingWharf', 'clayPit', 'timberCamp', 'ironMine', 'crystalMine', 'leyPylon'].includes(k)) fail(k + ' placement failed: ' + r.reason);
};
mk('well', 2, 1);
mk('market', 4, 1);
mk('granary', 8, 1);
mk('prefecture', 12, 1);
// farms near the road row (must touch a road to get workers)
let farmOk = false;
const farmSpots: Array<readonly [number, number]> = [
[15, 1], [16, 1], [14, 1], [17, 1], [13, 1],
[15, 4], [17, 4], [13, 4], [18, 1], [12, 1],
];
for (const [fdx, fdy] of farmSpots) {
if (farmOk) break;
if (canPlace(c, 'farmWheat', ox + fdx, oy + fdy) === true) {
const r = placeBuilding(c, 'farmWheat', ox + fdx, oy + fdy);
if (r.ok) { console.log(`farm at ${ox + fdx},${oy + fdy}`); farmOk = true; }
}
}
if (!farmOk) {
// widen: anywhere that touches the road row
for (let x = 2; x < c.W - 5 && !farmOk; x++) {
for (const dy of [1, -4] as const) {
if (canPlace(c, 'farmWheat', x, oy + dy) === true) {
const r = placeBuilding(c, 'farmWheat', x, oy + dy);
if (r.ok) { console.log('wide-scan farm at', x, oy + dy); farmOk = true; break; }
}
}
}
}
if (!farmOk) fail('no road-connected farm spot found');
console.log('initial funds', Math.round(c.funds));
// simulate 30 months (~300s of game time at speed idx 2)
let monthsSeen = c.month;
try {
for (let step = 0; step < 34 * 20 + 60; step++) {
tickCity(c, sc, 0.5); // dt seconds per iteration
if (step % 200 === 0 && step > 0) console.log('step', step, 'month', c.month, 'walkers', c.walkers.size);
if (c.month > monthsSeen) {
monthsSeen = c.month;
if (monthsSeen % 6 === 0) {
console.log(`month ${monthsSeen}: pop=${c.population} funds=${Math.round(c.funds)} sentiment=${c.sentiment} ratings=C${c.ratings.culture}/P${c.ratings.prosperity}/Pe${c.ratings.peace}/F${c.ratings.favor}`);
}
}
if (!Number.isFinite(c.funds)) fail('funds diverged');
}
} catch (e) { console.error('EXCEPTION IN LOOP:', e); fail(String(e)); }
console.log('LOOP DONE, months=' + c.month);
console.log('--- after ~30 months ---');
console.log(`pop=${c.population} funds=${Math.round(c.funds)} employed=${c.employed}/${c.laborers}`);
console.log('walkers alive:', c.walkers.size);
console.log('messages:', c.messages.slice(0, 8).map(m => m.text));
const houses = [...c.buildings.values()].filter(b => b.def === 'house');
const levels = houses.map(h => h.houseLevel);
console.log('houses:', houses.length, 'avg level:', (levels.reduce((a, b) => a + b, 0) / Math.max(1, levels.length)).toFixed(2), 'max:', Math.max(0, ...levels));
console.log('residents total:', houses.reduce((a, b) => a + b.residents, 0));
if (c.month < 25) fail(`only ${c.month} months advanced`);
if (c.population <= 0 && monthsSeen > 12) fail('population never grew');
if (monthsSeen >= 25 && c.population < 25) fail(`city collapsing: pop ${c.population} at month ${monthsSeen}`);
console.log('✅ SIM SMOKE TEST PASSED');