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)
@@ -79,6 +79,7 @@ resolution; the game auto-fits.
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
node tests/engine.mjs # 40-assertion simulation suite (pure Node)
|
node tests/engine.mjs # 40-assertion simulation suite (pure Node)
|
||||||
|
node tests/soak.mjs # 40-year economy/growth/power soak test
|
||||||
node tests/smoke.mjs # headless-browser end-to-end run
|
node tests/smoke.mjs # headless-browser end-to-end run
|
||||||
node tests/capture.mjs # renders promo screenshots into shots/
|
node tests/capture.mjs # renders promo screenshots into shots/
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -18,7 +18,8 @@
|
|||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"test": "node tests/engine.mjs",
|
"test": "node tests/engine.mjs",
|
||||||
"test:browser": "node tests/smoke.mjs",
|
"test:browser": "node tests/smoke.mjs",
|
||||||
"shots": "node tests/capture.mjs"
|
"shots": "node tests/capture.mjs",
|
||||||
|
"test:soak": "node tests/soak.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"three": "^0.185.1"
|
"three": "^0.185.1"
|
||||||
|
|||||||
|
After Width: | Height: | Size: 115 KiB |
|
After Width: | Height: | Size: 157 KiB |
|
After Width: | Height: | Size: 157 KiB |
|
After Width: | Height: | Size: 187 KiB |
|
After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 173 KiB |
|
After Width: | Height: | Size: 77 KiB |
@@ -20,14 +20,15 @@ export const BULLDOZE_COST = 3;
|
|||||||
export const TAX_DEFAULT = 9; // percent
|
export const TAX_DEFAULT = 9; // percent
|
||||||
|
|
||||||
// --- Power ---------------------------------------------------
|
// --- Power ---------------------------------------------------
|
||||||
export const ZONE_POWER = { 1: [3, 8, 18], 2: [5, 12, 26], 3: [8, 20, 44] };
|
export const ZONE_POWER = { 1: [12, 32, 72], 2: [20, 48, 104], 3: [32, 80, 176] };
|
||||||
|
|
||||||
// --- Zone capacities per level -------------------------------
|
// --- Zone capacities per level -------------------------------
|
||||||
export const ZONE_POP = { 1: [6, 16, 36] };
|
export const ZONE_POP = { 1: [6, 16, 36] };
|
||||||
export const ZONE_JOBS = { 2: [8, 20, 42], 3: [10, 26, 52] };
|
export const ZONE_JOBS = { 2: [8, 20, 42], 3: [10, 26, 52] };
|
||||||
|
|
||||||
// --- Land value thresholds for upgrading to level 2 / 3 ------
|
// --- Land value thresholds for upgrading to level 2 / 3 ------
|
||||||
export const LV_UPGRADE = { 2: 46, 3: 92 };
|
// Neighborhoods mature with age, adding up to +12 effective LV.
|
||||||
|
export const LV_UPGRADE = { 2: 34, 3: 74 };
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Placeable buildings (services + utilities)
|
// Placeable buildings (services + utilities)
|
||||||
@@ -41,37 +42,37 @@ export const BUILDINGS = {
|
|||||||
},
|
},
|
||||||
[STRUCT.COAL]: {
|
[STRUCT.COAL]: {
|
||||||
id: 'coal', name: 'Coal Power Plant', cost: 3200, w: 2, h: 2,
|
id: 'coal', name: 'Coal Power Plant', cost: 3200, w: 2, h: 2,
|
||||||
upkeep: 220, power: 6000, pollution: 46, pollutionRadius: 11, radius: 0,
|
upkeep: 180, power: 6000, pollution: 46, pollutionRadius: 11, radius: 0,
|
||||||
cat: 'power', desc: 'Cheap, dirty energy for ~6000 units. Keep it downwind of homes.', hotkey: ''
|
cat: 'power', desc: 'Cheap, dirty energy for ~6000 units. Keep it downwind of homes.', hotkey: ''
|
||||||
},
|
},
|
||||||
[STRUCT.SOLAR]: {
|
[STRUCT.SOLAR]: {
|
||||||
id: 'solar', name: 'Solar Farm', cost: 4800, w: 2, h: 2,
|
id: 'solar', name: 'Solar Farm', cost: 4800, w: 2, h: 2,
|
||||||
upkeep: 90, power: 2400, pollution: 0, pollutionRadius: 0, radius: 0,
|
upkeep: 70, power: 2400, pollution: 0, pollutionRadius: 0, radius: 0,
|
||||||
cat: 'power', desc: 'Clean energy for ~2400 units. Pricey upkeep, zero pollution.', hotkey: ''
|
cat: 'power', desc: 'Clean energy for ~2400 units. Pricey upkeep, zero pollution.', hotkey: ''
|
||||||
},
|
},
|
||||||
[STRUCT.WIND]: {
|
[STRUCT.WIND]: {
|
||||||
id: 'wind', name: 'Wind Turbine', cost: 1100, w: 1, h: 1,
|
id: 'wind', name: 'Wind Turbine', cost: 1100, w: 1, h: 1,
|
||||||
upkeep: 40, power: 750, pollution: 0, pollutionRadius: 0, radius: 0,
|
upkeep: 30, power: 750, pollution: 0, pollutionRadius: 0, radius: 0,
|
||||||
cat: 'power', desc: 'Compact clean energy (~750). +50% output next to water.', hotkey: ''
|
cat: 'power', desc: 'Compact clean energy (~750). +50% output next to water.', hotkey: ''
|
||||||
},
|
},
|
||||||
[STRUCT.POLICE]: {
|
[STRUCT.POLICE]: {
|
||||||
id: 'police', name: 'Police Station', cost: 650, w: 1, h: 1,
|
id: 'police', name: 'Police Station', cost: 650, w: 1, h: 1,
|
||||||
upkeep: 90, radius: 14, cat: 'safety',
|
upkeep: 60, radius: 14, cat: 'safety',
|
||||||
desc: 'Cuts crime nearby — boosts land value & happiness in radius.', hotkey: ''
|
desc: 'Cuts crime nearby — boosts land value & happiness in radius.', hotkey: ''
|
||||||
},
|
},
|
||||||
[STRUCT.FIRE]: {
|
[STRUCT.FIRE]: {
|
||||||
id: 'fire', name: 'Fire Station', cost: 650, w: 1, h: 1,
|
id: 'fire', name: 'Fire Station', cost: 650, w: 1, h: 1,
|
||||||
upkeep: 90, radius: 14, cat: 'safety',
|
upkeep: 60, radius: 14, cat: 'safety',
|
||||||
desc: 'Extinguishes fires in radius fast and prevents most ignitions.', hotkey: ''
|
desc: 'Extinguishes fires in radius fast and prevents most ignitions.', hotkey: ''
|
||||||
},
|
},
|
||||||
[STRUCT.HOSPITAL]: {
|
[STRUCT.HOSPITAL]: {
|
||||||
id: 'hospital', name: 'Hospital', cost: 1500, w: 1, h: 1,
|
id: 'hospital', name: 'Hospital', cost: 1500, w: 1, h: 1,
|
||||||
upkeep: 190, radius: 13, cat: 'health',
|
upkeep: 140, radius: 13, cat: 'health',
|
||||||
desc: 'Healthy citizens are happy citizens. Big residential boost.', hotkey: ''
|
desc: 'Healthy citizens are happy citizens. Big residential boost.', hotkey: ''
|
||||||
},
|
},
|
||||||
[STRUCT.SCHOOL]: {
|
[STRUCT.SCHOOL]: {
|
||||||
id: 'school', name: 'School', cost: 1000, w: 1, h: 1,
|
id: 'school', name: 'School', cost: 1000, w: 1, h: 1,
|
||||||
upkeep: 130, radius: 13, cat: 'education',
|
upkeep: 100, radius: 13, cat: 'education',
|
||||||
desc: 'Educated workers attract better industry and raise land value.', hotkey: ''
|
desc: 'Educated workers attract better industry and raise land value.', hotkey: ''
|
||||||
},
|
},
|
||||||
[STRUCT.PARK]: {
|
[STRUCT.PARK]: {
|
||||||
@@ -86,7 +87,7 @@ export const BUILDINGS = {
|
|||||||
},
|
},
|
||||||
[STRUCT.STADIUM]: {
|
[STRUCT.STADIUM]: {
|
||||||
id: 'stadium', name: 'Stadium', cost: 4200, w: 2, h: 2,
|
id: 'stadium', name: 'Stadium', cost: 4200, w: 2, h: 2,
|
||||||
upkeep: 260, radius: 16, cat: 'leisure',
|
upkeep: 200, radius: 16, cat: 'leisure',
|
||||||
desc: 'The pride of the city — huge happiness & land value aura.', hotkey: ''
|
desc: 'The pride of the city — huge happiness & land value aura.', hotkey: ''
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -113,7 +114,7 @@ export const SIM = {
|
|||||||
[5000, 'City'], [12000, 'Metropolis'], [30000, 'Megalopolis']
|
[5000, 'City'], [12000, 'Metropolis'], [30000, 'Megalopolis']
|
||||||
],
|
],
|
||||||
// tax income multipliers per month
|
// tax income multipliers per month
|
||||||
taxRes: 3.0, taxCom: 3.2, taxInd: 2.8,
|
taxRes: 7, taxCom: 8, taxInd: 7,
|
||||||
roadExpensePerTile: 1,
|
roadExpensePerTile: 1,
|
||||||
maxDebtWarning: -2000
|
maxDebtWarning: -2000
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -68,7 +68,10 @@ export class City extends EventBus {
|
|||||||
const tx = x + dx, tz = z + dz;
|
const tx = x + dx, tz = z + dz;
|
||||||
if (!g.inB(tx, tz)) return { ok: false, reason: 'Outside city limits' };
|
if (!g.inB(tx, tz)) return { ok: false, reason: 'Outside city limits' };
|
||||||
const i = g.idx(tx, tz);
|
const i = g.idx(tx, tz);
|
||||||
if (!this.tileFree(i)) return { ok: false, reason: 'Tile occupied — bulldoze first' };
|
// structures may claim empty (undeveloped) zoned plots — the zone
|
||||||
|
// designation is cleared underneath; rubble/burning/roads block.
|
||||||
|
if (g.struct[i] !== 0 || g.rubble[i] || g.burning[i] || g.level[i] > 0)
|
||||||
|
return { ok: false, reason: 'Tile occupied — bulldoze first' };
|
||||||
if (g.terrain[i] === 1) {
|
if (g.terrain[i] === 1) {
|
||||||
if (sid === STRUCT.ROAD) cost += BRIDGE_COST;
|
if (sid === STRUCT.ROAD) cost += BRIDGE_COST;
|
||||||
else return { ok: false, reason: 'Cannot build on water' };
|
else return { ok: false, reason: 'Cannot build on water' };
|
||||||
@@ -98,6 +101,7 @@ export class City extends EventBus {
|
|||||||
for (let dz = 0; dz < meta.h; dz++) {
|
for (let dz = 0; dz < meta.h; dz++) {
|
||||||
for (let dx = 0; dx < meta.w; dx++) {
|
for (let dx = 0; dx < meta.w; dx++) {
|
||||||
const i = g.idx(x + dx, z + dz);
|
const i = g.idx(x + dx, z + dz);
|
||||||
|
if (sid !== STRUCT.ROAD && g.zone[i] !== 0) g.zone[i] = ZONE.NONE;
|
||||||
g.struct[i] = sid;
|
g.struct[i] = sid;
|
||||||
g.level[i] = 0;
|
g.level[i] = 0;
|
||||||
g.variant[i] = 0;
|
g.variant[i] = 0;
|
||||||
@@ -419,7 +423,7 @@ export class City extends EventBus {
|
|||||||
const taxMod = this.taxRate <= 9 ? 1 : Math.max(0.25, 1 - (this.taxRate - 9) * 0.07);
|
const taxMod = this.taxRate <= 9 ? 1 : Math.max(0.25, 1 - (this.taxRate - 9) * 0.07);
|
||||||
const happyMod = 0.6 + (this.stats.happiness / 100) * 0.7;
|
const happyMod = 0.6 + (this.stats.happiness / 100) * 0.7;
|
||||||
|
|
||||||
const targetPop = jobs * 1.3 + 60;
|
const targetPop = jobs * 1.5 + 80;
|
||||||
const resD = clamp((targetPop - pop) / Math.max(70, targetPop + 70), -1, 1) * taxMod * happyMod;
|
const resD = clamp((targetPop - pop) / Math.max(70, targetPop + 70), -1, 1) * taxMod * happyMod;
|
||||||
const comTarget = pop * 0.32 + 12;
|
const comTarget = pop * 0.32 + 12;
|
||||||
const comD = clamp((comTarget - comCap) / Math.max(60, comTarget + comCap * 0.8 + 45), -1, 1) * taxMod;
|
const comD = clamp((comTarget - comCap) / Math.max(60, comTarget + comCap * 0.8 + 45), -1, 1) * taxMod;
|
||||||
@@ -528,7 +532,7 @@ export class City extends EventBus {
|
|||||||
const x = i % S, z = (i - x) / S;
|
const x = i % S, z = (i - x) / S;
|
||||||
if (!this.roadNear(x, z, 2)) continue;
|
if (!this.roadNear(x, z, 2)) continue;
|
||||||
// gradual neighbourhood filling — scales with demand and area
|
// gradual neighbourhood filling — scales with demand and area
|
||||||
if (this.rng() < Math.min(0.6, dem * 0.28)) {
|
if (this.rng() < Math.min(0.75, dem * 0.5)) {
|
||||||
g.level[i] = 1;
|
g.level[i] = 1;
|
||||||
g.variant[i] = (this.rng() * 3) | 0;
|
g.variant[i] = (this.rng() * 3) | 0;
|
||||||
g.age[i] = 0;
|
g.age[i] = 0;
|
||||||
@@ -549,10 +553,11 @@ export class City extends EventBus {
|
|||||||
|
|
||||||
if (g.powered[i]) {
|
if (g.powered[i]) {
|
||||||
if (g.badMonths[i] > 0) g.badMonths[i]--;
|
if (g.badMonths[i] > 0) g.badMonths[i]--;
|
||||||
// upgrade path
|
// upgrade path — matured streets gain up to +12 effective land value
|
||||||
|
const effLV = g.landValue[i] + Math.min(15, g.age[i] * 0.5);
|
||||||
if (lvl < 3) {
|
if (lvl < 3) {
|
||||||
const dem = this.demandFor(zv);
|
const dem = this.demandFor(zv);
|
||||||
if (dem > 0.3 && g.age[i] > 2 && g.landValue[i] >= LV_UPGRADE[lvl + 1] && this.rng() < 0.45) {
|
if (dem > 0.08 && g.age[i] > 2 && effLV >= LV_UPGRADE[lvl + 1] && this.rng() < 0.45) {
|
||||||
g.level[i] = lvl + 1;
|
g.level[i] = lvl + 1;
|
||||||
g.variant[i] = (this.rng() * 3) | 0;
|
g.variant[i] = (this.rng() * 3) | 0;
|
||||||
g.age[i] = 0;
|
g.age[i] = 0;
|
||||||
|
|||||||
@@ -278,6 +278,11 @@ export class Input {
|
|||||||
const r = this.city.placeStruct(sid, tile.x, tile.z);
|
const r = this.city.placeStruct(sid, tile.x, tile.z);
|
||||||
if (r.ok) {
|
if (r.ok) {
|
||||||
this.audio.place();
|
this.audio.place();
|
||||||
|
// friendly heads-up for service buildings without road access
|
||||||
|
const meta = BUILDINGS[sid];
|
||||||
|
if (meta.radius >= 10 && meta.cat !== 'power' && !this.city.roadNear(tile.x, tile.z, 2)) {
|
||||||
|
this.ui.toast(`${meta.name} has no road access — it will stay inactive until you connect it.`, 'warn');
|
||||||
|
}
|
||||||
this.game.checkQuests?.();
|
this.game.checkQuests?.();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||