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:
Brian Fertig 2026-07-23 21:55:20 -06:00
parent 09324f5b20
commit 94cc51b997
3 changed files with 104 additions and 37 deletions

View File

@ -1344,7 +1344,7 @@ export default class MiniMotorwaysGame extends Phaser.Scene {
root.add(this.add.text(cx, cy - 212, `WEEK ${week} COMPLETE`, {
fontFamily: 'Righteous', fontSize: '46px', color: COLORS.goldHex,
}).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,
}).setOrigin(0.5));

View File

@ -16,16 +16,14 @@ export const COLOR_HEX = {
export const TUNE = {
WEEK_MS: 70000,
SUBSTEP_MS: 50,
CAR_CAP: 60,
HOUSE_CAP: 20,
BUILDING_CAP: 8,
MIN_HOUSES_PER_COLOR: 2, // a colour is never left without supply…
CAR_SPEED: 2.5, // cells / second
HEADWAY: 0.65, // minimum gap behind the car ahead, in cells
DWELL_MS: 1000,
COOLDOWN_MS: 2000,
PIN_MS_BASE: 12000, // pin interval = max(MIN, BASE * DECAY^week)
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_CAP: 12,
OVERFLOW_PINS: 8,
@ -39,7 +37,6 @@ export const TUNE = {
BUILDING_MS_DECAY: 0.88,
BUILDING_MS_MIN: 70000,
START_ROADS: 30,
WEEK_ROADS: 12,
UPGRADE_ROADS: 10,
MOTORWAY_COST: 2.5, // A* cost of the portal edge
MOTORWAY_MS: 1200, // real traversal time, ignores all traffic
@ -51,11 +48,14 @@ export const TUNE = {
DISPATCH_MS: 500,
SECOND_CAR_WEEK: 2, // houses gain a second car from this week on
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: [
{ week: 0, w: 20, h: 12 },
{ week: 3, w: 26, h: 16 },
{ week: 6, w: 32, h: 20 },
{ week: 9, w: 40, h: 24 },
{ week: 0, w: 20, h: 12, houseCap: 14, buildingCap: 5, carCap: 24, weekRoads: 12 },
{ week: 3, w: 26, h: 16, houseCap: 24, buildingCap: 8, carCap: 40, weekRoads: 16 },
{ week: 6, w: 32, h: 20, houseCap: 34, buildingCap: 11, carCap: 56, weekRoads: 20 },
{ 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 }); }
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); }
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); }
@ -460,29 +464,38 @@ export class Sim {
canPlaceBridge(cells) {
if (this.stock.bridges < 1) 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 horiz = ys.every((y) => y === ys[0]);
const vert = xs.every((x) => x === xs[0]);
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 x = xOf(k); const y = yOf(k);
return inBounds(x, y) && this.terrain[k] !== TERRAIN.WATER;
};
// Guard against wrap-around on horizontal runs at the map edge.
if (horiz && (xOf(sorted[0]) === 0 || xOf(sorted[sorted.length - 1]) === WORLD_W - 1)) return false;
if (!horiz && (yOf(sorted[0]) === 0 || yOf(sorted[sorted.length - 1]) === WORLD_H - 1)) return false;
return dry(before) && dry(after);
// Validate the run as a straight span along one axis (step 1 = east-west,
// WORLD_W = north-south): contiguous, dry land just beyond each end, and no
// wrap-around at the map edge.
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) {
@ -712,7 +725,10 @@ export class Sim {
}
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);
if (k === null) return null;
const house = { id: this.nextId++, color, k, carIds: [] };
@ -721,8 +737,11 @@ export class Sim {
return house;
}
spawnBuilding(color) {
if (this.buildings.length >= TUNE.BUILDING_CAP) return null;
spawnBuilding(color, force = false) {
// 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);
if (k === null) return null;
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)) {
const color = this.city.colorOrder[this.colorsUnlocked];
this.colorsUnlocked++;
this.spawnBuilding(color);
// Bypass the house cap: a fresh colour must never start supply-starved.
// Force past the caps: a fresh colour must never start destination-less
// or supply-starved.
this.spawnBuilding(color, true);
this.spawnHouse(color, true);
this.spawnHouse(color, true);
this.emit('colorUnlock', { color });
@ -750,6 +770,16 @@ export class Sim {
pickSpawnColor(forBuilding) {
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) => ({
color,
houses: this.houses.filter((h) => h.color === color).length,
@ -810,8 +840,9 @@ export class Sim {
rollWeek() {
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);
if (growth > this.growthIdx) {
this.growthIdx = growth;
@ -819,6 +850,8 @@ export class Sim {
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;
this.unlockColors(unlockTarget);
@ -886,7 +919,7 @@ export class Sim {
if (assignments >= 3 || failures >= 8) break;
if (building.pins - building.reserved <= 0) break;
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);
}
if (!car) continue;

View File

@ -6,7 +6,7 @@
// 2. Road adjacency fixtures (diagonal crossing rule, costs).
// 3. Pathfinder fixtures (straight runs, bridges, motorway shortcuts).
// 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 {
WORLD_W, WORLD_H, TERRAIN, TUNE, CITIES, COLOR_NAMES,
@ -190,7 +190,7 @@ console.log('Overflow → game over');
// ── 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,
// 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})`;
}
}
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) {
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}`;
}
// 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 nb of sim.roadNeighbors(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++) {
const sim = new Sim(ci, 1000 + ci);
sim.stock.roads = 99999;
@ -333,8 +352,10 @@ function checkInvariants(sim, label) {
let invariantError = null;
let steps = 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);
steps++;
for (const e of events) {
@ -346,6 +367,12 @@ function checkInvariants(sim, label) {
if (sim.score < lastScore) scoreRegressed = true;
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,
// then put it back two ticks later.
if (steps % 600 === 300 && sim.roads.size > 10) {
@ -370,6 +397,13 @@ function checkInvariants(sim, label) {
check(`${name}: score never regressed`, !scoreRegressed);
check(`${name}: survived or died legitimately`,
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} `
+ `houses=${sim.houses.length} buildings=${sim.buildings.length} roads=${sim.roads.size} `
+ `stranded=${stranded} gameOver=${sim.gameOver}`);