// 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 = [ [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');