feat(minimotorways): dynamic scaling and starvation-safe spawning
- Replace flat caps (CAR_CAP, HOUSE_CAP, BUILDING_CAP, WEEK_ROADS) with per-stage values that scale over the run, preventing the week-6 growth plateau where the expanded map stayed empty - Add per-color starvation guards: ensure every unlocked colour has at least 2 houses and 1 building, even if it means exceeding stage caps - Allow spawnHouse/spawnBuilding to bypass caps when a colour is supply-starved or destination-less - Update bridge placement to handle 1-wide rivers (orientation-ambiguous single-cell spans) - Tune PIN_MS_MIN from 3500ms to 2400ms - Extend Monte Carlo verification to 20 weeks with assertions that the city grows past the old week-6 plateau
This commit is contained in:
parent
09324f5b20
commit
94cc51b997
|
|
@ -1344,7 +1344,7 @@ export default class MiniMotorwaysGame extends Phaser.Scene {
|
||||||
root.add(this.add.text(cx, cy - 212, `WEEK ${week} COMPLETE`, {
|
root.add(this.add.text(cx, cy - 212, `WEEK ${week} COMPLETE`, {
|
||||||
fontFamily: 'Righteous', fontSize: '46px', color: COLORS.goldHex,
|
fontFamily: 'Righteous', fontSize: '46px', color: COLORS.goldHex,
|
||||||
}).setOrigin(0.5));
|
}).setOrigin(0.5));
|
||||||
root.add(this.add.text(cx, cy - 158, `+${TUNE.WEEK_ROADS} road tiles delivered. Choose one bonus:`, {
|
root.add(this.add.text(cx, cy - 158, `+${this.sim.stage().weekRoads} road tiles delivered. Choose one bonus:`, {
|
||||||
fontFamily: '"Julius Sans One"', fontSize: '24px', color: COLORS.mutedHex,
|
fontFamily: '"Julius Sans One"', fontSize: '24px', color: COLORS.mutedHex,
|
||||||
}).setOrigin(0.5));
|
}).setOrigin(0.5));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,16 +16,14 @@ export const COLOR_HEX = {
|
||||||
export const TUNE = {
|
export const TUNE = {
|
||||||
WEEK_MS: 70000,
|
WEEK_MS: 70000,
|
||||||
SUBSTEP_MS: 50,
|
SUBSTEP_MS: 50,
|
||||||
CAR_CAP: 60,
|
MIN_HOUSES_PER_COLOR: 2, // a colour is never left without supply…
|
||||||
HOUSE_CAP: 20,
|
|
||||||
BUILDING_CAP: 8,
|
|
||||||
CAR_SPEED: 2.5, // cells / second
|
CAR_SPEED: 2.5, // cells / second
|
||||||
HEADWAY: 0.65, // minimum gap behind the car ahead, in cells
|
HEADWAY: 0.65, // minimum gap behind the car ahead, in cells
|
||||||
DWELL_MS: 1000,
|
DWELL_MS: 1000,
|
||||||
COOLDOWN_MS: 2000,
|
COOLDOWN_MS: 2000,
|
||||||
PIN_MS_BASE: 12000, // pin interval = max(MIN, BASE * DECAY^week)
|
PIN_MS_BASE: 12000, // pin interval = max(MIN, BASE * DECAY^week)
|
||||||
PIN_MS_DECAY: 0.90,
|
PIN_MS_DECAY: 0.90,
|
||||||
PIN_MS_MIN: 3500,
|
PIN_MS_MIN: 2400,
|
||||||
PIN_GRACE_MS: 15000, // new buildings wait this long before pin #1
|
PIN_GRACE_MS: 15000, // new buildings wait this long before pin #1
|
||||||
PIN_CAP: 12,
|
PIN_CAP: 12,
|
||||||
OVERFLOW_PINS: 8,
|
OVERFLOW_PINS: 8,
|
||||||
|
|
@ -39,7 +37,6 @@ export const TUNE = {
|
||||||
BUILDING_MS_DECAY: 0.88,
|
BUILDING_MS_DECAY: 0.88,
|
||||||
BUILDING_MS_MIN: 70000,
|
BUILDING_MS_MIN: 70000,
|
||||||
START_ROADS: 30,
|
START_ROADS: 30,
|
||||||
WEEK_ROADS: 12,
|
|
||||||
UPGRADE_ROADS: 10,
|
UPGRADE_ROADS: 10,
|
||||||
MOTORWAY_COST: 2.5, // A* cost of the portal edge
|
MOTORWAY_COST: 2.5, // A* cost of the portal edge
|
||||||
MOTORWAY_MS: 1200, // real traversal time, ignores all traffic
|
MOTORWAY_MS: 1200, // real traversal time, ignores all traffic
|
||||||
|
|
@ -51,11 +48,14 @@ export const TUNE = {
|
||||||
DISPATCH_MS: 500,
|
DISPATCH_MS: 500,
|
||||||
SECOND_CAR_WEEK: 2, // houses gain a second car from this week on
|
SECOND_CAR_WEEK: 2, // houses gain a second car from this week on
|
||||||
COLOR_UNLOCK_WEEKS: [0, 0, 3, 5, 8, 11],
|
COLOR_UNLOCK_WEEKS: [0, 0, 3, 5, 8, 11],
|
||||||
|
// The active map grows 4x in area over the run, so population caps and the
|
||||||
|
// weekly road grant are per-stage rather than flat — otherwise the outer city
|
||||||
|
// stays empty and the extra structures are unreachable.
|
||||||
GROWTH: [
|
GROWTH: [
|
||||||
{ week: 0, w: 20, h: 12 },
|
{ week: 0, w: 20, h: 12, houseCap: 14, buildingCap: 5, carCap: 24, weekRoads: 12 },
|
||||||
{ week: 3, w: 26, h: 16 },
|
{ week: 3, w: 26, h: 16, houseCap: 24, buildingCap: 8, carCap: 40, weekRoads: 16 },
|
||||||
{ week: 6, w: 32, h: 20 },
|
{ week: 6, w: 32, h: 20, houseCap: 34, buildingCap: 11, carCap: 56, weekRoads: 20 },
|
||||||
{ week: 9, w: 40, h: 24 },
|
{ week: 9, w: 40, h: 24, houseCap: 46, buildingCap: 15, carCap: 76, weekRoads: 24 },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -321,6 +321,10 @@ export class Sim {
|
||||||
|
|
||||||
emit(type, data = {}) { this.events.push({ type, ...data }); }
|
emit(type, data = {}) { this.events.push({ type, ...data }); }
|
||||||
|
|
||||||
|
stage() { return TUNE.GROWTH[this.growthIdx]; }
|
||||||
|
houseCap() { return this.stage().houseCap; }
|
||||||
|
buildingCap() { return this.stage().buildingCap; }
|
||||||
|
carCap() { return this.stage().carCap; }
|
||||||
houseInterval() { return Math.max(TUNE.HOUSE_MS_MIN, TUNE.HOUSE_MS_BASE * TUNE.HOUSE_MS_DECAY ** this.week); }
|
houseInterval() { return Math.max(TUNE.HOUSE_MS_MIN, TUNE.HOUSE_MS_BASE * TUNE.HOUSE_MS_DECAY ** this.week); }
|
||||||
buildingInterval() { return Math.max(TUNE.BUILDING_MS_MIN, TUNE.BUILDING_MS_BASE * TUNE.BUILDING_MS_DECAY ** this.week); }
|
buildingInterval() { return Math.max(TUNE.BUILDING_MS_MIN, TUNE.BUILDING_MS_BASE * TUNE.BUILDING_MS_DECAY ** this.week); }
|
||||||
pinInterval() { return Math.max(TUNE.PIN_MS_MIN, TUNE.PIN_MS_BASE * TUNE.PIN_MS_DECAY ** this.week); }
|
pinInterval() { return Math.max(TUNE.PIN_MS_MIN, TUNE.PIN_MS_BASE * TUNE.PIN_MS_DECAY ** this.week); }
|
||||||
|
|
@ -460,29 +464,38 @@ export class Sim {
|
||||||
canPlaceBridge(cells) {
|
canPlaceBridge(cells) {
|
||||||
if (this.stock.bridges < 1) return false;
|
if (this.stock.bridges < 1) return false;
|
||||||
if (!cells || cells.length < 1 || cells.length > 3) return false;
|
if (!cells || cells.length < 1 || cells.length > 3) return false;
|
||||||
|
for (const k of cells) {
|
||||||
|
if (this.terrain[k] !== TERRAIN.WATER || this.roads.has(k)) return false;
|
||||||
|
}
|
||||||
const xs = cells.map(xOf); const ys = cells.map(yOf);
|
const xs = cells.map(xOf); const ys = cells.map(yOf);
|
||||||
const horiz = ys.every((y) => y === ys[0]);
|
const horiz = ys.every((y) => y === ys[0]);
|
||||||
const vert = xs.every((x) => x === xs[0]);
|
const vert = xs.every((x) => x === xs[0]);
|
||||||
if (!horiz && !vert) return false;
|
if (!horiz && !vert) return false;
|
||||||
const sorted = [...cells].sort((a, b) => a - b);
|
|
||||||
for (let i = 1; i < sorted.length; i++) {
|
|
||||||
const stepOk = horiz ? sorted[i] === sorted[i - 1] + 1 : sorted[i] === sorted[i - 1] + WORLD_W;
|
|
||||||
if (!stepOk) return false;
|
|
||||||
}
|
|
||||||
for (const k of cells) {
|
|
||||||
if (this.terrain[k] !== TERRAIN.WATER || this.roads.has(k)) return false;
|
|
||||||
}
|
|
||||||
const step = horiz ? 1 : WORLD_W;
|
|
||||||
const before = sorted[0] - step;
|
|
||||||
const after = sorted[sorted.length - 1] + step;
|
|
||||||
const dry = (k) => {
|
const dry = (k) => {
|
||||||
const x = xOf(k); const y = yOf(k);
|
const x = xOf(k); const y = yOf(k);
|
||||||
return inBounds(x, y) && this.terrain[k] !== TERRAIN.WATER;
|
return inBounds(x, y) && this.terrain[k] !== TERRAIN.WATER;
|
||||||
};
|
};
|
||||||
// Guard against wrap-around on horizontal runs at the map edge.
|
// Validate the run as a straight span along one axis (step 1 = east-west,
|
||||||
if (horiz && (xOf(sorted[0]) === 0 || xOf(sorted[sorted.length - 1]) === WORLD_W - 1)) return false;
|
// WORLD_W = north-south): contiguous, dry land just beyond each end, and no
|
||||||
if (!horiz && (yOf(sorted[0]) === 0 || yOf(sorted[sorted.length - 1]) === WORLD_H - 1)) return false;
|
// wrap-around at the map edge.
|
||||||
return dry(before) && dry(after);
|
const axisOk = (step) => {
|
||||||
|
const sorted = [...cells].sort((a, b) => a - b);
|
||||||
|
for (let i = 1; i < sorted.length; i++) {
|
||||||
|
if (sorted[i] !== sorted[i - 1] + step) return false;
|
||||||
|
}
|
||||||
|
const first = sorted[0]; const last = sorted[sorted.length - 1];
|
||||||
|
if (step === 1) {
|
||||||
|
if (xOf(first) === 0 || xOf(last) === WORLD_W - 1) return false;
|
||||||
|
} else if (yOf(first) === 0 || yOf(last) === WORLD_H - 1) return false;
|
||||||
|
return dry(first - step) && dry(last + step);
|
||||||
|
};
|
||||||
|
|
||||||
|
// A single water cell is orientation-ambiguous, so bridge it whichever way
|
||||||
|
// has dry shores — this is what lets a 1-wide east-west river be spanned
|
||||||
|
// north-south. Multi-cell runs are locked to their actual orientation.
|
||||||
|
if (cells.length === 1) return axisOk(1) || axisOk(WORLD_W);
|
||||||
|
return horiz ? axisOk(1) : axisOk(WORLD_W);
|
||||||
}
|
}
|
||||||
|
|
||||||
placeBridge(cells) {
|
placeBridge(cells) {
|
||||||
|
|
@ -712,7 +725,10 @@ export class Sim {
|
||||||
}
|
}
|
||||||
|
|
||||||
spawnHouse(color, force = false) {
|
spawnHouse(color, force = false) {
|
||||||
if (!force && this.houses.length >= TUNE.HOUSE_CAP) return null;
|
// A colour below its minimum always spawns, even over the stage cap, so a
|
||||||
|
// freshly-unlocked colour is never left supply-starved.
|
||||||
|
const starved = this.houses.filter((h) => h.color === color).length < TUNE.MIN_HOUSES_PER_COLOR;
|
||||||
|
if (!force && !starved && this.houses.length >= this.houseCap()) return null;
|
||||||
const k = this.randomFreeCell(false);
|
const k = this.randomFreeCell(false);
|
||||||
if (k === null) return null;
|
if (k === null) return null;
|
||||||
const house = { id: this.nextId++, color, k, carIds: [] };
|
const house = { id: this.nextId++, color, k, carIds: [] };
|
||||||
|
|
@ -721,8 +737,11 @@ export class Sim {
|
||||||
return house;
|
return house;
|
||||||
}
|
}
|
||||||
|
|
||||||
spawnBuilding(color) {
|
spawnBuilding(color, force = false) {
|
||||||
if (this.buildings.length >= TUNE.BUILDING_CAP) return null;
|
// A colour with no destination always gets one, even over the stage cap:
|
||||||
|
// otherwise the houses of a late-unlocked colour have nowhere to drive.
|
||||||
|
const noDest = !this.buildings.some((b) => b.color === color);
|
||||||
|
if (!force && !noDest && this.buildings.length >= this.buildingCap()) return null;
|
||||||
const k = this.randomFreeCell(true);
|
const k = this.randomFreeCell(true);
|
||||||
if (k === null) return null;
|
if (k === null) return null;
|
||||||
const cells = [k, k + 1, k + WORLD_W, k + WORLD_W + 1];
|
const cells = [k, k + 1, k + WORLD_W, k + WORLD_W + 1];
|
||||||
|
|
@ -740,8 +759,9 @@ export class Sim {
|
||||||
while (this.colorsUnlocked < Math.min(target, this.city.colorOrder.length)) {
|
while (this.colorsUnlocked < Math.min(target, this.city.colorOrder.length)) {
|
||||||
const color = this.city.colorOrder[this.colorsUnlocked];
|
const color = this.city.colorOrder[this.colorsUnlocked];
|
||||||
this.colorsUnlocked++;
|
this.colorsUnlocked++;
|
||||||
this.spawnBuilding(color);
|
// Force past the caps: a fresh colour must never start destination-less
|
||||||
// Bypass the house cap: a fresh colour must never start supply-starved.
|
// or supply-starved.
|
||||||
|
this.spawnBuilding(color, true);
|
||||||
this.spawnHouse(color, true);
|
this.spawnHouse(color, true);
|
||||||
this.spawnHouse(color, true);
|
this.spawnHouse(color, true);
|
||||||
this.emit('colorUnlock', { color });
|
this.emit('colorUnlock', { color });
|
||||||
|
|
@ -750,6 +770,16 @@ export class Sim {
|
||||||
|
|
||||||
pickSpawnColor(forBuilding) {
|
pickSpawnColor(forBuilding) {
|
||||||
const unlocked = this.city.colorOrder.slice(0, this.colorsUnlocked);
|
const unlocked = this.city.colorOrder.slice(0, this.colorsUnlocked);
|
||||||
|
// Starvation always wins over the ratio heuristic: never let an early large
|
||||||
|
// colour hog spawns while another colour has no destination or too few homes.
|
||||||
|
if (forBuilding) {
|
||||||
|
const noDest = unlocked.find((c) => !this.buildings.some((b) => b.color === c));
|
||||||
|
if (noDest) return noDest;
|
||||||
|
} else {
|
||||||
|
const starved = unlocked.find((c) =>
|
||||||
|
this.houses.filter((h) => h.color === c).length < TUNE.MIN_HOUSES_PER_COLOR);
|
||||||
|
if (starved) return starved;
|
||||||
|
}
|
||||||
const stats = unlocked.map((color) => ({
|
const stats = unlocked.map((color) => ({
|
||||||
color,
|
color,
|
||||||
houses: this.houses.filter((h) => h.color === color).length,
|
houses: this.houses.filter((h) => h.color === color).length,
|
||||||
|
|
@ -810,8 +840,9 @@ export class Sim {
|
||||||
|
|
||||||
rollWeek() {
|
rollWeek() {
|
||||||
this.week++;
|
this.week++;
|
||||||
this.stock.roads += TUNE.WEEK_ROADS;
|
|
||||||
|
|
||||||
|
// Advance the growth stage first, so a growth week also pays out its larger
|
||||||
|
// road budget and unlocks its higher caps.
|
||||||
const growth = TUNE.GROWTH.findIndex((g) => g.week === this.week);
|
const growth = TUNE.GROWTH.findIndex((g) => g.week === this.week);
|
||||||
if (growth > this.growthIdx) {
|
if (growth > this.growthIdx) {
|
||||||
this.growthIdx = growth;
|
this.growthIdx = growth;
|
||||||
|
|
@ -819,6 +850,8 @@ export class Sim {
|
||||||
this.emit('growth', { rect: { ...this.activeRect }, idx: growth });
|
this.emit('growth', { rect: { ...this.activeRect }, idx: growth });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.stock.roads += this.stage().weekRoads;
|
||||||
|
|
||||||
const unlockTarget = TUNE.COLOR_UNLOCK_WEEKS.filter((w) => w <= this.week).length;
|
const unlockTarget = TUNE.COLOR_UNLOCK_WEEKS.filter((w) => w <= this.week).length;
|
||||||
this.unlockColors(unlockTarget);
|
this.unlockColors(unlockTarget);
|
||||||
|
|
||||||
|
|
@ -886,7 +919,7 @@ export class Sim {
|
||||||
if (assignments >= 3 || failures >= 8) break;
|
if (assignments >= 3 || failures >= 8) break;
|
||||||
if (building.pins - building.reserved <= 0) break;
|
if (building.pins - building.reserved <= 0) break;
|
||||||
let car = house.carIds.map((id) => this.carById(id)).find((c) => c && c.state === 'idle');
|
let car = house.carIds.map((id) => this.carById(id)).find((c) => c && c.state === 'idle');
|
||||||
if (!car && house.carIds.length < this.carsPerHouse() && this.cars.length < TUNE.CAR_CAP) {
|
if (!car && house.carIds.length < this.carsPerHouse() && this.cars.length < this.carCap()) {
|
||||||
car = this.createCar(house);
|
car = this.createCar(house);
|
||||||
}
|
}
|
||||||
if (!car) continue;
|
if (!car) continue;
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
// 2. Road adjacency fixtures (diagonal crossing rule, costs).
|
// 2. Road adjacency fixtures (diagonal crossing rule, costs).
|
||||||
// 3. Pathfinder fixtures (straight runs, bridges, motorway shortcuts).
|
// 3. Pathfinder fixtures (straight runs, bridges, motorway shortcuts).
|
||||||
// 4. Overflow with no roads ends the game.
|
// 4. Overflow with no roads ends the game.
|
||||||
// 5. Monte-carlo bot: 15 simulated weeks per city with invariant checks.
|
// 5. Monte-carlo bot: 20 simulated weeks per city with invariant checks.
|
||||||
|
|
||||||
import {
|
import {
|
||||||
WORLD_W, WORLD_H, TERRAIN, TUNE, CITIES, COLOR_NAMES,
|
WORLD_W, WORLD_H, TERRAIN, TUNE, CITIES, COLOR_NAMES,
|
||||||
|
|
@ -190,7 +190,7 @@ console.log('Overflow → game over');
|
||||||
|
|
||||||
// ── 5. Monte-carlo bot ─────────────────────────────────────────────────────────
|
// ── 5. Monte-carlo bot ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
console.log('Monte-carlo bot, 15 weeks per city');
|
console.log('Monte-carlo bot, 20 weeks per city');
|
||||||
|
|
||||||
// Weighted search over buildable cells (4-connected) between two structures,
|
// Weighted search over buildable cells (4-connected) between two structures,
|
||||||
// then pave the path. Water is allowed at a steep cost; runs of water ≤3 cells
|
// then pave the path. Water is allowed at a steep cost; runs of water ≤3 cells
|
||||||
|
|
@ -300,11 +300,30 @@ function checkInvariants(sim, label) {
|
||||||
return `${label}: NaN in car ${car.id} (${car.state})`;
|
return `${label}: NaN in car ${car.id} (${car.state})`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (sim.cars.length > TUNE.CAR_CAP) return `${label}: car cap exceeded (${sim.cars.length})`;
|
if (sim.cars.length > sim.carCap()) return `${label}: car cap exceeded (${sim.cars.length} > ${sim.carCap()})`;
|
||||||
for (const b of sim.buildings) {
|
for (const b of sim.buildings) {
|
||||||
if (b.reserved > b.pins) return `${label}: reserved ${b.reserved} > pins ${b.pins} at building ${b.id}`;
|
if (b.reserved > b.pins) return `${label}: reserved ${b.reserved} > pins ${b.pins} at building ${b.id}`;
|
||||||
if (b.ring < 0 || b.ring > 1.2) return `${label}: ring out of range ${b.ring}`;
|
if (b.ring < 0 || b.ring > 1.2) return `${label}: ring out of range ${b.ring}`;
|
||||||
}
|
}
|
||||||
|
// Every unlocked colour must have at least one destination and the minimum
|
||||||
|
// supply — the core regression guard for the late-game scaling bug.
|
||||||
|
const unlocked = sim.city.colorOrder.slice(0, sim.colorsUnlocked);
|
||||||
|
for (const color of unlocked) {
|
||||||
|
if (!sim.buildings.some((b) => b.color === color)) {
|
||||||
|
return `${label}: unlocked colour ${color} has no building`;
|
||||||
|
}
|
||||||
|
if (sim.houses.filter((h) => h.color === color).length < TUNE.MIN_HOUSES_PER_COLOR) {
|
||||||
|
return `${label}: unlocked colour ${color} below min houses`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Caps may be exceeded only by the per-colour starvation allowance.
|
||||||
|
const colorSlack = unlocked.length;
|
||||||
|
if (sim.houses.length > sim.houseCap() + colorSlack * TUNE.MIN_HOUSES_PER_COLOR) {
|
||||||
|
return `${label}: house cap far exceeded (${sim.houses.length} > ${sim.houseCap()})`;
|
||||||
|
}
|
||||||
|
if (sim.buildings.length > sim.buildingCap() + colorSlack) {
|
||||||
|
return `${label}: building cap far exceeded (${sim.buildings.length} > ${sim.buildingCap()})`;
|
||||||
|
}
|
||||||
for (const k of sim.roads) {
|
for (const k of sim.roads) {
|
||||||
for (const nb of sim.roadNeighbors(k)) {
|
for (const nb of sim.roadNeighbors(k)) {
|
||||||
if (!sim.roadNeighbors(nb.k).some((x) => x.k === k)) {
|
if (!sim.roadNeighbors(nb.k).some((x) => x.k === k)) {
|
||||||
|
|
@ -316,7 +335,7 @@ function checkInvariants(sim, label) {
|
||||||
}
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
const targetWeeks = 15;
|
const targetWeeks = 20;
|
||||||
for (let ci = 0; ci < CITIES.length; ci++) {
|
for (let ci = 0; ci < CITIES.length; ci++) {
|
||||||
const sim = new Sim(ci, 1000 + ci);
|
const sim = new Sim(ci, 1000 + ci);
|
||||||
sim.stock.roads = 99999;
|
sim.stock.roads = 99999;
|
||||||
|
|
@ -333,8 +352,10 @@ function checkInvariants(sim, label) {
|
||||||
let invariantError = null;
|
let invariantError = null;
|
||||||
let steps = 0;
|
let steps = 0;
|
||||||
let stranded = 0;
|
let stranded = 0;
|
||||||
|
let lastWeek = 0;
|
||||||
|
const census = {}; // week -> { houses, buildings }
|
||||||
|
|
||||||
while (sim.week < targetWeeks && !sim.gameOver && steps < 30000) {
|
while (sim.week < targetWeeks && !sim.gameOver && steps < 40000) {
|
||||||
const events = sim.step(100);
|
const events = sim.step(100);
|
||||||
steps++;
|
steps++;
|
||||||
for (const e of events) {
|
for (const e of events) {
|
||||||
|
|
@ -346,6 +367,12 @@ function checkInvariants(sim, label) {
|
||||||
if (sim.score < lastScore) scoreRegressed = true;
|
if (sim.score < lastScore) scoreRegressed = true;
|
||||||
lastScore = sim.score;
|
lastScore = sim.score;
|
||||||
|
|
||||||
|
// Snapshot the population at each week boundary for the growth assertion.
|
||||||
|
if (sim.week !== lastWeek) {
|
||||||
|
lastWeek = sim.week;
|
||||||
|
census[sim.week] = { houses: sim.houses.length, buildings: sim.buildings.length };
|
||||||
|
}
|
||||||
|
|
||||||
// Exercise erase + reroute: pull a road cell out from under traffic,
|
// Exercise erase + reroute: pull a road cell out from under traffic,
|
||||||
// then put it back two ticks later.
|
// then put it back two ticks later.
|
||||||
if (steps % 600 === 300 && sim.roads.size > 10) {
|
if (steps % 600 === 300 && sim.roads.size > 10) {
|
||||||
|
|
@ -370,6 +397,13 @@ function checkInvariants(sim, label) {
|
||||||
check(`${name}: score never regressed`, !scoreRegressed);
|
check(`${name}: score never regressed`, !scoreRegressed);
|
||||||
check(`${name}: survived or died legitimately`,
|
check(`${name}: survived or died legitimately`,
|
||||||
sim.week >= targetWeeks || sim.gameOver, `week=${sim.week} steps=${steps}`);
|
sim.week >= targetWeeks || sim.gameOver, `week=${sim.week} steps=${steps}`);
|
||||||
|
// If the city reached week 12, the map must have kept filling in past the
|
||||||
|
// old week-6 plateau — proves the scaling wall is gone.
|
||||||
|
if (census[12] && census[6]) {
|
||||||
|
check(`${name}: city grew past the week-6 plateau`,
|
||||||
|
census[12].houses > census[6].houses && census[12].buildings > census[6].buildings,
|
||||||
|
`wk6=${JSON.stringify(census[6])} wk12=${JSON.stringify(census[12])}`);
|
||||||
|
}
|
||||||
console.log(` ${name}: weeks=${sim.week} score=${sim.score} cars=${sim.cars.length} `
|
console.log(` ${name}: weeks=${sim.week} score=${sim.score} cars=${sim.cars.length} `
|
||||||
+ `houses=${sim.houses.length} buildings=${sim.buildings.length} roads=${sim.roads.size} `
|
+ `houses=${sim.houses.length} buildings=${sim.buildings.length} roads=${sim.roads.size} `
|
||||||
+ `stranded=${stranded} gameOver=${sim.gameOver}`);
|
+ `stranded=${stranded} gameOver=${sim.gameOver}`);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue