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`, {
|
||||
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));
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
|
|
|
|||
Loading…
Reference in New Issue