/** * 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'); } // ---------------- tornado disasters ---------------- { const c = new City(777, 'Tornadoville'); 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 cp = findSpot(c, 2, 2, 12, 36); c.placeStruct(STRUCT.COAL, cp[0], cp[1]); for (let i = 0; i < 10; i++) c.tick(); const devBefore = [...c.grid.level].filter(v => v > 0).length; ok(devBefore > 0, 'town exists before tornado'); let eventFired = false; c.on('disaster', (e) => { if (e.type === 'tornado' && e.destroyed > 0) eventFired = true; }); c.spawnTornado(); const rubbleAfter = [...c.grid.rubble].filter(v => v > 0).length; ok(eventFired, 'disaster event emitted with damage count'); ok(rubbleAfter > 0, `tornado left rubble behind (${rubbleAfter} tiles)`); let anchorBad = 0; for (let i = 0; i < c.grid.n; i++) { const s = c.grid.struct[i]; if (!s || s === STRUCT.ROAD || c.grid.anchor[i] !== i) continue; if (c.grid.struct[c.grid.anchor[i]] !== s) anchorBad++; } ok(anchorBad === 0, 'anchors consistent after tornado destruction'); // disabled flag suppresses natural spawning c.disastersEnabled = false; let spawnedWhileOff = false; const origSpawn = c.spawnTornado.bind(c); c.spawnTornado = () => { spawnedWhileOff = true; return origSpawn(); }; c.monthIndex = 60; for (let m = 0; m < 80; m++) c.tick(); ok(!spawnedWhileOff, 'disabled flag prevents tornado spawns'); } console.log(`\n${pass} passed, ${fail} failed`); process.exit(fail ? 1 : 0);