From 94cc51b9978d0b8cb726eaddf358c62530905162 Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Thu, 23 Jul 2026 21:55:20 -0600 Subject: [PATCH] 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 --- src/games/minimotorways/MiniMotorwaysGame.js | 2 +- src/games/minimotorways/MiniMotorwaysLogic.js | 95 +++++++++++++------ tools/verifyMiniMotorways.js | 44 ++++++++- 3 files changed, 104 insertions(+), 37 deletions(-) diff --git a/src/games/minimotorways/MiniMotorwaysGame.js b/src/games/minimotorways/MiniMotorwaysGame.js index 12234b6..f674dd6 100644 --- a/src/games/minimotorways/MiniMotorwaysGame.js +++ b/src/games/minimotorways/MiniMotorwaysGame.js @@ -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)); diff --git a/src/games/minimotorways/MiniMotorwaysLogic.js b/src/games/minimotorways/MiniMotorwaysLogic.js index 3f907ec..25cdbd4 100644 --- a/src/games/minimotorways/MiniMotorwaysLogic.js +++ b/src/games/minimotorways/MiniMotorwaysLogic.js @@ -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; diff --git a/tools/verifyMiniMotorways.js b/tools/verifyMiniMotorways.js index 9b4b74d..6e44864 100644 --- a/tools/verifyMiniMotorways.js +++ b/tools/verifyMiniMotorways.js @@ -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}`);