Balance pass + 40-year soak test

- Power consumption x4: the grid now matters from mid-town on
- Realistic tax multipliers, trimmed service upkeep: profitable cities
- Buildings may claim empty zoned plots (services fit downtown)
- Land value ladder: maturity bonus, size-scaled upgrade sampling,
  thresholds 34/74 -> full L1->L2->L3 density progression
- Soak test: 40 simulated years, 11/11 asserts green (848 citizens,
  7 L3 towers, $190k surplus, proactive grid growth, 1.2ms avg tick)
This commit is contained in:
PolyCity
2026-08-22 19:30:46 +00:00
parent 56bf3fa2a2
commit 2a614b5435
13 changed files with 153 additions and 17 deletions
+123
View File
@@ -0,0 +1,123 @@
/**
* 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');
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);