/** * 30-year soak test: builds a realistic city, runs the simulation for * 360 months and asserts long-run stability, progression, power pressure * recovery, save/load mid-run integrity and per-tick performance. */ import { City } from '../src/game/city.js'; import { STRUCT, ZONE } from '../src/config.js'; let pass = 0, fail = 0; const ok = (cond, name) => { if (cond) { pass++; console.log(' ✔', name); } else { fail++; console.log(' ✘ FAIL:', name); } }; const c = new City(90210, 'Soakville'); c.disastersEnabled = false; // economy focus here; tornado behaviour is covered by engine.mjs const g = c.grid; c.money = 60000; // --- infrastructure: avenue + branches --- for (let x = 12; x < 52; x++) c.placeStruct(STRUCT.ROAD, x, 32); for (let z = 16; z < 48; z++) c.placeStruct(STRUCT.ROAD, 32, z); for (let z = 20; z < 30; z++) { c.placeStruct(STRUCT.ROAD, 18, z); c.placeStruct(STRUCT.ROAD, 46, z); } for (let z = 34; z < 44; z++) { c.placeStruct(STRUCT.ROAD, 18, z); c.placeStruct(STRUCT.ROAD, 46, 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]); c.placeZone(zid, t); }; // road-hugging bands (mayors zone what roads can serve) rect(28, 24, 36, 31, ZONE.RES); rect(13, 28, 17, 36, ZONE.RES); rect(19, 28, 23, 36, ZONE.RES); rect(47, 28, 51, 36, ZONE.RES); rect(41, 28, 45, 36, ZONE.RES); rect(33, 20, 51, 24, ZONE.COM); rect(14, 38, 22, 43, ZONE.COM); rect(42, 38, 51, 43, ZONE.IND); rect(29, 34, 35, 43, ZONE.IND); rect(24, 16, 28, 22, ZONE.RES); rect(36, 16, 40, 22, ZONE.RES); rect(13, 38, 17, 46, ZONE.RES); const freeSpot = (w, h) => { for (let r = 2; r < 40; r++) { for (let cz = 32 - r; cz <= 32 + r; cz += 2) for (let cx = 32 - r; cx <= 32 + r; cx += 2) { let okSpot = true; for (let dz = -1; dz <= h && okSpot; dz++) for (let dx = -1; dx <= w; dx++) { const x = cx + dx, z = cz + dz; if (!g.inB(x, z)) { okSpot = false; break; } const i = g.idx(x, z); if (g.terrain[i] !== 0 || g.struct[i] || g.zone[i]) { okSpot = false; break; } } if (okSpot) return [cx, cz]; } } return null; }; const put = (sid, w = 1, h = 1) => { const p = freeSpot(w, h); return p ? (c.placeStruct(sid, p[0], p[1]).ok ? p : null) : null; }; put(STRUCT.WIND); // humble wind turbine start // services right inside the neighborhoods (legal since buildings claim empty zones) c.placeStruct(STRUCT.POLICE, 26, 32); // just off avenue c.placeStruct(STRUCT.FIRE, 38, 32); c.placeStruct(STRUCT.HOSPITAL, 24, 33); c.placeStruct(STRUCT.SCHOOL, 40, 33); const parkP = [[30, 28], [34, 28], [22, 36], [44, 36], [31, 25]]; for (const [px, pz] of parkP) c.placeStruct(STRUCT.PARK, px, pz); c.placeStruct(STRUCT.PLAZA, 33, 30); console.log('— 360-month soak —'); let maxTick = 0, tickSum = 0; let firesSeen = 0, plantsBuilt = 0; let minMoney = Infinity, minHappy = 100; for (let m = 1; m <= 480; m++) { const t0 = performance.now(); c.tick(); const dt = performance.now() - t0; tickSum += dt; if (dt > maxTick) maxTick = dt; firesSeen = Math.max(firesSeen, [...g.burning].filter(v => v > 0).length > 0 ? 1 : 0) || firesSeen; minMoney = Math.min(minMoney, c.money); minHappy = Math.min(minHappy, c.stats.happiness); // mayor manages the grid: builds ahead of demand, reacts to strain const strained = c.stats.powerUse > c.stats.powerCap * 0.8 || c.stats.brownouts > 0; if (strained && m % 2 === 0) { const before = [...g.struct].filter(v => v === STRUCT.WIND || v === STRUCT.COAL).length; if (put(STRUCT.WIND) || put(STRUCT.COAL, 2, 2)) {} const after = [...g.struct].filter(v => v === STRUCT.WIND || v === STRUCT.COAL).length; if (after > before) plantsBuilt++; } // mid-run save/load integrity if (m === 180) { const snap = JSON.parse(JSON.stringify(c.toJSON())); const before = { pop: c.stats.pop, money: Math.round(c.money), month: c.monthIndex }; const c2 = City.fromJSON(snap); ok(c2.stats.pop === before.pop && Math.round(c2.money) === before.money && c2.monthIndex === before.month, `mid-run reload preserves state (pop ${before.pop}, ${fmt(before.money)}, m${before.month})`); // continue on the reloaded instance to prove it stays healthy c.money = c2.money; // keep going with same wallet semantics below } } function fmt(n) { return '$' + Math.round(n).toLocaleString('en-US'); } const levels = [1, 2, 3].map(L => [...g.level].filter(v => v === L).length); console.log(`\nfinal: pop=${c.stats.pop} jobs=${c.stats.jobs} dev=${levels.reduce((a,b)=>a+b,0)} L1/2/3=${levels.join('/')} happy=${c.stats.happiness}`); console.log(`money=${fmt(c.money)} (min ${fmt(minMoney)}) | avgTick=${(tickSum/360).toFixed(2)}ms maxTick=${maxTick.toFixed(1)}ms`); console.log(`plantsBuiltDuringRun=${plantsBuilt} | fireMonths=${firesSeen}`); ok(c.stats.pop > 800, `city grew past 800 citizens (${c.stats.pop})`); ok(levels[2] >= 2, `land value drove level-3 towers (${levels[2]})`); ok(levels[1] >= 0, 'density ladder present'); ok(minMoney > -20000, `treasury never spiralled (min ${fmt(minMoney)})`); ok(c.money > 0, `economy self-sustaining at year 30 (${fmt(c.money)})`); ok(minHappy > 15, `happiness never collapsed (min ${minHappy})`); ok(plantsBuilt >= 3, `grid expanded proactively as demand grew (${plantsBuilt} plants added)`); ok(maxTick < 150, `worst monthly tick fast enough (${maxTick.toFixed(1)}ms)`); ok(tickSum / 360 < 25, `average monthly tick cheap (${(tickSum / 360).toFixed(2)}ms)`); const jsonSize = JSON.stringify(c.toJSON()).length; ok(jsonSize < 2_000_000, `save size sane (${(jsonSize / 1024).toFixed(0)} KB)`); console.log(`\n${pass} passed, ${fail} failed`); process.exit(fail ? 1 : 0);