- Monthly-chance tornadoes (after month 30 grace) carve a wide erratic path, flattening buildings into rubble; anchors stay consistent - 'Tornado disasters' toggle in menu settings, persisted - Help handbook documents the hazard and the rebuild loop - Engine suite grows to 45 assertions (spawn damage, event, anchors, suppression when disabled); soak keeps economy focus disaster-free
125 lines
5.6 KiB
JavaScript
125 lines
5.6 KiB
JavaScript
/**
|
|
* 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);
|