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:
parent
01dcfbfd85
commit
3cd0f2b2e7
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
|
|
@ -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
|
|
@ -70,6 +70,8 @@ import MahjongMatchGame from './games/mahjongmatch/MahjongMatchGame.js';
|
|||
import MahjongGame from './games/mahjong/MahjongGame.js';
|
||||
import JewelQuestGame from './games/jewelquest/JewelQuestGame.js';
|
||||
import ZumaGame from './games/zuma/ZumaGame.js';
|
||||
import BejeweledGame from './games/bejeweled/BejeweledGame.js';
|
||||
import MiniMotorwaysGame from './games/minimotorways/MiniMotorwaysGame.js';
|
||||
|
||||
const config = {
|
||||
type: Phaser.AUTO,
|
||||
|
|
@ -153,6 +155,8 @@ const config = {
|
|||
MahjongGame,
|
||||
JewelQuestGame,
|
||||
ZumaGame,
|
||||
BejeweledGame,
|
||||
MiniMotorwaysGame,
|
||||
],
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ export default class GameRoomScene extends Phaser.Scene {
|
|||
}
|
||||
|
||||
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]) {
|
||||
this.scene.start(slugDispatch[this.game.slug], {
|
||||
game: this.game,
|
||||
|
|
|
|||
|
|
@ -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: '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: '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 });
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
Loading…
Reference in New Issue