feat: add Bejeweled Blitz and Mini Motorways games

- Implement Phaser scenes and pure JS logic modules for both games
- Register games in server registry and scene routing
- Add headless verification scripts for Bejeweled and Mini Motorways logic
- Update game-icons sprite sheet
This commit is contained in:
Brian Fertig 2026-06-12 13:52:27 -06:00
parent 01dcfbfd85
commit 3cd0f2b2e7
11 changed files with 4706 additions and 1 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 225 KiB

After

Width:  |  Height:  |  Size: 232 KiB

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,424 @@
// Bejeweled Blitz — pure game logic (no Phaser).
//
// Board is ROWS×COLS of gems { color, special }. Swapping two adjacent gems is
// legal when it creates a run of 3+ (or involves a Hypercube). Matches resolve
// in cascading phases; each phase reports exactly what happened so the scene
// can animate it: cleared gems, special-gem spawns, detonation events, falls
// and refills, plus the points earned.
//
// Specials:
// FLAME — from a 4-in-a-row; detonates a 3×3 blast when cleared.
// STAR — from an L/T intersection; clears its full row and column.
// HYPER — from 5+ in a row; swap with any gem to clear that colour
// (detonated by a blast, it zaps a random colour instead).
// MULT — multiplier gem dropped during deep cascades; clearing it raises
// the global score multiplier (×2 … ×8).
export const COLS = 8;
export const ROWS = 8;
export const BLITZ_SECONDS = 60;
export const MAX_MULTIPLIER = 8;
export const GEM_COLORS = ['red', 'orange', 'yellow', 'green', 'blue', 'purple', 'white'];
export const SPECIAL = {
NONE: 'none',
FLAME: 'flame',
STAR: 'star',
HYPER: 'hyper',
MULT: 'mult',
};
const key = (c, r) => r * COLS + c;
const unkey = (k) => ({ c: k % COLS, r: (k / COLS) | 0 });
const inBounds = (c, r) => c >= 0 && c < COLS && r >= 0 && r < ROWS;
function randomGem(rng) {
return { color: GEM_COLORS[(rng() * GEM_COLORS.length) | 0], special: SPECIAL.NONE };
}
// ── Board construction ──────────────────────────────────────────────────────
export function randomBoard(rng = Math.random) {
for (let attempt = 0; attempt < 100; attempt++) {
const board = [];
for (let r = 0; r < ROWS; r++) {
board[r] = [];
for (let c = 0; c < COLS; c++) {
let gem;
do { gem = randomGem(rng); } while (
(c >= 2 && board[r][c - 1].color === gem.color && board[r][c - 2].color === gem.color) ||
(r >= 2 && board[r - 1][c].color === gem.color && board[r - 2][c].color === gem.color)
);
board[r][c] = gem;
}
}
if (findMove(board)) return board;
}
throw new Error('Could not generate a board with a legal move.');
}
export function newGame(rng = Math.random) {
return { board: randomBoard(rng), multiplier: 1, noMoves: false };
}
// ── Runs & match groups ─────────────────────────────────────────────────────
export function findRuns(board) {
const runs = [];
for (let r = 0; r < ROWS; r++) {
let c = 0;
while (c < COLS) {
const color = board[r][c]?.color;
if (!color) { c++; continue; }
let end = c + 1;
while (end < COLS && board[r][end]?.color === color) end++;
if (end - c >= 3) {
const cells = [];
for (let i = c; i < end; i++) cells.push([i, r]);
runs.push({ color, horizontal: true, cells });
}
c = end;
}
}
for (let c = 0; c < COLS; c++) {
let r = 0;
while (r < ROWS) {
const color = board[r][c]?.color;
if (!color) { r++; continue; }
let end = r + 1;
while (end < ROWS && board[end][c]?.color === color) end++;
if (end - r >= 3) {
const cells = [];
for (let i = r; i < end; i++) cells.push([c, i]);
runs.push({ color, horizontal: false, cells });
}
r = end;
}
}
return runs;
}
// Union runs that share a cell into match groups (an L/T counts as one group).
function groupRuns(runs) {
const parent = runs.map((_, i) => i);
const find = (i) => (parent[i] === i ? i : (parent[i] = find(parent[i])));
const union = (a, b) => { parent[find(a)] = find(b); };
const byCell = new Map();
runs.forEach((run, i) => run.cells.forEach(([c, r]) => {
const k = key(c, r);
if (byCell.has(k)) union(i, byCell.get(k));
else byCell.set(k, i);
}));
const groups = new Map();
runs.forEach((run, i) => {
const root = find(i);
if (!groups.has(root)) groups.set(root, { color: run.color, runs: [], cells: new Set() });
const g = groups.get(root);
g.runs.push(run);
run.cells.forEach(([c, r]) => g.cells.add(key(c, r)));
});
return [...groups.values()];
}
// Where a freshly-earned special gem materialises: the swapped cell if it is
// part of the group, else the runs' shared cell, else the longest run's middle.
function pickSpawnKey(group, swapKeys) {
for (const k of swapKeys) if (group.cells.has(k)) return k;
if (group.runs.length >= 2) {
const seen = new Set();
for (const run of group.runs) {
for (const [c, r] of run.cells) {
const k = key(c, r);
if (seen.has(k)) return k;
seen.add(k);
}
}
}
const longest = group.runs.reduce((a, b) => (b.cells.length > a.cells.length ? b : a));
const [c, r] = longest.cells[(longest.cells.length / 2) | 0];
return key(c, r);
}
// ── Detonation chains ───────────────────────────────────────────────────────
// Expand a seed set of cleared cells through special-gem chain reactions.
// hyperOverrides maps a hyper gem's key to the colour it must zap (set by a
// hyper swap); hypers consumed by blasts zap a random colour on the board.
function expandClears(board, seedKeys, rng, hyperOverrides = new Map()) {
const keys = new Set();
const events = [];
const queue = [];
const add = (c, r) => {
if (!inBounds(c, r)) return;
const k = key(c, r);
if (keys.has(k) || !board[r][c]) return;
keys.add(k);
const sp = board[r][c].special;
if (sp === SPECIAL.FLAME || sp === SPECIAL.STAR || sp === SPECIAL.HYPER) queue.push(k);
};
for (const k of seedKeys) { const { c, r } = unkey(k); add(c, r); }
while (queue.length) {
const k = queue.shift();
const { c, r } = unkey(k);
const sp = board[r][c].special;
if (sp === SPECIAL.FLAME) {
events.push({ type: 'flame', c, r });
for (let dr = -1; dr <= 1; dr++) for (let dc = -1; dc <= 1; dc++) add(c + dc, r + dr);
} else if (sp === SPECIAL.STAR) {
events.push({ type: 'star', c, r });
for (let i = 0; i < COLS; i++) add(i, r);
for (let i = 0; i < ROWS; i++) add(c, i);
} else if (sp === SPECIAL.HYPER) {
let color = hyperOverrides.get(k) ?? null;
if (!color) {
const present = new Set();
for (let rr = 0; rr < ROWS; rr++) for (let cc = 0; cc < COLS; cc++) {
if (board[rr][cc]?.color && !keys.has(key(cc, rr))) present.add(board[rr][cc].color);
}
const pool = [...present];
color = pool.length ? pool[(rng() * pool.length) | 0] : null;
}
const cells = [];
if (color) {
for (let rr = 0; rr < ROWS; rr++) for (let cc = 0; cc < COLS; cc++) {
if (board[rr][cc]?.color === color) { cells.push([cc, rr]); add(cc, rr); }
}
}
events.push({ type: 'hyper', c, r, color, cells });
}
}
return { keys, events };
}
// ── Gravity & refill ────────────────────────────────────────────────────────
function collapse(board, rng) {
const falls = [];
const refills = [];
for (let c = 0; c < COLS; c++) {
let write = ROWS - 1;
for (let r = ROWS - 1; r >= 0; r--) {
if (!board[r][c]) continue;
if (write !== r) {
board[write][c] = board[r][c];
board[r][c] = null;
falls.push({ c, fromR: r, toR: write });
}
write--;
}
const newCount = write + 1;
for (let r = write; r >= 0; r--) {
const gem = randomGem(rng);
board[r][c] = gem;
refills.push({ c, r, color: gem.color, special: gem.special, fromR: r - newCount });
}
}
return { falls, refills };
}
// ── Cascade driver ──────────────────────────────────────────────────────────
const GEM_POINTS = 30;
const EVENT_BONUS = { flame: 100, star: 200, hyper: 400 };
// Resolve one or more cascade phases. opts.preClear seeds phase 1 directly
// (hyper swaps, Last Hurrah); afterwards phases come from runs on the board.
function runCascades(state, rng, opts = {}) {
const board = state.board;
const phases = [];
let swapKeys = opts.swapKeys ?? [];
let preClear = opts.preClear ?? null;
let multDropped = false;
let cascade = 0;
while (cascade < 30) {
cascade++;
let seedKeys;
let spawns = [];
let runBonus = 0;
let hyperOverrides = new Map();
if (preClear) {
seedKeys = preClear.keys;
runBonus = preClear.bonus ?? 0;
hyperOverrides = preClear.hyperOverrides ?? new Map();
preClear = null;
} else {
const runs = findRuns(board);
if (!runs.length) break;
seedKeys = new Set();
for (const group of groupRuns(runs)) {
group.cells.forEach((k) => seedKeys.add(k));
const maxLen = Math.max(...group.runs.map((r) => r.cells.length));
let special = null;
if (maxLen >= 5) special = SPECIAL.HYPER;
else if (group.runs.length >= 2) special = SPECIAL.STAR;
else if (maxLen === 4) special = SPECIAL.FLAME;
if (special) {
const k = pickSpawnKey(group, swapKeys);
spawns.push({ k, color: special === SPECIAL.HYPER ? null : group.color, special });
}
runBonus += (maxLen - 3) * 100 + (group.runs.length >= 2 ? 150 : 0);
}
}
const { keys, events } = expandClears(board, seedKeys, rng, hyperOverrides);
const spawnKeys = new Set(spawns.map((s) => s.k));
// Multiplier gems consumed this phase raise the global multiplier.
let eventBonus = 0;
const cleared = [];
for (const k of keys) {
const { c, r } = unkey(k);
const gem = board[r][c];
if (gem.special === SPECIAL.MULT && state.multiplier < MAX_MULTIPLIER) {
state.multiplier++;
events.push({ type: 'mult', c, r, multiplier: state.multiplier });
}
if (!spawnKeys.has(k)) cleared.push({ c, r, color: gem.color, special: gem.special });
}
for (const e of events) eventBonus += EVENT_BONUS[e.type] ?? 0;
const points = Math.round((keys.size * GEM_POINTS + runBonus + eventBonus) * cascade * state.multiplier);
for (const k of keys) { const { c, r } = unkey(k); board[r][c] = null; }
const placedSpawns = [];
for (const s of spawns) {
const { c, r } = unkey(s.k);
board[r][c] = { color: s.color, special: s.special };
placedSpawns.push({ c, r, color: s.color, special: s.special });
}
const { falls, refills } = collapse(board, rng);
// Deep cascades can drop a multiplier gem into the refill.
if (opts.allowMult !== false && !multDropped && cascade >= 2
&& state.multiplier < MAX_MULTIPLIER && refills.length && rng() < 0.6) {
const pick = refills[(rng() * refills.length) | 0];
pick.special = SPECIAL.MULT;
board[pick.r][pick.c].special = SPECIAL.MULT;
multDropped = true;
}
phases.push({
cascade, points, multiplier: state.multiplier,
cleared, spawns: placedSpawns, events, falls, refills,
});
swapKeys = [];
}
state.noMoves = !findMove(board);
return phases;
}
// ── Moves ───────────────────────────────────────────────────────────────────
// Attempt a swap of adjacent cells a/b ({c, r}). Returns an array of phases,
// or null if the swap is illegal (board left untouched).
export function applyMove(state, a, b, rng = Math.random) {
const board = state.board;
if (!inBounds(a.c, a.r) || !inBounds(b.c, b.r)) return null;
if (Math.abs(a.c - b.c) + Math.abs(a.r - b.r) !== 1) return null;
const A = board[a.r][a.c];
const B = board[b.r][b.c];
if (!A || !B) return null;
if (A.special === SPECIAL.HYPER || B.special === SPECIAL.HYPER) {
const keys = new Set();
const hyperOverrides = new Map();
let bonus;
if (A.special === SPECIAL.HYPER && B.special === SPECIAL.HYPER) {
// Double hypercube: the whole board goes up.
for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS; c++) keys.add(key(c, r));
hyperOverrides.set(key(a.c, a.r), null);
hyperOverrides.set(key(b.c, b.r), null);
bonus = 2000;
} else {
const hyper = A.special === SPECIAL.HYPER ? a : b;
const other = A.special === SPECIAL.HYPER ? B : A;
keys.add(key(hyper.c, hyper.r));
hyperOverrides.set(key(hyper.c, hyper.r), other.color);
bonus = 500;
}
return runCascades(state, rng, { preClear: { keys, bonus, hyperOverrides } });
}
board[a.r][a.c] = B;
board[b.r][b.c] = A;
if (!findRuns(board).length) {
board[a.r][a.c] = A;
board[b.r][b.c] = B;
return null;
}
return runCascades(state, rng, { swapKeys: [key(b.c, b.r), key(a.c, a.r)] });
}
// When the clock runs out every special left on the board detonates,
// repeatedly, until none remain.
export function lastHurrah(state, rng = Math.random) {
const board = state.board;
const phases = [];
for (let round = 0; round < 12; round++) {
const keys = new Set();
for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS; c++) {
if (board[r][c] && board[r][c].special !== SPECIAL.NONE) keys.add(key(c, r));
}
if (!keys.size) break;
phases.push(...runCascades(state, rng, { preClear: { keys }, allowMult: false }));
}
return phases;
}
// ── Move search / shuffle ───────────────────────────────────────────────────
export function findMove(board) {
for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS; c++) {
if (board[r][c]?.special === SPECIAL.HYPER) {
const b = c + 1 < COLS ? { c: c + 1, r } : { c: c - 1, r };
return { a: { c, r }, b };
}
}
for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS; c++) {
for (const [dc, dr] of [[1, 0], [0, 1]]) {
const c2 = c + dc, r2 = r + dr;
if (!inBounds(c2, r2)) continue;
const A = board[r][c], B = board[r2][c2];
if (!A || !B || A.color === B.color) continue;
board[r][c] = B; board[r2][c2] = A;
const hit = findRuns(board).length > 0;
board[r][c] = A; board[r2][c2] = B;
if (hit) return { a: { c, r }, b: { c: c2, r: r2 } };
}
}
return null;
}
// Rearrange the existing gems into a fresh layout with no instant matches and
// at least one legal move. Falls back to a brand-new board if that fails.
export function shuffleBoard(state, rng = Math.random) {
const gems = [];
for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS; c++) gems.push(state.board[r][c]);
for (let attempt = 0; attempt < 300; attempt++) {
for (let i = gems.length - 1; i > 0; i--) {
const j = (rng() * (i + 1)) | 0;
[gems[i], gems[j]] = [gems[j], gems[i]];
}
const board = [];
for (let r = 0; r < ROWS; r++) board[r] = gems.slice(r * COLS, (r + 1) * COLS);
if (!findRuns(board).length && findMove(board)) {
state.board = board;
state.noMoves = false;
return board;
}
}
state.board = randomBoard(rng);
state.noMoves = false;
return state.board;
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -70,6 +70,8 @@ import MahjongMatchGame from './games/mahjongmatch/MahjongMatchGame.js';
import MahjongGame from './games/mahjong/MahjongGame.js'; import MahjongGame from './games/mahjong/MahjongGame.js';
import JewelQuestGame from './games/jewelquest/JewelQuestGame.js'; import JewelQuestGame from './games/jewelquest/JewelQuestGame.js';
import ZumaGame from './games/zuma/ZumaGame.js'; import ZumaGame from './games/zuma/ZumaGame.js';
import BejeweledGame from './games/bejeweled/BejeweledGame.js';
import MiniMotorwaysGame from './games/minimotorways/MiniMotorwaysGame.js';
const config = { const config = {
type: Phaser.AUTO, type: Phaser.AUTO,
@ -153,6 +155,8 @@ const config = {
MahjongGame, MahjongGame,
JewelQuestGame, JewelQuestGame,
ZumaGame, ZumaGame,
BejeweledGame,
MiniMotorwaysGame,
], ],
}; };

View File

@ -22,7 +22,7 @@ export default class GameRoomScene extends Phaser.Scene {
} }
create() { create() {
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame', monopoly: 'MonopolyGame', triominoes: 'TriominoesGame', freecell: 'FreecellGame', rushhour: 'RushHourGame', hexsweeper: 'HexsweeperGame', puddingmonsters: 'PuddingMonstersGame', shift: 'ShiftGame', blockfighter: 'BlockFighterGame', mahjongmatch: 'MahjongMatchGame', mahjong: 'MahjongGame', jewelquest: 'JewelQuestGame', zuma: 'ZumaGame' }; const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame', monopoly: 'MonopolyGame', triominoes: 'TriominoesGame', freecell: 'FreecellGame', rushhour: 'RushHourGame', hexsweeper: 'HexsweeperGame', puddingmonsters: 'PuddingMonstersGame', shift: 'ShiftGame', blockfighter: 'BlockFighterGame', mahjongmatch: 'MahjongMatchGame', mahjong: 'MahjongGame', jewelquest: 'JewelQuestGame', zuma: 'ZumaGame', bejeweled: 'BejeweledGame', minimotorways: 'MiniMotorwaysGame' };
if (slugDispatch[this.game.slug]) { if (slugDispatch[this.game.slug]) {
this.scene.start(slugDispatch[this.game.slug], { this.scene.start(slugDispatch[this.game.slug], {
game: this.game, game: this.game,

View File

@ -85,3 +85,5 @@ registerGame({ slug: 'mahjongmatch', name: 'Mahjong Match', category: '
registerGame({ slug: 'mahjong', name: 'Mahjong', category: 'tabletop', minPlayers: 4, maxPlayers: 4, minOpponents: 3, maxOpponents: 3, hasTutorial: true, iconFrame: 58 }); registerGame({ slug: 'mahjong', name: 'Mahjong', category: 'tabletop', minPlayers: 4, maxPlayers: 4, minOpponents: 3, maxOpponents: 3, hasTutorial: true, iconFrame: 58 });
registerGame({ slug: 'jewelquest', name: 'Jewel Quest', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, hasTutorial: true, iconFrame: 59 }); registerGame({ slug: 'jewelquest', name: 'Jewel Quest', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, hasTutorial: true, iconFrame: 59 });
registerGame({ slug: 'zuma', name: 'Zuma', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 60 }); registerGame({ slug: 'zuma', name: 'Zuma', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 60 });
registerGame({ slug: 'bejeweled', name: 'Bejeweled Blitz', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 61 });
registerGame({ slug: 'minimotorways', name: 'Mini Motorways', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 62 });

View File

@ -0,0 +1,281 @@
// Headless verification for Bejeweled Blitz.
// node server/scripts/verifyBejeweled.js
// Exits non-zero on any failure.
//
// 1. Fixture tests: special-gem creation and detonation on hand-built boards.
// 2. Monte-carlo self-play: thousands of random moves with invariants checked
// after every resolution (board full, no resting matches, phases coherent).
// 3. Last Hurrah and shuffle sanity.
import {
COLS, ROWS, SPECIAL, GEM_COLORS,
newGame, applyMove, lastHurrah, findMove, findRuns, shuffleBoard, randomBoard,
} from '../../public/src/games/bejeweled/BejeweledLogic.js';
let failures = 0;
function check(name, cond, detail = '') {
if (cond) { console.log(` ok ${name}`); return; }
failures++;
console.error(` FAIL ${name}${detail ? `${detail}` : ''}`);
}
function mulberry32(seed) {
let a = seed >>> 0;
return () => {
a |= 0; a = (a + 0x6D2B79F5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// Build a board from 8 strings of 8 colour initials (r/o/y/g/b/p/w).
// Uppercase suffix markers are handled by the caller via overrides.
const INITIAL = { r: 'red', o: 'orange', y: 'yellow', g: 'green', b: 'blue', p: 'purple', w: 'white' };
function boardFromStrings(rows, overrides = {}) {
const board = [];
for (let r = 0; r < ROWS; r++) {
board[r] = [];
for (let c = 0; c < COLS; c++) {
const color = INITIAL[rows[r][c]];
board[r][c] = { color, special: SPECIAL.NONE };
}
}
for (const [k, v] of Object.entries(overrides)) {
const [c, r] = k.split(',').map(Number);
board[r][c] = { ...board[r][c], ...v };
}
return board;
}
function boardFull(board) {
for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS; c++) {
const cell = board[r][c];
if (!cell) return false;
if (cell.special !== SPECIAL.HYPER && !GEM_COLORS.includes(cell.color)) return false;
}
return true;
}
console.log('Fixture: 3-match clears, board refills');
{
// Swapping (0,1)↔(0,0)? Build a guaranteed vertical 3-match: column 0 has
// red at rows 1,2 and a red arrives at row 0 via swap from (1,0).
const rows = [
'rgybgypg',
'grybyopw',
'gboprwyb',
'ywgwobry',
'obrygwpo',
'wpogrbwy',
'rygbpoyr',
'bowyrgbw',
];
const board = boardFromStrings(rows);
const state = { board, multiplier: 1, noMoves: false };
// (1,0) is 'g'; swap with (0,0)='r'? col0 rows1,2 are g,g → moving g to (0,0) makes col0 g,g,g.
const phases = applyMove(state, { c: 1, r: 0 }, { c: 0, r: 0 }, mulberry32(7));
check('legal swap returns phases', Array.isArray(phases) && phases.length >= 1);
check('phase 1 cleared 3+ gems', phases && phases[0].cleared.length >= 3);
check('phase points positive', phases && phases[0].points > 0);
check('board still full after resolution', boardFull(state.board));
check('no resting matches', findRuns(state.board).length === 0);
}
console.log('Fixture: illegal swap rejected, board untouched');
{
const state = newGame(mulberry32(3));
const snapshot = JSON.stringify(state.board);
// Find a swap that yields no match.
let rejected = false;
outer:
for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS - 1; c++) {
const res = applyMove(state, { c, r }, { c: c + 1, r }, mulberry32(4));
if (res === null) { rejected = true; break outer; }
// applyMove mutated the board — restore for the next probe.
state.board = JSON.parse(snapshot);
}
check('some non-matching swap was rejected', rejected);
check('rejected swap left board unchanged', JSON.stringify(state.board) === snapshot);
check('non-adjacent swap rejected', applyMove(state, { c: 0, r: 0 }, { c: 2, r: 0 }) === null);
}
console.log('Fixture: match 4 spawns a Flame gem');
{
const rows = [
'gybgypgo',
'rrwryopw',
'gboprwyb',
'ywgwobry',
'obrygwpo',
'wpogrbwy',
'rygbpoyr',
'bowyrgbw',
];
// Row 1: r r w r — swapping (2,1)'w' with (2,0)'b'? need the 'w' replaced by r.
// Instead swap (2,1)↔(2,2): (2,2)='o'… simpler: put r at (2,0) and swap down.
const board = boardFromStrings(rows, { '2,0': { color: 'red' } });
const state = { board, multiplier: 1, noMoves: false };
const phases = applyMove(state, { c: 2, r: 0 }, { c: 2, r: 1 }, mulberry32(9));
check('4-match resolved', phases !== null);
const spawns = phases ? phases.flatMap((p) => p.spawns) : [];
check('flame gem spawned', spawns.some((s) => s.special === SPECIAL.FLAME && s.color === 'red'),
JSON.stringify(spawns));
}
console.log('Fixture: match 5 spawns a Hypercube; hyper swap zaps a colour');
{
const rows = [
'gybgypgo',
'rrwrropw',
'gboprwyb',
'ywgwobry',
'obrygwpo',
'wpogrbwy',
'rygbpoyr',
'bowyrgbw',
];
// Row 1 becomes r r r r r after dropping a red into (2,1) from (2,0).
const board = boardFromStrings(rows, { '2,0': { color: 'red' } });
const state = { board, multiplier: 1, noMoves: false };
const phases = applyMove(state, { c: 2, r: 0 }, { c: 2, r: 1 }, mulberry32(11));
const spawns = phases ? phases.flatMap((p) => p.spawns) : [];
const hyperSpawn = spawns.find((s) => s.special === SPECIAL.HYPER);
check('hypercube spawned from 5-match', !!hyperSpawn, JSON.stringify(spawns));
// Now swap the hypercube with a neighbour and confirm a colour sweep.
if (hyperSpawn) {
// The hyper may have fallen; find it.
let pos = null;
for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS; c++) {
if (state.board[r][c]?.special === SPECIAL.HYPER) pos = { c, r };
}
check('hypercube present on board', !!pos);
if (pos) {
const nb = pos.c > 0 ? { c: pos.c - 1, r: pos.r } : { c: pos.c + 1, r: pos.r };
const target = state.board[nb.r][nb.c].color;
const before = JSON.stringify(state.board);
const hp = applyMove(state, pos, nb, mulberry32(13));
check('hyper swap always legal', hp !== null);
if (hp) {
const ev = hp.flatMap((p) => p.events).find((e) => e.type === 'hyper');
check('hyper event fired with swapped colour', !!ev && ev.color === target,
`event=${JSON.stringify(ev)} target=${target}`);
check('board full after hyper sweep', boardFull(state.board));
} else {
state.board = JSON.parse(before);
}
}
}
}
console.log('Fixture: L-match spawns a Star; star detonation clears row+col');
{
const rows = [
'rwbgypgo',
'rgwbropw',
'gboprwyb',
'ywgwobry',
'obrygwpo',
'wpogrbwy',
'rygbpoyr',
'bowyrgbw',
];
// Column 0: r r g … and row 0: r w b — swap (1,0)'g' row0? Build L:
// put red at (1,1) wait — simpler: col0 rows0,1 red; row2 'g b o' — make
// row 2 start r ? Use overrides: row2 col1,col2 red → swapping (0,2)'g'
// with (1,2)? Instead: cells (0,0),(0,1) red vertical; (1,2),(2,2) red
// horizontal… Build explicitly:
const board = boardFromStrings(rows, {
'0,0': { color: 'red' }, '0,1': { color: 'red' }, // col 0, rows 0,1
'1,2': { color: 'red' }, '2,2': { color: 'red' }, // row 2, cols 1,2
'0,3': { color: 'green' }, // below the L corner
'0,2': { color: 'blue' }, '1,3': { color: 'yellow' },
});
// Swap (1,3)? The corner (0,2) needs red: swap (0,2)'blue' with… place red at (1,2)? taken.
// Give (0,3) red and swap it up into (0,2): col0 r,r,[r] + row2 [r],r,r → L of 5.
board[3][0] = { color: 'red', special: SPECIAL.NONE };
board[2][0] = { color: 'blue', special: SPECIAL.NONE };
const state = { board, multiplier: 1, noMoves: false };
const phases = applyMove(state, { c: 0, r: 3 }, { c: 0, r: 2 }, mulberry32(17));
check('L-swap resolved', phases !== null);
const spawns = phases ? phases.flatMap((p) => p.spawns) : [];
check('star gem spawned from L', spawns.some((s) => s.special === SPECIAL.STAR),
JSON.stringify(spawns));
}
console.log('Monte-carlo self-play');
{
const rng = mulberry32(42);
let totalMoves = 0;
let totalPhases = 0;
let specialsSeen = 0;
let multsSeen = 0;
let maxCascade = 0;
let invariantsOk = true;
let pointsOk = true;
for (let game = 0; game < 60; game++) {
const state = newGame(rng);
for (let move = 0; move < 80; move++) {
const mv = findMove(state.board);
if (!mv) { shuffleBoard(state, rng); continue; }
const phases = applyMove(state, mv.a, mv.b, rng);
if (!phases) { invariantsOk = false; console.error(' findMove suggested an illegal move', mv); break; }
totalMoves++;
totalPhases += phases.length;
for (const p of phases) {
maxCascade = Math.max(maxCascade, p.cascade);
if (p.points <= 0) pointsOk = false;
specialsSeen += p.spawns.length;
multsSeen += p.events.filter((e) => e.type === 'mult').length;
// falls/refills coherence: every refill lands on a distinct cell.
const seen = new Set();
for (const f of p.refills) {
const k = `${f.c},${f.r}`;
if (seen.has(k)) { invariantsOk = false; console.error(' duplicate refill cell', k); }
seen.add(k);
}
}
if (!boardFull(state.board)) { invariantsOk = false; console.error(' board has holes after move'); break; }
if (findRuns(state.board).length) { invariantsOk = false; console.error(' resting matches after move'); break; }
if (state.multiplier < 1 || state.multiplier > 8) { invariantsOk = false; console.error(' multiplier out of range'); break; }
}
if (!invariantsOk) break;
}
check('played 4000+ moves', totalMoves >= 4000, `moves=${totalMoves}`);
check('all invariants held', invariantsOk);
check('all phases scored points', pointsOk);
check('cascades occurred', totalPhases > totalMoves, `phases=${totalPhases}`);
check('special gems spawned', specialsSeen > 0, `specials=${specialsSeen}`);
check('multiplier gems appeared', multsSeen > 0, `mults=${multsSeen}`);
console.log(` info moves=${totalMoves} phases=${totalPhases} specials=${specialsSeen} mults=${multsSeen} maxCascade=${maxCascade}`);
}
console.log('Last Hurrah & shuffle');
{
const rng = mulberry32(99);
const state = newGame(rng);
// Seed some specials by hand.
state.board[7][0].special = SPECIAL.FLAME;
state.board[7][3].special = SPECIAL.STAR;
state.board[7][6] = { color: null, special: SPECIAL.HYPER };
state.board[6][2].special = SPECIAL.MULT;
const phases = lastHurrah(state, rng);
check('last hurrah produced phases', phases.length >= 1);
check('last hurrah detonated events', phases.flatMap((p) => p.events).length >= 3);
let specialsLeft = 0;
for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS; c++) {
if (state.board[r][c].special !== SPECIAL.NONE) specialsLeft++;
}
check('no specials remain after last hurrah', specialsLeft === 0, `left=${specialsLeft}`);
check('board full after last hurrah', boardFull(state.board));
const s2 = { board: randomBoard(rng), multiplier: 1, noMoves: false };
shuffleBoard(s2, rng);
check('shuffle leaves no resting matches', findRuns(s2.board).length === 0);
check('shuffle leaves a legal move', !!findMove(s2.board));
}
console.log(failures ? `\n${failures} FAILURE(S)` : '\nAll checks passed.');
process.exit(failures ? 1 : 0);

View File

@ -0,0 +1,380 @@
// Headless verification for Mini Motorways.
// node server/scripts/verifyMiniMotorways.js
// Exits non-zero on any failure.
//
// 1. City generation invariants for all six cities.
// 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.
import {
WORLD_W, WORLD_H, TERRAIN, TUNE, CITIES, COLOR_NAMES,
Sim, generateCity, keyOf, xOf, yOf, octile,
} from '../../public/src/games/minimotorways/MiniMotorwaysLogic.js';
let failures = 0;
function check(name, cond, detail = '') {
if (cond) { console.log(` ok ${name}`); return; }
failures++;
console.error(` FAIL ${name}${detail ? `${detail}` : ''}`);
}
// A sim stripped to bare terrain for hand-built network fixtures.
function fixtureSim() {
const sim = new Sim(0, 12345);
sim.terrain.fill(TERRAIN.LAND);
sim.houses = [];
sim.buildings = [];
sim.cars = [];
sim.roads.clear();
sim.bridgeCells.clear();
sim.bridges = [];
sim.items.clear();
sim.motorways = [];
sim.portals.clear();
sim.cellOcc.clear();
sim.touchNetwork();
return sim;
}
// ── 1. City generation ─────────────────────────────────────────────────────────
console.log('City generation');
{
const r = { x0: (WORLD_W - 20) / 2, y0: (WORLD_H - 12) / 2, w: 20, h: 12 };
for (let ci = 0; ci < CITIES.length; ci++) {
for (const seed of [1, 777, 424242]) {
const { terrain } = generateCity(ci, seed);
let bad = terrain.length !== WORLD_W * WORLD_H;
let water = 0; let startWater = 0;
for (let i = 0; i < terrain.length; i++) {
if (terrain[i] > 2) bad = true;
if (terrain[i] === TERRAIN.WATER) {
water++;
const x = xOf(i); const y = yOf(i);
if (x >= r.x0 && x < r.x0 + r.w && y >= r.y0 && y < r.y0 + r.h) startWater++;
}
}
check(`${CITIES[ci].name} seed ${seed}: terrain valid`, !bad);
check(`${CITIES[ci].name} seed ${seed}: start rect ≥80% land`,
startWater <= r.w * r.h * 0.2, `water=${startWater}`);
const expectsWater = CITIES[ci].gen.rivers > 0 || CITIES[ci].gen.lakes > 0;
if (expectsWater) check(`${CITIES[ci].name} seed ${seed}: has water`, water > 0);
}
}
}
// ── 2. Adjacency ───────────────────────────────────────────────────────────────
console.log('Adjacency / crossing rule');
{
const sim = fixtureSim();
// 2x2 all-road square: diagonals must be suppressed, orthogonals intact.
const square = [keyOf(5, 5), keyOf(6, 5), keyOf(5, 6), keyOf(6, 6)];
for (const k of square) sim.roads.add(k);
sim.touchNetwork();
let diagonals = 0; let orthogonals = 0;
for (const k of square) {
for (const nb of sim.roadNeighbors(k)) {
if (Math.abs(nb.cost - Math.SQRT2) < 0.001) diagonals++;
else orthogonals++;
}
}
check('2x2 square has zero diagonal edges', diagonals === 0, `diag=${diagonals}`);
check('2x2 square fully orthogonally connected', orthogonals === 8, `orth=${orthogonals}`);
// Diagonal staircase keeps its diagonal edges at cost √2.
const sim2 = fixtureSim();
const stairs = [keyOf(10, 10), keyOf(11, 11), keyOf(12, 12)];
for (const k of stairs) sim2.roads.add(k);
sim2.touchNetwork();
const mid = sim2.roadNeighbors(keyOf(11, 11));
check('staircase middle has two diagonal edges', mid.length === 2);
check('diagonal edge costs √2', mid.every((nb) => Math.abs(nb.cost - Math.SQRT2) < 0.001));
// Symmetry: every edge runs both ways.
const sim3 = fixtureSim();
for (let i = 0; i < 60; i++) {
sim3.roads.add(keyOf(2 + Math.floor(Math.random() * 20), 2 + Math.floor(Math.random() * 15)));
}
sim3.touchNetwork();
let asym = 0;
for (const k of sim3.roads) {
for (const nb of sim3.roadNeighbors(k)) {
if (!sim3.roadNeighbors(nb.k).some((b) => b.k === k)) asym++;
}
}
check('random network adjacency is symmetric', asym === 0, `asym=${asym}`);
}
// ── 3. Pathfinding ─────────────────────────────────────────────────────────────
console.log('Pathfinding');
{
const sim = fixtureSim();
for (let x = 1; x <= 10; x++) sim.roads.add(keyOf(x, 1));
sim.touchNetwork();
const path = sim.findPath([keyOf(1, 1)], [keyOf(10, 1)]);
check('straight 10-cell road: path found', !!path);
check('straight path has 10 cells', path && path.length === 10, `len=${path?.length}`);
// Bridge across a 2-wide channel.
const sim2 = fixtureSim();
for (let y = 0; y < WORLD_H; y++) {
sim2.terrain[keyOf(12, y)] = TERRAIN.WATER;
sim2.terrain[keyOf(13, y)] = TERRAIN.WATER;
}
for (let x = 8; x <= 11; x++) sim2.roads.add(keyOf(x, 5));
for (let x = 14; x <= 17; x++) sim2.roads.add(keyOf(x, 5));
sim2.touchNetwork();
check('channel blocks path', sim2.findPath([keyOf(8, 5)], [keyOf(17, 5)]) === null);
sim2.stock.bridges = 1;
const span = [keyOf(12, 5), keyOf(13, 5)];
check('bridge placement allowed', sim2.canPlaceBridge(span));
sim2.placeBridge(span);
const over = sim2.findPath([keyOf(8, 5)], [keyOf(17, 5)]);
check('bridge connects the banks', !!over && over.length === 10, `len=${over?.length}`);
check('bridge cells marked', sim2.bridgeCells.has(span[0]) && sim2.bridgeCells.has(span[1]));
sim2.eraseBridgeAt(span[0]);
check('erasing bridge disconnects again', sim2.findPath([keyOf(8, 5)], [keyOf(17, 5)]) === null);
check('bridge stock refunded', sim2.stock.bridges === 1);
// Motorway shortcut.
const sim3 = fixtureSim();
for (let x = 1; x <= 30; x++) sim3.roads.add(keyOf(x, 1));
sim3.touchNetwork();
sim3.stock.motorways = 1;
const a = keyOf(1, 2); const b = keyOf(30, 2);
check('motorway placement allowed', sim3.canPlaceMotorway(a, b));
sim3.placeMotorway(a, b);
const quick = sim3.findPath([keyOf(1, 1)], [keyOf(30, 1)]);
check('motorway path found', !!quick);
check('path takes the portal', quick && quick.includes(a) && quick.includes(b));
check('portal route is short', quick && quick.length <= 6, `len=${quick?.length}`);
sim3.eraseMotorwayAt(a);
const slow = sim3.findPath([keyOf(1, 1)], [keyOf(30, 1)]);
check('after erase, path is the long way', !!slow && slow.length === 30, `len=${slow?.length}`);
check('motorway stock refunded', sim3.stock.motorways === 1);
// Intersection items need ≥3 connections.
const sim4 = fixtureSim();
sim4.roads.add(keyOf(5, 5)); sim4.roads.add(keyOf(4, 5)); sim4.roads.add(keyOf(6, 5));
sim4.touchNetwork();
sim4.stock.lights = 1;
check('light rejected on straight road', !sim4.canPlaceItem(keyOf(5, 5), 'light'));
sim4.roads.add(keyOf(5, 4));
sim4.touchNetwork();
check('light allowed on T-junction', sim4.canPlaceItem(keyOf(5, 5), 'light'));
}
// ── 4. Overflow ends the game ──────────────────────────────────────────────────
console.log('Overflow → game over');
{
const sim = new Sim(0, 99);
let gameOverEvent = null;
let guard = 0;
while (!sim.gameOver && guard++ < 10000) {
const events = sim.step(100);
for (const e of events) {
if (e.type === 'weekEnd') sim.chooseUpgrade(0);
if (e.type === 'gameOver') gameOverEvent = e;
}
}
check('roadless city overflows to game over', sim.gameOver);
check('game over event emitted with culprit', !!gameOverEvent && gameOverEvent.buildingId > 0);
check('overflow timing plausible', sim.time > 60000 && sim.time < 400000, `t=${sim.time}`);
check('score stayed at zero', sim.score === 0);
}
// ── 5. Monte-carlo bot ─────────────────────────────────────────────────────────
console.log('Monte-carlo bot, 15 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
// become bridges, longer runs abort the connection.
function botConnect(sim, fromCells, toCells) {
const from = Array.isArray(fromCells) ? fromCells : [fromCells];
const to = new Set(Array.isArray(toCells) ? toCells : [toCells]);
const structs = new Set([...from, ...to]);
const occ = sim.buildOccupiedSet();
const cellCost = (k) => {
if (structs.has(k)) return 1;
if (sim.terrain[k] === TERRAIN.WATER) return sim.roads.has(k) ? 1 : 6;
if (sim.terrain[k] === TERRAIN.LAND && !occ.has(k)) return 1;
return Infinity;
};
const dist = new Map();
const prev = new Map();
const open = [...from.map((k) => [0, k])];
for (const k of from) dist.set(k, 0);
let hit = null;
while (open.length && hit === null) {
open.sort((a, b) => a[0] - b[0]);
const [d, k] = open.shift();
if (d > (dist.get(k) ?? Infinity)) continue;
if (to.has(k)) { hit = k; break; }
const x = xOf(k); const y = yOf(k);
for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
const nx = x + dx; const ny = y + dy;
if (nx < 0 || nx >= WORLD_W || ny < 0 || ny >= WORLD_H) continue;
const nk = keyOf(nx, ny);
const c = cellCost(nk);
if (c === Infinity) continue;
const nd = d + c;
if (nd < (dist.get(nk) ?? Infinity)) {
dist.set(nk, nd);
prev.set(nk, k);
open.push([nd, nk]);
}
}
}
if (hit === null) return false;
const path = [];
for (let k = hit; k !== undefined; k = prev.get(k)) path.push(k);
path.reverse();
// Validate water runs first: each must be ≤3 cells and straight.
const runs = [];
let run = [];
for (const k of path) {
if (sim.terrain[k] === TERRAIN.WATER && !sim.roads.has(k)) { run.push(k); continue; }
if (run.length) { runs.push(run); run = []; }
}
if (run.length) runs.push(run);
for (const r of runs) {
if (r.length > 3) return false;
const straight = r.every((k) => xOf(k) === xOf(r[0])) || r.every((k) => yOf(k) === yOf(r[0]));
if (!straight) return false;
}
for (const r of runs) {
if (sim.stock.bridges < 1 || !sim.placeBridge(r)) return false;
}
for (const k of path) {
if (!structs.has(k) && !sim.roads.has(k) && sim.terrain[k] !== TERRAIN.WATER) sim.placeRoad(k);
}
return true;
}
function botHandleSpawn(sim, e) {
if (e.type === 'houseSpawn') {
const targets = sim.buildings.filter((b) => b.color === e.color);
targets.sort((p, q) => octile(xOf(p.k), yOf(p.k), xOf(e.k), yOf(e.k))
- octile(xOf(q.k), yOf(q.k), xOf(e.k), yOf(e.k)));
for (const t of targets.slice(0, 4)) {
if (botConnect(sim, e.k, t.cells)) return;
}
} else if (e.type === 'buildingSpawn') {
const building = sim.buildings.find((b) => b.id === e.id);
const homes = sim.houses.filter((h) => h.color === e.color && h.k !== e.k);
homes.sort((p, q) => octile(xOf(p.k), yOf(p.k), xOf(e.k), yOf(e.k))
- octile(xOf(q.k), yOf(q.k), xOf(e.k), yOf(e.k)));
for (const h of homes.slice(0, 4)) {
if (botConnect(sim, h.k, building.cells)) return;
}
}
}
// Late repairs: any building accumulating pins with nothing en route gets a
// fresh connection attempt to its nearest same-colour houses.
function botRescue(sim) {
for (const b of sim.buildings) {
if (b.pins < 3 || (b.reserved > 0 && b.pins < 5)) continue;
const homes = sim.houses.filter((h) => h.color === b.color);
homes.sort((p, q) => octile(xOf(p.k), yOf(p.k), xOf(b.k), yOf(b.k))
- octile(xOf(q.k), yOf(q.k), xOf(b.k), yOf(b.k)));
for (const h of homes.slice(0, 3)) {
if (botConnect(sim, h.k, b.cells)) break;
}
}
}
function checkInvariants(sim, label) {
for (const car of sim.cars) {
if (Number.isNaN(car.x) || Number.isNaN(car.y) || Number.isNaN(car.pos)) {
return `${label}: NaN in car ${car.id} (${car.state})`;
}
}
if (sim.cars.length > TUNE.CAR_CAP) return `${label}: car cap exceeded (${sim.cars.length})`;
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}`;
}
for (const k of sim.roads) {
for (const nb of sim.roadNeighbors(k)) {
if (!sim.roadNeighbors(nb.k).some((x) => x.k === k)) {
return `${label}: asymmetric edge ${k}${nb.k}`;
}
}
}
return null;
}
{
const targetWeeks = 15;
for (let ci = 0; ci < CITIES.length; ci++) {
const sim = new Sim(ci, 1000 + ci);
sim.stock.roads = 99999;
sim.stock.bridges += 50;
// Wire up the structures spawned during construction.
for (const h of sim.houses) {
const targets = sim.buildings.filter((b) => b.color === h.color);
if (targets.length) botConnect(sim, h.k, targets[0].cells);
}
let lastScore = 0;
let scoreRegressed = false;
let invariantError = null;
let steps = 0;
let stranded = 0;
while (sim.week < targetWeeks && !sim.gameOver && steps < 30000) {
const events = sim.step(100);
steps++;
for (const e of events) {
if (e.type === 'weekEnd') sim.chooseUpgrade(0);
else if (e.type === 'houseSpawn' || e.type === 'buildingSpawn' || e.type === 'colorUnlock') {
if (e.type !== 'colorUnlock') botHandleSpawn(sim, e);
} else if (e.type === 'stranded') stranded++;
}
if (sim.score < lastScore) scoreRegressed = true;
lastScore = sim.score;
// 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) {
const plain = [...sim.roads].filter((k) => !sim.bridgeCells.has(k) && !sim.portals.has(k));
if (plain.length) {
const victim = plain[Math.floor(Math.random() * plain.length)];
sim.eraseRoad(victim);
sim.step(100); steps++;
sim.placeRoad(victim);
}
}
if (steps % 100 === 0) botRescue(sim);
if (steps % 50 === 0 && !invariantError) {
invariantError = checkInvariants(sim, `${CITIES[ci].name} step ${steps}`);
}
}
const name = CITIES[ci].name;
check(`${name}: no invariant violations`, !invariantError, invariantError ?? '');
check(`${name}: score is positive`, sim.score > 0, `score=${sim.score}`);
check(`${name}: score never regressed`, !scoreRegressed);
check(`${name}: survived or died legitimately`,
sim.week >= targetWeeks || sim.gameOver, `week=${sim.week} steps=${steps}`);
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}`);
}
}
console.log(failures ? `\n${failures} FAILURE(S)` : '\nAll checks passed.');
process.exit(failures ? 1 : 0);