diff --git a/public/assets/images/game-icons.png b/public/assets/images/game-icons.png index e288b62..bb56cf1 100644 Binary files a/public/assets/images/game-icons.png and b/public/assets/images/game-icons.png differ diff --git a/public/assets/images/game-icons.psd b/public/assets/images/game-icons.psd index 7a95f13..e994999 100644 Binary files a/public/assets/images/game-icons.psd and b/public/assets/images/game-icons.psd differ diff --git a/public/data/blockfighter.json b/public/data/blockfighter.json new file mode 100644 index 0000000..b27f6f2 --- /dev/null +++ b/public/data/blockfighter.json @@ -0,0 +1,44 @@ +{ + "levels": [ + { "level": 1, "opponentId": "ethel", "skill": 1, "speed": 1, "tagline": "A gentle warm-up over tea.", + "dropPattern": ["RRRRRR", "GGGGGG", "BBBBBB", "YYYYYY"] }, + { "level": 2, "opponentId": "kona", "skill": 1, "speed": 1, "tagline": "Good puppy. Surprisingly good at gems.", + "dropPattern": ["YYYYYY", "BBBBBB", "GGGGGG", "RRRRRR"] }, + { "level": 3, "opponentId": "bernie", "skill": 2, "speed": 1, "tagline": "All fun and games... until the gems drop.", + "dropPattern": ["RRRGGG", "RRRGGG", "BBBYYY", "BBBYYY"] }, + { "level": 4, "opponentId": "brad", "skill": 2, "speed": 1, "tagline": "Less salmon, more smashing.", + "dropPattern": ["GGGYYY", "GGGYYY", "RRRBBB", "RRRBBB"] }, + { "level": 5, "opponentId": "jerry", "skill": 3, "speed": 2, "tagline": "Y'all ready for a real scrap?", + "dropPattern": ["RRGGBB", "RRGGBB", "YYRRGG", "YYRRGG"] }, + { "level": 6, "opponentId": "jeff", "skill": 3, "speed": 2, "tagline": "Plays slow. Wins fast. You've been warned.", + "dropPattern": ["BBYYRR", "BBYYRR", "GGBBYY", "GGBBYY"] }, + { "level": 7, "opponentId": "mario", "skill": 4, "speed": 2, "tagline": "Welcome to the maze of falling gems!", + "dropPattern": ["RRGGYY", "BBRRGG", "YYBBRR", "GGYYBB"] }, + { "level": 8, "opponentId": "juliet", "skill": 4, "speed": 2, "tagline": "A summer day, a storm of gems.", + "dropPattern": ["GGBBRR", "YYGGBB", "RRYYGG", "BBRRYY"] }, + { "level": 9, "opponentId": "michael", "skill": 5, "speed": 3, "tagline": "Easy vibes, heavy chains, mon.", + "dropPattern": ["RGGBBY", "YRGGBB", "BYRGGB", "BBYRGG"] }, + { "level": 10, "opponentId": "croc", "skill": 5, "speed": 3, "tagline": "The party's over when your board fills up.", + "dropPattern": ["GBYRGB", "BYRGBY", "YRGBYR", "RGBYRG"] }, + { "level": 11, "opponentId": "gerome", "skill": 6, "speed": 3, "tagline": "Extreme victory or nothing!", + "dropPattern": ["RYRYRY", "GBGBGB", "YRYRYR", "BGBGBG"] }, + { "level": 12, "opponentId": "beth", "skill": 6, "speed": 3, "tagline": "These parts have rules, stranger.", + "dropPattern": ["BRBRBR", "YGYGYG", "RBRBRB", "GYGYGY"] }, + { "level": 13, "opponentId": "steve", "skill": 7, "speed": 4, "tagline": "Stupid Earth gems. Prepare to lose.", + "dropPattern": ["RGBYRG", "BYRGBY", "GBYRGB", "YRGBYR"] }, + { "level": 14, "opponentId": "fireball", "skill": 7, "speed": 4, "tagline": "No x-ray eyes. Just flawless drops.", + "dropPattern": ["RRYYRR", "YYRRYY", "GGBBGG", "BBGGBB"] }, + { "level": 15, "opponentId": "natasha", "skill": 8, "speed": 4, "tagline": "Your secrets fall with your gems.", + "dropPattern": ["RGYBGR", "BYGRYB", "GRBYRG", "YBRGBY"] }, + { "level": 16, "opponentId": "victor", "skill": 8, "speed": 4, "tagline": "Every drop calculated. Centuries ago.", + "dropPattern": ["RGBYBG", "YBGRGB", "GYRBRY", "BRYGYR"] }, + { "level": 17, "opponentId": "balam", "skill": 9, "speed": 5, "tagline": "Mystical powers meet falling blocks.", + "dropPattern": ["GYBRYG", "RBYGBR", "YGRBGY", "BRGYRB"] }, + { "level": 18, "opponentId": "cybro", "skill": 9, "speed": 5, "tagline": "The future has already beaten you.", + "dropPattern": ["RBGYRB", "GYRBGY", "BRYGBR", "YGBRYG"] }, + { "level": 19, "opponentId": "zanthor", "skill": 10, "speed": 5, "tagline": "Alacazam! Your board is doomed!", + "dropPattern": ["RYGBRY", "GBRYGB", "YRBGYR", "BGYRBG"] }, + { "level": 20, "opponentId": "blackwind", "skill": 10, "speed": 5, "tagline": "The final showdown on the high seas!", + "dropPattern": ["RGBYRG", "YBRGYB", "GRYBGR", "BYGRBY"] } + ] +} diff --git a/public/src/games/blockfighter/BlockFighterAI.js b/public/src/games/blockfighter/BlockFighterAI.js new file mode 100644 index 0000000..570829a --- /dev/null +++ b/public/src/games/blockfighter/BlockFighterAI.js @@ -0,0 +1,185 @@ +// Block Fighter AI — placement search over the headless engine. +// Skill 1-10 controls lookahead, blunder rate, evaluation richness, and how +// fast the AI physically executes its plan (one input per movePeriodMs). + +import { + WIDTH, HEIGHT, SPAWN_COL, KIND, + legalPlacements, simulatePlacement, makeRng, peekPiece, +} from './BlockFighterLogic.js'; + +// skill → knobs (interpolated linearly between anchor rows) +const SKILL_ANCHORS = [ + { skill: 1, lookahead: 1, blunder: 0.45, movePeriodMs: 620, profile: 'basic' }, + { skill: 3, lookahead: 1, blunder: 0.25, movePeriodMs: 460, profile: 'cluster' }, + { skill: 5, lookahead: 1, blunder: 0.10, movePeriodMs: 330, profile: 'full' }, + { skill: 7, lookahead: 2, blunder: 0.05, movePeriodMs: 230, profile: 'full' }, + { skill: 9, lookahead: 2, blunder: 0.01, movePeriodMs: 150, profile: 'aggressive' }, + { skill: 10, lookahead: 2, blunder: 0.00, movePeriodMs: 110, profile: 'aggressive' }, +]; + +function knobsFor(skill) { + const s = Math.max(1, Math.min(10, skill)); + let lo = SKILL_ANCHORS[0], hi = SKILL_ANCHORS[SKILL_ANCHORS.length - 1]; + for (let i = 0; i < SKILL_ANCHORS.length - 1; i++) { + if (s >= SKILL_ANCHORS[i].skill && s <= SKILL_ANCHORS[i + 1].skill) { + lo = SKILL_ANCHORS[i]; hi = SKILL_ANCHORS[i + 1]; + break; + } + } + const t = hi.skill === lo.skill ? 0 : (s - lo.skill) / (hi.skill - lo.skill); + return { + lookahead: t < 0.5 ? lo.lookahead : hi.lookahead, + blunder: lo.blunder + (hi.blunder - lo.blunder) * t, + movePeriodMs: Math.round(lo.movePeriodMs + (hi.movePeriodMs - lo.movePeriodMs) * t), + profile: t < 0.5 ? lo.profile : hi.profile, + }; +} + +const EVAL_WEIGHTS = { + basic: { attack: 40, cleared: 6, cluster: 0, crash: 0, power: 0, height: 1.0, spawnCol: 2, counters: 0, bumpy: 0 }, + cluster: { attack: 40, cleared: 6, cluster: 2.5, crash: 4, power: 0, height: 1.0, spawnCol: 2, counters: 0.5, bumpy: 0.5 }, + full: { attack: 45, cleared: 5, cluster: 3, crash: 6, power: 5, height: 1.1, spawnCol: 3, counters: 1, bumpy: 1 }, + aggressive: { attack: 60, cleared: 4, cluster: 3.5, crash: 8, power: 7, height: 1.2, spawnCol: 4, counters: 1.5, bumpy: 1 }, +}; + +export function createAI({ skill = 5, speed = 1, seed = 1 } = {}) { + const knobs = knobsFor(skill); + // higher level speed also quickens the AI's hands a bit + knobs.movePeriodMs = Math.round(knobs.movePeriodMs * (1 - 0.08 * (speed - 1))); + return { + skill, + knobs, + rng: makeRng(seed), + plan: null, // { col, orient } + nextActionAt: 0, + useSoftDrop: skill >= 4, + useHardDrop: skill >= 7, + }; +} + +// ── Board evaluation ───────────────────────────────────────────────────────── +function evaluateBoard(player, w) { + const { board } = player; + let score = 0; + + const heights = new Array(WIDTH).fill(0); + for (let c = 0; c < WIDTH; c++) { + for (let r = 0; r < HEIGHT; r++) { + if (board[r][c]) { heights[c] = HEIGHT - r; break; } + } + } + for (let c = 0; c < WIDTH; c++) { + const weight = c === SPAWN_COL ? w.spawnCol : w.height; + score -= weight * heights[c] * heights[c] * 0.25; + } + for (let c = 0; c < WIDTH - 1; c++) score -= w.bumpy * Math.abs(heights[c] - heights[c + 1]); + + let cluster = 0, counters = 0, crashPotential = 0; + for (let r = 0; r < HEIGHT; r++) { + for (let c = 0; c < WIDTH; c++) { + const cell = board[r][c]; + if (!cell) continue; + if (cell.kind === KIND.COUNTER) { counters += 1; continue; } + const right = c + 1 < WIDTH ? board[r][c + 1] : null; + const down = r + 1 < HEIGHT ? board[r + 1][c] : null; + if (right && right.kind !== KIND.COUNTER && right.color === cell.color) cluster += 1; + if (down && down.kind !== KIND.COUNTER && down.color === cell.color) cluster += 1; + if (cell.kind === KIND.CRASH) { + // size of the same-color group this crash gem touches + let touching = 0; + for (const [dr, dc] of [[-1, 0], [1, 0], [0, -1], [0, 1]]) { + const n = board[r + dr]?.[c + dc]; + if (n && n.color === cell.color && n.kind !== KIND.COUNTER) touching += 1; + } + crashPotential += touching; + } + } + } + score += w.cluster * cluster + w.crash * crashPotential - w.counters * counters; + + let power = 0; + for (const gem of player.powerGems.values()) power += Math.pow(gem.w * gem.h, 1.3); + score += w.power * power; + + return score; +} + +function scorePlacement(result, w) { + if (!result) return -Infinity; + if (result.died) return -1e9; + return w.attack * result.attack + w.cleared * result.cleared + evaluateBoard(result.player, w); +} + +// ── Planning ───────────────────────────────────────────────────────────────── +export function planPlacement(ai, match, pIdx) { + const player = match.players[pIdx]; + if (!player.piece) return null; + const w = EVAL_WEIGHTS[ai.knobs.profile]; + const options = []; + + for (const { col, orient } of legalPlacements(player)) { + const result = simulatePlacement(match, pIdx, col, orient); + if (!result) continue; + let score = scorePlacement(result, w); + + if (ai.knobs.lookahead >= 2 && score > -1e8) { + // probe the next shared piece on the resulting board + const nextProto = peekPiece(match, player.pieceIndex); + if (nextProto) { + const ghost = { + players: pIdx === 0 + ? [result.player, match.players[1]] + : [match.players[0], result.player], + over: false, winner: null, headless: true, + }; + result.player.piece = { a: { ...nextProto.a }, b: { ...nextProto.b }, row: 1, col: SPAWN_COL, orient: 0 }; + let best2 = -Infinity; + for (const p2 of legalPlacements(result.player)) { + const r2 = simulatePlacement(ghost, pIdx, p2.col, p2.orient); + if (!r2) continue; + const s2 = scorePlacement(r2, w); + if (s2 > best2) best2 = s2; + } + result.player.piece = null; + if (best2 > -Infinity) score = score * 0.6 + best2 * 0.4; + } + } + options.push({ col, orient, score }); + } + + if (!options.length) { ai.plan = null; return null; } + options.sort((a, b) => b.score - a.score); + + let pick = options[0]; + if (ai.rng() < ai.knobs.blunder) { + pick = options[Math.floor(ai.rng() * Math.min(options.length, 5))]; + } + ai.plan = { col: pick.col, orient: pick.orient }; + ai.nextActionAt = 0; + return ai.plan; +} + +// ── Execution: one input per movePeriodMs ──────────────────────────────────── +export function nextAction(ai, match, pIdx, nowMs) { + const player = match.players[pIdx]; + if (!player.piece || !ai.plan) return null; + if (nowMs < ai.nextActionAt) return null; + + const piece = player.piece; + let action = null; + if (piece.orient !== ai.plan.orient) { + const cwDist = (ai.plan.orient - piece.orient + 4) % 4; + action = cwDist <= 2 ? 'rotateCW' : 'rotateCCW'; + } else if (piece.col > ai.plan.col) { + action = 'left'; + } else if (piece.col < ai.plan.col) { + action = 'right'; + } else if (ai.useHardDrop) { + action = 'hardDrop'; + } else if (ai.useSoftDrop) { + action = 'softDrop'; + } + + if (action) ai.nextActionAt = nowMs + ai.knobs.movePeriodMs; + return action; +} diff --git a/public/src/games/blockfighter/BlockFighterGame.js b/public/src/games/blockfighter/BlockFighterGame.js new file mode 100644 index 0000000..408ded7 --- /dev/null +++ b/public/src/games/blockfighter/BlockFighterGame.js @@ -0,0 +1,818 @@ +import * as Phaser from 'phaser'; +import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js'; +import { Button } from '../../ui/Button.js'; +import { MusicPlayer } from '../../ui/MusicPlayer.js'; +import { playSound, SFX } from '../../ui/Sounds.js'; +import { api } from '../../services/api.js'; +import { createOpponentPortrait, createPlayerPortrait } from '../../ui/Portrait.js'; +import { + WIDTH, VISIBLE_ROWS, HIDDEN_ROWS, KIND, SPEED_GRAVITY_MS, + createMatch, spawnPiece, stepDown, hardDrop, moveLeft, moveRight, + rotateCW, rotateCCW, getGhostCells, pieceCells, peekPiece, +} from './BlockFighterLogic.js'; +import { createAI, planPlacement, nextAction } from './BlockFighterAI.js'; + +const CELL = 52; +const BOARD_W = CELL * WIDTH; // 312 +const BOARD_H = CELL * VISIBLE_ROWS; // 624 +const BOARD_TOP = 240; +const BOARD_LEFT = [360, GAME_WIDTH - 360 - BOARD_W]; // P1 left, AI right + +const FELT = 0x101626; +const FRAME = 0x0a1020; +const CELLBG = 0x182238; +const GRIDLN = 0x243352; +const GEM_COLORS = [0xe04444, 0x2ecc71, 0x3f8efc, 0xf1c40f]; +const GEM_HEX = ['#e04444', '#2ecc71', '#3f8efc', '#f1c40f']; + +const D = { felt: -2, frame: -1, grid: 0, cells: 5, piece: 8, fx: 12, ui: 30, overlay: 60, overlayUI: 62 }; +const REPLAY_DELAY = { place: 70, settle: 150, clear: 230, diamond: 260, counter: 140, garbage: 200, attack: 60, spawn: 0, lose: 0 }; + +export default class BlockFighterGame extends Phaser.Scene { + constructor() { super('BlockFighterGame'); } + + init(data) { + this.gameDef = data.game ?? { slug: 'blockfighter', name: 'Block Fighter' }; + this.bank = []; + this.roster = []; + this.levelsCompleted = 0; + this.canPersist = true; + this.view = 'select'; + this.portraits = []; // active Portrait handles (DOM-backed, not in layer) + this.match = null; + this.overlayUp = false; + } + + async create() { + try { + const music = this.cache.json.get('music'); + if (music?.tracks) new MusicPlayer(this, music.tracks); + } catch (_) { /* optional */ } + + this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, FELT).setDepth(D.felt); + + const raw = this.cache.json.get('blockfighter'); + this.bank = (raw?.levels ?? []).slice().sort((a, b) => a.level - b.level); + + try { + const res = await fetch('/data/opponents.json'); + const json = await res.json(); + this.roster = json.opponents ?? []; + } catch (_) { this.roster = []; } + + try { + const res = await api.get('/puzzles/blockfighter/progress'); + this.levelsCompleted = res?.levelsCompleted ?? 0; + } catch (_) { + this.canPersist = false; + this.levelsCompleted = 0; + } + + this.makeTextures(); + this.layer = this.add.container(0, 0); + this.showLevelSelect(); + } + + opponentFor(levelDef) { + const opp = this.roster.find((o) => o.id === levelDef.opponentId); + if (opp) return opp; + console.warn(`blockfighter: opponent '${levelDef.opponentId}' not in roster; using stub`); + return { id: levelDef.opponentId, spriteIndex: 0, name: levelDef.opponentId, bio: '', speech: {} }; + } + + // ── Generated gem textures ────────────────────────────────────────────────── + + makeTextures() { + if (this.textures.exists('bf-gem-0')) return; + for (let c = 0; c < 4; c++) { + const color = GEM_COLORS[c]; + let g = this.make.graphics({ add: false }); + g.fillStyle(color, 1); + g.fillRoundedRect(2, 2, CELL - 4, CELL - 4, 10); + g.fillStyle(0xffffff, 0.32); + g.fillRoundedRect(7, 6, CELL - 14, 14, 6); + g.lineStyle(2, 0x000000, 0.35); + g.strokeRoundedRect(2, 2, CELL - 4, CELL - 4, 10); + g.generateTexture(`bf-gem-${c}`, CELL, CELL); + g.destroy(); + + g = this.make.graphics({ add: false }); + g.fillStyle(0x000000, 0.25); + g.fillCircle(CELL / 2, CELL / 2, CELL / 2 - 2); + g.fillStyle(color, 1); + g.fillCircle(CELL / 2, CELL / 2, CELL / 2 - 5); + g.fillStyle(0xffffff, 0.85); + g.fillCircle(CELL / 2, CELL / 2, 7); + g.lineStyle(3, 0xffffff, 0.5); + g.strokeCircle(CELL / 2, CELL / 2, CELL / 2 - 10); + g.generateTexture(`bf-crash-${c}`, CELL, CELL); + g.destroy(); + + g = this.make.graphics({ add: false }); + g.fillStyle(color, 0.45); + g.fillRoundedRect(3, 3, CELL - 6, CELL - 6, 8); + g.lineStyle(3, color, 0.95); + g.strokeRoundedRect(3, 3, CELL - 6, CELL - 6, 8); + g.generateTexture(`bf-counter-${c}`, CELL, CELL); + g.destroy(); + } + const g = this.make.graphics({ add: false }); + g.fillStyle(0xffffff, 1); + const m = CELL / 2; + g.fillPoints([ + { x: m, y: 3 }, { x: CELL - 4, y: m }, { x: m, y: CELL - 3 }, { x: 4, y: m }, + ], true); + g.fillStyle(0xb8e8ff, 0.85); + g.fillPoints([ + { x: m, y: 12 }, { x: CELL - 13, y: m }, { x: m, y: CELL - 12 }, { x: 13, y: m }, + ], true); + g.generateTexture('bf-diamond', CELL, CELL); + g.destroy(); + } + + // ── View management ───────────────────────────────────────────────────────── + + clearLayer() { + for (const p of this.portraits) { try { p.destroy(); } catch (_) {} } + this.portraits = []; + this.input.keyboard.off('keydown', this.onKeyDown, this); + this.layer.removeAll(true); + this.boardObjs = null; + } + + // ── Level select ──────────────────────────────────────────────────────────── + + showLevelSelect() { + this.view = 'select'; + this.overlayUp = false; + this.match = null; + this.clearLayer(); + const cx = GAME_WIDTH / 2; + + const title = this.add.text(cx, 84, 'BLOCK FIGHTER', { + fontFamily: 'Righteous', fontSize: '64px', color: COLORS.goldHex, + }).setOrigin(0.5); + const sub = this.add.text(cx, 138, 'Match gems, drop crash gems, and bury your rival in counter gems. Beat each fighter to unlock the next.', { + fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.mutedHex, + }).setOrigin(0.5); + this.layer.add([title, sub]); + + if (!this.bank.length) { + const msg = this.add.text(cx, 520, 'No levels found in /data/blockfighter.json', { + fontFamily: '"Julius Sans One"', fontSize: '26px', color: COLORS.dangerHex, align: 'center', + }).setOrigin(0.5); + const back = new Button(this, cx, GAME_HEIGHT - 90, 'Back', () => this.scene.start('GameMenu'), { variant: 'ghost' }); + this.layer.add([msg, back]); + return; + } + + const nextLevel = Math.min(this.levelsCompleted + 1, this.bank.length); + const prog = this.add.text(cx, 182, `Defeated ${this.levelsCompleted} / ${this.bank.length}`, { + fontFamily: 'Righteous', fontSize: '24px', color: COLORS.textHex, + }).setOrigin(0.5); + this.layer.add(prog); + + const COLS = 10; + const SIZE = 128; + const GAP = 16; + const gridW = COLS * SIZE + (COLS - 1) * GAP; + const left = cx - gridW / 2 + SIZE / 2; + const top = 300; + + this.bank.forEach((lv, i) => { + const col = i % COLS; + const row = Math.floor(i / COLS); + const x = left + col * (SIZE + GAP); + const y = top + row * (SIZE + GAP + 36); + const level = lv.level; + const cleared = level <= this.levelsCompleted; + const playable = level <= nextLevel; + const opp = this.opponentFor(lv); + + const fill = cleared ? 0x1f5c3a : playable ? 0x1e3a52 : 0x16202b; + const stroke = cleared ? 0x2ecc71 : playable ? COLORS.gold : 0x2a3744; + const tile = this.add.rectangle(x, y, SIZE, SIZE + 28, fill).setStrokeStyle(playable || cleared ? 3 : 2, stroke, 1); + const num = this.add.text(x, y - SIZE / 2 + 22, String(level), { + fontFamily: 'Righteous', fontSize: '26px', + color: playable || cleared ? COLORS.textHex : '#54606b', + }).setOrigin(0.5); + const objs = [tile, num]; + + if (this.textures.exists('opponents')) { + const face = this.add.image(x, y + 6, 'opponents', opp.spriteIndex ?? 0).setDisplaySize(76, 76); + if (!playable && !cleared) { face.setTint(0x333a44); face.setAlpha(0.7); } + objs.push(face); + } + const tag = this.add.text(x, y + SIZE / 2 + 2, cleared ? `✓ ${opp.name}` : playable ? opp.name : 'locked', { + fontFamily: '"Julius Sans One"', fontSize: '15px', + color: cleared ? '#9be7b4' : playable ? COLORS.mutedHex : '#54606b', + }).setOrigin(0.5); + objs.push(tag); + this.layer.add(objs); + + if (playable) { + tile.setInteractive({ useHandCursor: true }); + tile.on('pointerover', () => tile.setStrokeStyle(4, COLORS.gold, 1)); + tile.on('pointerout', () => tile.setStrokeStyle(3, stroke, 1)); + tile.on('pointerup', () => this.showIntro(level)); + } + }); + + const resume = new Button(this, cx - 150, GAME_HEIGHT - 78, `Fight Level ${nextLevel}`, () => this.showIntro(nextLevel), + { width: 280, height: 58, fontSize: 24 }); + const back = new Button(this, cx + 170, GAME_HEIGHT - 78, 'Back', () => this.scene.start('GameMenu'), + { variant: 'ghost', width: 180, height: 58, fontSize: 24 }); + const reset = new Button(this, 210, GAME_HEIGHT - 78, 'Reset Progress', () => this.confirmResetProgress(), + { variant: 'ghost', width: 260, height: 58, fontSize: 22, textColor: COLORS.dangerHex }); + this.layer.add([resume, back, reset]); + + if (!this.canPersist) { + const note = this.add.text(cx, GAME_HEIGHT - 28, 'Sign in to save your progress across devices.', { + fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.mutedHex, + }).setOrigin(0.5); + this.layer.add(note); + } + } + + confirmResetProgress() { + const cx = GAME_WIDTH / 2; + const cy = GAME_HEIGHT / 2; + const dim = this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.62).setDepth(D.overlay).setInteractive(); + const panel = this.add.graphics().setDepth(D.overlay); + panel.fillStyle(COLORS.panel, 0.98); + panel.fillRoundedRect(cx - 320, cy - 160, 640, 320, 20); + panel.lineStyle(3, COLORS.danger, 1); + panel.strokeRoundedRect(cx - 320, cy - 160, 640, 320, 20); + const title = this.add.text(cx, cy - 92, 'Reset Progress?', { + fontFamily: 'Righteous', fontSize: '52px', color: COLORS.dangerHex, + }).setOrigin(0.5).setDepth(D.overlayUI); + const msg = this.add.text(cx, cy - 14, + 'This clears every fighter you have beaten and\nstarts you back at Level 1. This cannot be undone.', { + fontFamily: '"Julius Sans One"', fontSize: '24px', color: COLORS.textHex, align: 'center', lineSpacing: 6, + }).setOrigin(0.5).setDepth(D.overlayUI); + const yes = new Button(this, cx - 150, cy + 88, 'Reset', () => { + api.post('/puzzles/blockfighter/reset').catch(() => {}); + this.levelsCompleted = 0; + this.showLevelSelect(); + }, { width: 250, height: 58, fontSize: 24, textColor: COLORS.dangerHex }).setDepth(D.overlayUI); + const no = new Button(this, cx + 150, cy + 88, 'Cancel', () => this.showLevelSelect(), + { variant: 'ghost', width: 250, height: 58, fontSize: 24 }).setDepth(D.overlayUI); + this.layer.add([dim, panel, title, msg, yes, no]); + } + + // ── Pre-battle intro ──────────────────────────────────────────────────────── + + showIntro(level) { + const lv = this.bank.find((l) => l.level === level); + if (!lv) return; + this.view = 'intro'; + this.clearLayer(); + const cx = GAME_WIDTH / 2; + const opp = this.opponentFor(lv); + + const title = this.add.text(cx, 110, `LEVEL ${level}`, { + fontFamily: 'Righteous', fontSize: '48px', color: COLORS.goldHex, + }).setOrigin(0.5); + const vs = this.add.text(cx, 560, 'VS', { + fontFamily: 'Righteous', fontSize: '40px', color: COLORS.mutedHex, + }).setOrigin(0.5).setAlpha(0.6); + this.layer.add([title, vs]); + + this.portraits.push(createOpponentPortrait(this, opp, cx, 360, 150, D.ui, { playIntro: true })); + + const name = this.add.text(cx, 552 + 80, opp.name, { + fontFamily: 'Righteous', fontSize: '54px', color: COLORS.textHex, + }).setOrigin(0.5); + const bio = this.add.text(cx, 552 + 140, opp.bio ?? '', { + fontFamily: '"Julius Sans One"', fontSize: '24px', color: COLORS.mutedHex, + }).setOrigin(0.5); + const tagline = this.add.text(cx, 552 + 188, lv.tagline ?? '', { + fontFamily: '"Julius Sans One"', fontSize: '26px', color: COLORS.goldHex, fontStyle: 'italic', + }).setOrigin(0.5); + + const stars = '★'.repeat(Math.ceil(lv.skill / 2)) + '☆'.repeat(5 - Math.ceil(lv.skill / 2)); + const bolts = '⚡'.repeat(lv.speed); + const statText = this.add.text(cx, 552 + 248, `Skill ${stars} Speed ${bolts}`, { + fontFamily: '"Julius Sans One"', fontSize: '28px', color: COLORS.textHex, + }).setOrigin(0.5); + this.layer.add([name, bio, tagline, statText]); + + const fight = new Button(this, cx - 130, GAME_HEIGHT - 110, 'FIGHT!', () => this.startBattle(level), + { width: 240, height: 66, fontSize: 30 }); + const back = new Button(this, cx + 140, GAME_HEIGHT - 110, 'Back', () => this.showLevelSelect(), + { variant: 'ghost', width: 200, height: 66, fontSize: 24 }); + this.layer.add([fight, back]); + } + + // ── Battle setup ──────────────────────────────────────────────────────────── + + startBattle(level) { + const lv = this.bank.find((l) => l.level === level); + if (!lv) return; + this.view = 'battle'; + this.overlayUp = false; + this.level = level; + this.levelDef = lv; + this.opponent = this.opponentFor(lv); + this.clearLayer(); + + const seed = (Date.now() ^ (Math.random() * 0xffffffff)) >>> 0; + this.match = createMatch({ seed, dropPatterns: [lv.dropPattern, lv.dropPattern] }); + this.ai = createAI({ skill: lv.skill, speed: lv.speed, seed: seed ^ 0x9e3779b9 }); + this.gravityMs = SPEED_GRAVITY_MS[Math.max(1, Math.min(5, lv.speed))]; + this.gravTimer = [0, 0]; + this.replayQueue = [[], []]; + this.replayTimer = [0, 0]; + this.matchEnded = false; + + this.drawBattleChrome(); + + this.input.keyboard.on('keydown', this.onKeyDown, this); + + for (const i of [0, 1]) this.beginSpawn(i); + } + + drawBattleChrome() { + const cx = GAME_WIDTH / 2; + const hud = this.add.text(cx, 56, `Level ${this.level} — vs ${this.opponent.name}`, { + fontFamily: 'Righteous', fontSize: '38px', color: COLORS.goldHex, + }).setOrigin(0.5).setDepth(D.ui); + this.layer.add(hud); + + this.boardObjs = []; + this.pieceImgs = []; + this.ghostImgs = []; + this.garbageTexts = []; + this.sentTexts = []; + this.previewImgs = []; + + for (const i of [0, 1]) { + const left = BOARD_LEFT[i]; + const g = this.add.graphics().setDepth(D.frame); + g.fillStyle(FRAME, 1); + g.fillRoundedRect(left - 16, BOARD_TOP - 16, BOARD_W + 32, BOARD_H + 32, 14); + g.fillStyle(CELLBG, 1); + g.fillRect(left, BOARD_TOP, BOARD_W, BOARD_H); + this.layer.add(g); + + const grid = this.add.graphics().setDepth(D.grid); + grid.lineStyle(1, GRIDLN, 0.8); + for (let c = 0; c <= WIDTH; c++) grid.lineBetween(left + c * CELL, BOARD_TOP, left + c * CELL, BOARD_TOP + BOARD_H); + for (let r = 0; r <= VISIBLE_ROWS; r++) grid.lineBetween(left, BOARD_TOP + r * CELL, left + BOARD_W, BOARD_TOP + r * CELL); + // danger marker over the spawn column + grid.fillStyle(0xe04444, 0.5); + grid.fillTriangle( + left + 3 * CELL + CELL / 2 - 12, BOARD_TOP - 16, + left + 3 * CELL + CELL / 2 + 12, BOARD_TOP - 16, + left + 3 * CELL + CELL / 2, BOARD_TOP - 4, + ); + this.layer.add(grid); + + const label = this.add.text(left + BOARD_W / 2, BOARD_TOP + BOARD_H + 36, i === 0 ? 'YOU' : this.opponent.name.toUpperCase(), { + fontFamily: 'Righteous', fontSize: '24px', color: i === 0 ? COLORS.accentHex ?? '#5bc0de' : COLORS.dangerHex, + }).setOrigin(0.5).setDepth(D.ui); + this.layer.add(label); + + // pending garbage indicator + sent counter above the board + const gt = this.add.text(left + BOARD_W / 2, BOARD_TOP - 44, '', { + fontFamily: 'Righteous', fontSize: '26px', color: COLORS.dangerHex, + }).setOrigin(0.5).setDepth(D.ui); + this.garbageTexts.push(gt); + const st = this.add.text(left + (i === 0 ? -16 : BOARD_W + 16), BOARD_TOP - 44, 'Sent: 0', { + fontFamily: '"Julius Sans One"', fontSize: '20px', color: COLORS.mutedHex, + }).setOrigin(i === 0 ? 1 : 0, 0.5).setDepth(D.ui); + this.sentTexts.push(st); + this.layer.add([gt, st]); + + // next-piece preview in the centre gutter + const px = i === 0 ? BOARD_LEFT[0] + BOARD_W + 90 : BOARD_LEFT[1] - 90; + const pg = this.add.graphics().setDepth(D.frame); + pg.fillStyle(FRAME, 1); + pg.fillRoundedRect(px - 44, 290 - 70, 88, 150, 10); + const pl = this.add.text(px, 290 - 92, 'NEXT', { + fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.mutedHex, + }).setOrigin(0.5).setDepth(D.ui); + this.layer.add([pg, pl]); + this.previewImgs.push([ + this.add.image(px, 290 - 28, 'bf-gem-0').setDepth(D.ui).setVisible(false), + this.add.image(px, 290 + 28, 'bf-gem-0').setDepth(D.ui).setVisible(false), + ]); + this.layer.add(this.previewImgs[i]); + + this.boardObjs.push([]); // cell images, rebuilt per render + this.pieceImgs.push([ + this.add.image(0, 0, 'bf-gem-0').setDepth(D.piece).setVisible(false), + this.add.image(0, 0, 'bf-gem-0').setDepth(D.piece).setVisible(false), + ]); + this.layer.add(this.pieceImgs[i]); + this.ghostImgs.push([ + this.add.rectangle(0, 0, CELL - 8, CELL - 8).setStrokeStyle(2, 0xffffff, 0.35).setDepth(D.grid + 1).setVisible(false), + this.add.rectangle(0, 0, CELL - 8, CELL - 8).setStrokeStyle(2, 0xffffff, 0.35).setDepth(D.grid + 1).setVisible(false), + ]); + this.layer.add(this.ghostImgs[i]); + } + + // portraits + this.portraits.push(createPlayerPortrait(this, 165, 380, 84, D.ui, 'BlockFighterGame')); + this.oppPortrait = createOpponentPortrait(this, this.opponent, GAME_WIDTH - 165, 380, 84, D.ui, { playIntro: false }); + this.portraits.push(this.oppPortrait); + + // chain callout + this.calloutText = this.add.text(GAME_WIDTH / 2, 620, '', { + fontFamily: 'Righteous', fontSize: '54px', color: COLORS.goldHex, stroke: '#000000', strokeThickness: 6, + }).setOrigin(0.5).setDepth(D.fx).setAlpha(0); + this.layer.add(this.calloutText); + + const quit = new Button(this, 140, GAME_HEIGHT - 60, 'Levels', () => this.showLevelSelect(), + { variant: 'ghost', width: 180, height: 52, fontSize: 22 }); + this.layer.add(quit); + + this.drawTouchControls(); + } + + drawTouchControls() { + const y = GAME_HEIGHT - 110; + const cx = GAME_WIDTH / 2; + const defs = [ + { x: cx - 290, glyph: '◀', action: 'left', repeat: true }, + { x: cx - 174, glyph: '▶', action: 'right', repeat: true }, + { x: cx - 58, glyph: '⟲', action: 'rotateCCW', repeat: false }, + { x: cx + 58, glyph: '⟳', action: 'rotateCW', repeat: false }, + { x: cx + 174, glyph: '▼', action: 'softDrop', repeat: true }, + { x: cx + 290, glyph: '⤓', action: 'hardDrop', repeat: false }, + ]; + for (const def of defs) { + const g = this.add.graphics().setDepth(D.ui); + g.fillStyle(COLORS.panel, 0.9); + g.fillCircle(def.x, y, 46); + g.lineStyle(2, COLORS.gold, 0.7); + g.strokeCircle(def.x, y, 46); + const t = this.add.text(def.x, y, def.glyph, { + fontFamily: 'Righteous', fontSize: '34px', color: COLORS.textHex, + }).setOrigin(0.5).setDepth(D.ui + 1); + const zone = this.add.zone(def.x, y, 96, 96).setInteractive({ useHandCursor: true }).setDepth(D.ui + 2); + let repeatEvt = null; + const stop = () => { if (repeatEvt) { repeatEvt.remove(); repeatEvt = null; } }; + zone.on('pointerdown', () => { + this.playerAction(def.action); + if (def.repeat) { + stop(); + repeatEvt = this.time.addEvent({ delay: 120, loop: true, callback: () => this.playerAction(def.action) }); + } + }); + zone.on('pointerup', stop); + zone.on('pointerout', stop); + this.layer.add([g, t, zone]); + } + } + + // ── Input ─────────────────────────────────────────────────────────────────── + + onKeyDown(event) { + if (this.view !== 'battle') return; + switch (event.code) { + case 'ArrowLeft': case 'KeyA': this.playerAction('left'); break; + case 'ArrowRight': case 'KeyD': this.playerAction('right'); break; + case 'ArrowDown': case 'KeyS': this.playerAction('softDrop'); break; + case 'ArrowUp': case 'KeyX': if (!event.repeat) this.playerAction('rotateCW'); break; + case 'KeyZ': if (!event.repeat) this.playerAction('rotateCCW'); break; + case 'Space': if (!event.repeat) this.playerAction('hardDrop'); event.preventDefault(); break; + default: break; + } + } + + playerAction(action) { this.applyAction(0, action); } + + applyAction(idx, action) { + const m = this.match; + if (!m || m.over || this.overlayUp || this.view !== 'battle') return; + if (this.replayQueue[idx].length) return; // board busy animating + const p = m.players[idx]; + if (!p.piece) return; + + let locked = false; + let events = null; + switch (action) { + case 'left': if (moveLeft(m, idx) && idx === 0) playSound(this, SFX.PIECE_CLICK); break; + case 'right': if (moveRight(m, idx) && idx === 0) playSound(this, SFX.PIECE_CLICK); break; + case 'rotateCW': if (rotateCW(m, idx) && idx === 0) playSound(this, SFX.PIECE_CLICK); break; + case 'rotateCCW': if (rotateCCW(m, idx) && idx === 0) playSound(this, SFX.PIECE_CLICK); break; + case 'softDrop': { + const r = stepDown(m, idx); + locked = r.locked; events = r.events; + this.gravTimer[idx] = 0; + break; + } + case 'hardDrop': { + events = hardDrop(m, idx); + locked = true; + break; + } + default: break; + } + if (locked) this.onLocked(idx, events); + else this.updatePieceSprites(idx); + } + + // ── Spawn / lock / replay flow ────────────────────────────────────────────── + + beginSpawn(idx) { + const events = spawnPiece(this.match, idx); + this.enqueue(idx, events); + if (this.match.players[idx].piece && idx === 1) { + planPlacement(this.ai, this.match, 1); + } + } + + onLocked(idx, events) { + playSound(this, SFX.CARD_PLACE); + this.updatePieceSprites(idx); // hides the piece (now null) + this.enqueue(idx, events); + } + + enqueue(idx, events) { + if (events?.length) this.replayQueue[idx].push(...events); + this.updateMeters(); + } + + // process one queued event; returns the delay before the next one + processEvent(idx, e) { + const left = BOARD_LEFT[idx]; + switch (e.type) { + case 'spawn': + this.updatePieceSprites(idx); + this.updatePreviews(); + break; + case 'place': + case 'settle': + case 'counter': + this.renderBoard(idx, e); + break; + case 'garbage': + this.renderBoard(idx, e); + if (e.cells?.length) playSound(this, SFX.DICE_ROLL); + break; + case 'clear': + case 'diamond': { + this.renderBoard(idx, e); + playSound(this, SFX.MASTERMIND_MATCH ?? SFX.CARD_SHOW); + for (const cell of e.cells ?? []) { + if (cell.r < HIDDEN_ROWS) continue; + const fx = this.add.rectangle( + left + cell.c * CELL + CELL / 2, + BOARD_TOP + (cell.r - HIDDEN_ROWS) * CELL + CELL / 2, + CELL - 4, CELL - 4, 0xffffff, 0.9, + ).setDepth(D.fx); + this.layer.add(fx); + this.tweens.add({ targets: fx, alpha: 0, scale: 1.4, duration: 260, onComplete: () => fx.destroy() }); + } + if (e.type === 'clear' && e.chain >= 2) { + this.showCallout(`${e.chain} CHAIN!`); + if (idx === 0) this.oppPortrait?.playEmotion('upset'); + else this.oppPortrait?.playEmotion('happy'); + } + break; + } + case 'attack': { + if (e.sent > 0) { + this.showCallout(`${idx === 0 ? 'YOU' : this.opponent.name} +${e.sent} ▶`, idx === 0 ? '#9be7b4' : '#ff8a8a'); + if (e.sent >= 6) this.oppPortrait?.playEmotion(idx === 0 ? 'upset' : 'happy'); + } + break; + } + case 'lose': + this.renderBoard(idx, e); + this.endMatch(); + break; + default: + this.renderBoard(idx, e); + break; + } + this.updateMeters(); + return REPLAY_DELAY[e.type] ?? 120; + } + + showCallout(text, color = COLORS.goldHex) { + this.calloutText.setText(text).setColor(color).setAlpha(1).setScale(0.7); + this.tweens.add({ targets: this.calloutText, scale: 1, duration: 140, ease: 'Back.easeOut' }); + this.tweens.add({ targets: this.calloutText, alpha: 0, delay: 850, duration: 300 }); + } + + // ── Rendering ─────────────────────────────────────────────────────────────── + + renderBoard(idx, snap) { + for (const o of this.boardObjs[idx]) o.destroy(); + this.boardObjs[idx] = []; + const left = BOARD_LEFT[idx]; + const inPower = new Set(); + for (const g of snap.powerGems ?? []) { + for (let r = g.y; r < g.y + g.h; r++) for (let c = g.x; c < g.x + g.w; c++) inPower.add(r * WIDTH + c); + const vr = Math.max(g.y, HIDDEN_ROWS); + const vh = g.y + g.h - vr; + if (vh <= 0) continue; + const gx = left + g.x * CELL; + const gy = BOARD_TOP + (vr - HIDDEN_ROWS) * CELL; + const pg = this.add.graphics().setDepth(D.cells); + pg.fillStyle(GEM_COLORS[g.color], 1); + pg.fillRoundedRect(gx + 2, gy + 2, g.w * CELL - 4, vh * CELL - 4, 12); + pg.fillStyle(0xffffff, 0.28); + pg.fillRoundedRect(gx + 8, gy + 6, g.w * CELL - 16, 16, 8); + pg.lineStyle(3, 0xffffff, 0.55); + pg.strokeRoundedRect(gx + 4, gy + 4, g.w * CELL - 8, vh * CELL - 8, 10); + pg.lineStyle(2, 0x000000, 0.35); + pg.strokeRoundedRect(gx + 2, gy + 2, g.w * CELL - 4, vh * CELL - 4, 12); + this.layer.add(pg); + this.boardObjs[idx].push(pg); + } + for (let r = HIDDEN_ROWS; r < snap.board.length; r++) { + for (let c = 0; c < WIDTH; c++) { + const cell = snap.board[r][c]; + if (!cell || inPower.has(r * WIDTH + c)) continue; + const x = left + c * CELL + CELL / 2; + const y = BOARD_TOP + (r - HIDDEN_ROWS) * CELL + CELL / 2; + const img = this.add.image(x, y, this.textureFor(cell)).setDepth(D.cells); + this.layer.add(img); + this.boardObjs[idx].push(img); + if (cell.kind === KIND.COUNTER) { + const t = this.add.text(x, y, String(cell.count), { + fontFamily: 'Righteous', fontSize: '26px', color: '#ffffff', stroke: '#000000', strokeThickness: 4, + }).setOrigin(0.5).setDepth(D.cells + 1); + this.layer.add(t); + this.boardObjs[idx].push(t); + } + } + } + } + + textureFor(cell) { + if (cell.kind === KIND.DIAMOND) return 'bf-diamond'; + if (cell.kind === KIND.CRASH) return `bf-crash-${cell.color}`; + if (cell.kind === KIND.COUNTER) return `bf-counter-${cell.color}`; + return `bf-gem-${cell.color}`; + } + + updatePieceSprites(idx) { + const p = this.match.players[idx]; + const imgs = this.pieceImgs[idx]; + const ghosts = this.ghostImgs[idx]; + if (!p.piece) { + imgs.forEach((img) => img.setVisible(false)); + ghosts.forEach((g) => g.setVisible(false)); + return; + } + const left = BOARD_LEFT[idx]; + const cells = pieceCells(p.piece); + cells.forEach(({ r, c, half }, i) => { + imgs[i] + .setTexture(this.textureFor(p.piece[half])) + .setPosition(left + c * CELL + CELL / 2, BOARD_TOP + (r - HIDDEN_ROWS) * CELL + CELL / 2) + .setVisible(true) + .setAlpha(r < HIDDEN_ROWS ? 0.45 : 1); + }); + getGhostCells(p).forEach(({ r, c }, i) => { + ghosts[i] + .setPosition(left + c * CELL + CELL / 2, BOARD_TOP + (r - HIDDEN_ROWS) * CELL + CELL / 2) + .setVisible(r >= HIDDEN_ROWS); + }); + } + + updatePreviews() { + if (!this.match) return; + for (const i of [0, 1]) { + const p = this.match.players[i]; + const next = peekPiece(this.match, p.pieceIndex); + this.previewImgs[i][0].setTexture(this.textureFor(next.b)).setVisible(true); + this.previewImgs[i][1].setTexture(this.textureFor(next.a)).setVisible(true); + } + } + + updateMeters() { + if (!this.match || !this.garbageTexts) return; + for (const i of [0, 1]) { + const pending = this.match.players[i].pendingGarbage; + this.garbageTexts[i].setText(pending > 0 ? `⚠ ${pending} incoming` : ''); + this.sentTexts[i].setText(`Sent: ${this.match.players[i].garbageSent}`); + } + } + + // ── Main loop ─────────────────────────────────────────────────────────────── + + update(time, delta) { + if (this.view !== 'battle' || !this.match || this.overlayUp) return; + const m = this.match; + + for (const i of [0, 1]) { + // replay queued engine events (board is frozen while animating) + if (this.replayQueue[i].length) { + this.replayTimer[i] -= delta; + if (this.replayTimer[i] <= 0) { + const e = this.replayQueue[i].shift(); + this.replayTimer[i] = this.processEvent(i, e); + } + continue; + } + if (m.over) continue; + + const p = m.players[i]; + if (!p.piece) { + if (!p.lost) this.beginSpawn(i); + continue; + } + + // AI inputs + if (i === 1) { + const act = nextAction(this.ai, m, 1, time); + if (act) { + this.applyAction(1, act); + if (!m.players[1].piece) continue; // locked via soft/hard drop + } + } + + // gravity + this.gravTimer[i] += delta; + if (this.gravTimer[i] >= this.gravityMs) { + this.gravTimer[i] = 0; + const r = stepDown(m, i); + if (r.locked) this.onLocked(i, r.events); + else this.updatePieceSprites(i); + } + } + } + + // ── End of match ──────────────────────────────────────────────────────────── + + endMatch() { + if (this.matchEnded) return; + this.matchEnded = true; + const won = this.match.winner === 0; + const p = this.match.players[0]; + + this.oppPortrait?.playEmotion(won ? 'upset' : 'happy'); + playSound(this, won ? SFX.VICTORY_SHORT : SFX.CASINO_LOSE); + + api.post('/history/single-player', { + slug: 'blockfighter', + score: p.garbageSent, + opponentScores: [this.match.players[1].garbageSent], + result: won ? 'win' : 'loss', + }).catch(() => {}); + + if (won) { + if (this.level > this.levelsCompleted) this.levelsCompleted = this.level; + api.post('/puzzles/blockfighter/complete', { level: this.level }) + .then((res) => { + if (res?.levelsCompleted != null) this.levelsCompleted = Math.max(this.levelsCompleted, res.levelsCompleted); + }) + .catch(() => {}); + } + + this.time.delayedCall(900, () => this.showEndModal(won)); + } + + showEndModal(won) { + if (this.view !== 'battle' || !this.match) return; // user already left the battle + this.overlayUp = true; + const cx = GAME_WIDTH / 2; + const cy = GAME_HEIGHT / 2; + const dim = this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.62).setDepth(D.overlay).setInteractive(); + const panel = this.add.graphics().setDepth(D.overlay); + panel.fillStyle(COLORS.panel, 0.98); + panel.fillRoundedRect(cx - 340, cy - 210, 680, 420, 20); + panel.lineStyle(3, won ? COLORS.accent : COLORS.danger, 1); + panel.strokeRoundedRect(cx - 340, cy - 210, 680, 420, 20); + this.layer.add([dim, panel]); + + const p = this.match.players[0]; + const title = this.add.text(cx, cy - 140, won ? 'VICTORY!' : 'DEFEATED', { + fontFamily: 'Righteous', fontSize: '64px', color: won ? COLORS.goldHex : COLORS.dangerHex, + }).setOrigin(0.5).setDepth(D.overlayUI); + const stat = this.add.text(cx, cy - 55, + won + ? `You beat ${this.opponent.name}!\nGarbage sent: ${p.garbageSent} Best chain: ${p.bestChain}` + : `${this.opponent.name} buried you in gems.\nGarbage sent: ${p.garbageSent} Best chain: ${p.bestChain}`, { + fontFamily: '"Julius Sans One"', fontSize: '26px', color: COLORS.textHex, align: 'center', lineSpacing: 8, + }).setOrigin(0.5).setDepth(D.overlayUI); + this.layer.add([title, stat]); + + const btns = []; + if (won) { + const hasNext = this.level < this.bank.length; + if (hasNext) { + btns.push(new Button(this, cx, cy + 50, `Next Fight (${this.level + 1})`, () => this.showIntro(this.level + 1), + { width: 340, height: 60, fontSize: 26 }).setDepth(D.overlayUI)); + } else { + btns.push(this.add.text(cx, cy + 45, 'You beat every fighter. Champion!', { + fontFamily: '"Julius Sans One"', fontSize: '24px', color: COLORS.goldHex, + }).setOrigin(0.5).setDepth(D.overlayUI)); + } + btns.push(new Button(this, cx - 110, cy + 135, 'Rematch', () => this.startBattle(this.level), + { width: 200, height: 54, fontSize: 22, variant: 'ghost' }).setDepth(D.overlayUI)); + } else { + btns.push(new Button(this, cx - 110, cy + 80, 'Retry', () => this.startBattle(this.level), + { width: 200, height: 60, fontSize: 26 }).setDepth(D.overlayUI)); + } + btns.push(new Button(this, cx + 120, won ? cy + 135 : cy + 80, 'Levels', () => this.showLevelSelect(), + { width: 200, height: 54, fontSize: 22, variant: 'ghost' }).setDepth(D.overlayUI)); + this.layer.add(btns); + } +} diff --git a/public/src/games/blockfighter/BlockFighterLogic.js b/public/src/games/blockfighter/BlockFighterLogic.js new file mode 100644 index 0000000..88c3078 --- /dev/null +++ b/public/src/games/blockfighter/BlockFighterLogic.js @@ -0,0 +1,634 @@ +// Block Fighter — pure game engine (no Phaser, no DOM, no timers). +// A Super Puzzle Fighter II Turbo style versus engine. The scene (or a headless +// script) drives all timing; every transition returns an ordered event list, +// each event carrying a board snapshot, which the renderer replays as animation. +// +// Power-gem fusion here is a deterministic approximation of the arcade's +// folklore rules: greedy largest-rectangle fusion, then full-row/column +// extension, then same-seam merging. Fixtures in verifyBlockFighter.js pin it. + +export const WIDTH = 6; +export const VISIBLE_ROWS = 12; +export const HIDDEN_ROWS = 2; +export const HEIGHT = VISIBLE_ROWS + HIDDEN_ROWS; // row 0 = top (hidden) +export const NUM_COLORS = 4; // 0=red 1=green 2=blue 3=yellow +export const SPAWN_COL = 3; +export const KIND = { GEM: 'gem', CRASH: 'crash', COUNTER: 'counter', DIAMOND: 'diamond' }; + +export const CRASH_RATE = 0.28; +export const DIAMOND_EVERY = 25; +export const COUNTER_START = 5; +export const MAX_GARBAGE_PER_DROP = 24; +export const CHAIN_MULT = [1, 2, 3, 4, 6, 8, 12, 16, 24, 32]; +export const POWER_BONUS = 0.5; // extra attack per cleared power-gem cell +export const ATTACK_DISCOUNT = 2; // cells "free" per clear step before attack +export const DIAMOND_DAMAGE_DIV = 2; + +export const SPEED_GRAVITY_MS = [null, 900, 750, 600, 475, 350]; // index = level speed 1-5 + +export const COLOR_LETTERS = 'RGBY'; +export const DEFAULT_DROP_PATTERN = ['RRGGYY', 'BBRRGG', 'YYBBRR', 'GGYYBB']; + +// ── Seeded RNG (mulberry32, matches genRushHour.js) ───────────────────────── +export function makeRng(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; + }; +} + +export function parseDropPattern(rows) { + const src = Array.isArray(rows) && rows.length === 4 ? rows : DEFAULT_DROP_PATTERN; + return src.map((row) => + [...String(row).padEnd(WIDTH, 'R').slice(0, WIDTH)].map((ch) => { + const c = COLOR_LETTERS.indexOf(ch.toUpperCase()); + return c >= 0 ? c : 0; + }) + ); +} + +// ── Match / player construction ────────────────────────────────────────────── +function emptyBoard() { + return Array.from({ length: HEIGHT }, () => Array(WIDTH).fill(null)); +} + +function createPlayer(dropPattern) { + return { + board: emptyBoard(), + powerGems: new Map(), // id -> { id, color, x, y, w, h } + nextPowerId: 1, + piece: null, // { a, b, row, col, orient }; orient: 0 b-above, 1 b-right, 2 b-below, 3 b-left + pieceIndex: 0, // next index into match.pieceSeq + pendingGarbage: 0, + dropPattern: parseDropPattern(dropPattern), + patternRow: 0, + garbageSent: 0, + garbageReceived: 0, + bestChain: 0, + lastResolve: { attack: 0, cleared: 0, chain: 0 }, + lost: false, + }; +} + +export function createMatch({ seed = 1, dropPatterns = [null, null] } = {}) { + return { + rng: makeRng(seed), + pieceSeq: [], // shared by both players for fairness + players: [createPlayer(dropPatterns[0]), createPlayer(dropPatterns[1])], + over: false, + winner: null, + }; +} + +function getSeqPiece(match, index) { + while (match.pieceSeq.length <= index) { + const i = match.pieceSeq.length; + const half = () => ({ + color: Math.floor(match.rng() * NUM_COLORS), + kind: match.rng() < CRASH_RATE ? KIND.CRASH : KIND.GEM, + }); + const a = half(); + const b = half(); + if ((i + 1) % DIAMOND_EVERY === 0) { a.kind = KIND.DIAMOND; a.color = null; } + match.pieceSeq.push({ a, b }); + } + return match.pieceSeq[index]; +} + +// Peek at (and lazily generate) the shared sequence — used for next-piece +// previews and AI lookahead. +export function peekPiece(match, index) { + return getSeqPiece(match, index); +} + +// ── Snapshots (for renderer playback) ──────────────────────────────────────── +function cloneCell(cell) { return cell ? { ...cell } : null; } +function cloneBoard(board) { return board.map((row) => row.map(cloneCell)); } + +export function snapshot(player) { + return { + board: cloneBoard(player.board), + powerGems: [...player.powerGems.values()].map((g) => ({ ...g })), + }; +} + +function evt(player, type, extra = {}) { + return { type, ...extra, ...snapshot(player) }; +} + +// ── Piece geometry ─────────────────────────────────────────────────────────── +const ORIENT_DELTA = [[-1, 0], [0, 1], [1, 0], [0, -1]]; // b relative to a + +export function pieceCells(piece) { + const [dr, dc] = ORIENT_DELTA[piece.orient]; + return [ + { r: piece.row, c: piece.col, half: 'a' }, + { r: piece.row + dr, c: piece.col + dc, half: 'b' }, + ]; +} + +function cellFree(board, r, c) { + return r >= 0 && r < HEIGHT && c >= 0 && c < WIDTH && !board[r][c]; +} + +function pieceFits(board, piece) { + return pieceCells(piece).every(({ r, c }) => cellFree(board, r, c)); +} + +// ── Spawning & garbage drops ───────────────────────────────────────────────── +export function spawnPiece(match, pIdx) { + const player = match.players[pIdx]; + const events = []; + if (match.over || player.lost) return events; + + if (player.pendingGarbage > 0) { + const dropped = dropGarbage(player); + events.push(evt(player, 'garbage', { cells: dropped, remaining: player.pendingGarbage })); + } + + const proto = getSeqPiece(match, player.pieceIndex); + player.pieceIndex += 1; + const piece = { a: { ...proto.a }, b: { ...proto.b }, row: 1, col: SPAWN_COL, orient: 0 }; + + if (!pieceFits(player.board, piece)) { + player.lost = true; + match.over = true; + match.winner = 1 - pIdx; + events.push(evt(player, 'lose')); + return events; + } + player.piece = piece; + events.push(evt(player, 'spawn', { piece: { ...piece, a: { ...piece.a }, b: { ...piece.b } } })); + return events; +} + +function topOfStack(board, col) { + for (let r = 0; r < HEIGHT; r++) if (board[r][col]) return r; + return HEIGHT; +} + +function dropGarbage(player) { + const n = Math.min(player.pendingGarbage, MAX_GARBAGE_PER_DROP); + player.pendingGarbage -= n; + player.garbageReceived += n; + const placed = []; + for (let i = 0; i < n; i++) { + const col = i % WIDTH; + const rowOffset = Math.floor(i / WIDTH); + const color = player.dropPattern[(player.patternRow + rowOffset) % 4][col]; + const r = topOfStack(player.board, col) - 1; + if (r < 0) continue; // column already full; the loss check happens at spawn + player.board[r][col] = { color, kind: KIND.COUNTER, count: COUNTER_START }; + placed.push({ r, c: col, color }); + } + player.patternRow = (player.patternRow + Math.ceil(n / WIDTH)) % 4; + return placed; +} + +// ── Player input ───────────────────────────────────────────────────────────── +function tryShift(player, dc) { + if (!player.piece) return false; + const moved = { ...player.piece, col: player.piece.col + dc }; + if (!pieceFits(player.board, moved)) return false; + player.piece = moved; + return true; +} + +export function moveLeft(match, pIdx) { return tryShift(match.players[pIdx], -1); } +export function moveRight(match, pIdx) { return tryShift(match.players[pIdx], 1); } + +const ROTATE_KICKS = [[0, 0], [0, -1], [0, 1], [-1, 0]]; + +function tryRotate(player, dir) { + if (!player.piece) return false; + const orient = (player.piece.orient + dir + 4) % 4; + for (const [dr, dc] of ROTATE_KICKS) { + const rotated = { ...player.piece, orient, row: player.piece.row + dr, col: player.piece.col + dc }; + if (pieceFits(player.board, rotated)) { player.piece = rotated; return true; } + } + return false; +} + +export function rotateCW(match, pIdx) { return tryRotate(match.players[pIdx], 1); } +export function rotateCCW(match, pIdx) { return tryRotate(match.players[pIdx], -1); } + +export function stepDown(match, pIdx) { + const player = match.players[pIdx]; + if (!player.piece || player.lost) return { locked: false, events: [] }; + const moved = { ...player.piece, row: player.piece.row + 1 }; + if (pieceFits(player.board, moved)) { + player.piece = moved; + return { locked: false, events: [] }; + } + return { locked: true, events: lockPiece(match, pIdx) }; +} + +export function hardDrop(match, pIdx) { + const player = match.players[pIdx]; + if (!player.piece || player.lost) return []; + let step = stepDown(match, pIdx); + while (!step.locked) step = stepDown(match, pIdx); + return step.events; +} + +export function getGhostCells(player) { + if (!player.piece) return []; + let probe = { ...player.piece }; + while (true) { + const next = { ...probe, row: probe.row + 1 }; + if (!pieceFits(player.board, next)) break; + probe = next; + } + return pieceCells(probe); +} + +// ── Lock + resolution pipeline ─────────────────────────────────────────────── +function restingRow(board, col) { + return topOfStack(board, col) - 1; // -1 means column full +} + +function lockPiece(match, pIdx) { + const player = match.players[pIdx]; + const opponent = match.players[1 - pIdx]; + const piece = player.piece; + player.piece = null; + const events = []; + const live = !match.headless; // headless sims (AI search) skip snapshot events + const push = (type, extra) => { if (live) events.push(evt(player, type, extra)); }; + player.lastResolve = { attack: 0, cleared: 0, chain: 0 }; + + // Place: vertical pairs stack in-column; horizontal halves settle independently. + const placedCells = []; + const place = (half, r, c) => { + if (r < 0) return; + player.board[r][c] = { ...half }; + placedCells.push({ r, c }); + }; + let diamondPos = null; + if (piece.orient === 0 || piece.orient === 2) { + const bottomHalf = piece.orient === 0 ? piece.a : piece.b; + const topHalf = piece.orient === 0 ? piece.b : piece.a; + const rBottom = restingRow(player.board, piece.col); + place(bottomHalf, rBottom, piece.col); + place(topHalf, rBottom - 1, piece.col); + if (bottomHalf.kind === KIND.DIAMOND) diamondPos = { r: rBottom, c: piece.col }; + if (topHalf.kind === KIND.DIAMOND) diamondPos = { r: rBottom - 1, c: piece.col }; + } else { + const cells = pieceCells(piece); + for (const { c, half } of cells) { + const r = restingRow(player.board, c); + place(piece[half], r, c); + if (piece[half].kind === KIND.DIAMOND) diamondPos = { r, c }; + } + } + push('place', { cells: placedCells }); + + let attack = 0; + + // Diamond: wipe every cell of the color directly beneath it. + if (diamondPos && player.board[diamondPos.r]?.[diamondPos.c]?.kind === KIND.DIAMOND) { + const below = player.board[diamondPos.r + 1]?.[diamondPos.c]; + const wiped = []; + if (below && below.color != null) { + for (let r = 0; r < HEIGHT; r++) { + for (let c = 0; c < WIDTH; c++) { + const cell = player.board[r][c]; + if (cell && cell.color === below.color) { + if (cell.powerId != null) player.powerGems.delete(cell.powerId); + player.board[r][c] = null; + wiped.push({ r, c, color: cell.color, kind: cell.kind }); + } + } + } + attack += Math.floor(wiped.length / DIAMOND_DAMAGE_DIV); + player.lastResolve.cleared += wiped.length; + } + player.board[diamondPos.r][diamondPos.c] = null; + push('diamond', { cells: wiped }); + } + + attack += runCascade(player, live ? events : null); + + // Counter tick: one decrement per lock; matured counters become plain gems + // and may themselves enable fusions/crashes, so re-resolve if any matured. + const matured = []; + for (let r = 0; r < HEIGHT; r++) { + for (let c = 0; c < WIDTH; c++) { + const cell = player.board[r][c]; + if (cell?.kind === KIND.COUNTER) { + cell.count -= 1; + if (cell.count <= 0) { + player.board[r][c] = { color: cell.color, kind: KIND.GEM }; + matured.push({ r, c, color: cell.color }); + } + } + } + } + if (matured.length) { + push('counter', { matured }); + attack += runCascade(player, live ? events : null); + } + + // Damage: offset our own pending garbage first, remainder goes to opponent. + player.lastResolve.attack = attack; + if (attack > 0) { + const offset = Math.min(attack, player.pendingGarbage); + player.pendingGarbage -= offset; + const sent = attack - offset; + opponent.pendingGarbage += sent; + player.garbageSent += sent; + push('attack', { amount: attack, offset, sent }); + } + return events; +} + +function runCascade(player, events) { + let attack = 0; + let chain = 0; + while (true) { + const fell = settle(player); + const fused = fuseAll(player); + if ((fell || fused) && events) events.push(evt(player, 'settle')); + const { cleared, powerCells } = crashClear(player); + if (!cleared.length) break; + chain += 1; + player.bestChain = Math.max(player.bestChain, chain); + player.lastResolve.cleared += cleared.length; + player.lastResolve.chain = Math.max(player.lastResolve.chain, chain); + const mult = CHAIN_MULT[Math.min(chain - 1, CHAIN_MULT.length - 1)]; + attack += Math.max(0, Math.ceil((cleared.length - ATTACK_DISCOUNT + powerCells * POWER_BONUS) * mult)); + if (events) events.push(evt(player, 'clear', { cells: cleared, chain })); + } + return attack; +} + +// ── Gravity (power gems fall rigidly) ──────────────────────────────────────── +function settle(player) { + const { board, powerGems } = player; + let movedAny = false; + let movedThisPass = true; + while (movedThisPass) { + movedThisPass = false; + for (let r = HEIGHT - 2; r >= 0; r--) { + for (let c = 0; c < WIDTH; c++) { + const cell = board[r][c]; + if (!cell || cell.powerId != null) continue; + if (!board[r + 1][c]) { + board[r + 1][c] = cell; + board[r][c] = null; + movedThisPass = true; + } + } + } + for (const gem of powerGems.values()) { + let canFall = gem.y + gem.h < HEIGHT; + for (let c = gem.x; canFall && c < gem.x + gem.w; c++) { + if (board[gem.y + gem.h][c]) canFall = false; + } + if (canFall) { + for (let c = gem.x; c < gem.x + gem.w; c++) { + board[gem.y + gem.h][c] = board[gem.y + gem.h - 1][c]; + for (let r = gem.y + gem.h - 1; r > gem.y; r--) board[r][c] = board[r - 1][c]; + board[gem.y][c] = null; + } + gem.y += 1; + movedThisPass = true; + } + } + if (movedThisPass) movedAny = true; + } + return movedAny; +} + +// ── Power-gem fusion ───────────────────────────────────────────────────────── +function isFusable(cell, color) { + return cell && cell.kind === KIND.GEM && cell.powerId == null && cell.color === color; +} + +function hasAnyTwoByTwo(board) { + for (let r = 0; r < HEIGHT - 1; r++) { + for (let c = 0; c < WIDTH - 1; c++) { + const cell = board[r][c]; + if (!cell || cell.kind !== KIND.GEM || cell.powerId != null) continue; + if (isFusable(board[r][c + 1], cell.color) && + isFusable(board[r + 1][c], cell.color) && + isFusable(board[r + 1][c + 1], cell.color)) return true; + } + } + return false; +} + +function findBestRect(board) { + let best = null; + for (let r = 0; r < HEIGHT - 1; r++) { + for (let c = 0; c < WIDTH - 1; c++) { + const cell = board[r][c]; + if (!cell || cell.kind !== KIND.GEM || cell.powerId != null) continue; + const color = cell.color; + let maxW = 0; + while (c + maxW < WIDTH && isFusable(board[r][c + maxW], color)) maxW++; + for (let w = 2; w <= maxW; w++) { + let h = 1; + outer: while (r + h < HEIGHT) { + for (let cc = c; cc < c + w; cc++) { + if (!isFusable(board[r + h][cc], color)) break outer; + } + h++; + } + if (h >= 2 && (!best || w * h > best.w * best.h)) best = { x: c, y: r, w, h, color }; + } + } + } + return best; +} + +function applyPowerId(board, gem) { + for (let r = gem.y; r < gem.y + gem.h; r++) { + for (let c = gem.x; c < gem.x + gem.w; c++) board[r][c].powerId = gem.id; + } +} + +function fuseAll(player) { + const { board, powerGems } = player; + let changed = false; + // New 2x2+ rectangles, largest-first. + if (hasAnyTwoByTwo(board)) { + let rect; + while ((rect = findBestRect(board))) { + const gem = { id: player.nextPowerId++, color: rect.color, x: rect.x, y: rect.y, w: rect.w, h: rect.h }; + powerGems.set(gem.id, gem); + applyPowerId(board, gem); + changed = true; + } + } + // Row/column extension and same-seam merging. + let again = true; + while (again) { + again = false; + for (const gem of powerGems.values()) { + for (const dy of [-1, gem.h]) { // row above / below + const r = gem.y + (dy === -1 ? -1 : gem.h); + if (r < 0 || r >= HEIGHT) continue; + let full = true; + for (let c = gem.x; c < gem.x + gem.w; c++) if (!isFusable(board[r][c], gem.color)) { full = false; break; } + if (full) { + if (dy === -1) gem.y -= 1; + gem.h += 1; + applyPowerId(board, gem); + again = true; changed = true; + } + } + for (const dx of [-1, gem.w]) { // column left / right + const c = gem.x + (dx === -1 ? -1 : gem.w); + if (c < 0 || c >= WIDTH) continue; + let full = true; + for (let r = gem.y; r < gem.y + gem.h; r++) if (!isFusable(board[r][c], gem.color)) { full = false; break; } + if (full) { + if (dx === -1) gem.x -= 1; + gem.w += 1; + applyPowerId(board, gem); + again = true; changed = true; + } + } + } + // Merge pairs sharing a full seam. + const gems = [...powerGems.values()]; + for (let i = 0; i < gems.length && !again; i++) { + for (let j = i + 1; j < gems.length && !again; j++) { + const a = gems[i], b = gems[j]; + if (a.color !== b.color) continue; + const vStack = a.x === b.x && a.w === b.w && (a.y + a.h === b.y || b.y + b.h === a.y); + const hStack = a.y === b.y && a.h === b.h && (a.x + a.w === b.x || b.x + b.w === a.x); + if (vStack || hStack) { + a.x = Math.min(a.x, b.x); a.y = Math.min(a.y, b.y); + if (vStack) a.h += b.h; else a.w += b.w; + powerGems.delete(b.id); + applyPowerId(board, a); + again = true; changed = true; + } + } + } + } + return changed; +} + +// ── Crash clearing ─────────────────────────────────────────────────────────── +const ORTH = [[-1, 0], [1, 0], [0, -1], [0, 1]]; + +function crashClear(player) { + const { board, powerGems } = player; + const toClear = new Set(); + const visited = new Set(); + + for (let r = 0; r < HEIGHT; r++) { + for (let c = 0; c < WIDTH; c++) { + const cell = board[r][c]; + if (!cell || cell.kind !== KIND.CRASH) continue; + const startKey = r * WIDTH + c; + if (visited.has(startKey)) continue; + const group = []; + const stack = [[r, c]]; + visited.add(startKey); + while (stack.length) { + const [gr, gc] = stack.pop(); + group.push([gr, gc]); + for (const [dr, dc] of ORTH) { + const nr = gr + dr, nc = gc + dc; + if (nr < 0 || nr >= HEIGHT || nc < 0 || nc >= WIDTH) continue; + const key = nr * WIDTH + nc; + if (visited.has(key)) continue; + const ncell = board[nr][nc]; + if (ncell && ncell.color === cell.color && (ncell.kind === KIND.GEM || ncell.kind === KIND.CRASH)) { + visited.add(key); + stack.push([nr, nc]); + } + } + } + if (group.length >= 2) group.forEach(([gr, gc]) => toClear.add(gr * WIDTH + gc)); + } + } + if (!toClear.size) return { cleared: [], powerCells: 0 }; + + // Counter gems adjacent to a cleared cell of their own color also clear. + for (const key of [...toClear]) { + const r = Math.floor(key / WIDTH), c = key % WIDTH; + const color = board[r][c].color; + for (const [dr, dc] of ORTH) { + const nr = r + dr, nc = c + dc; + if (nr < 0 || nr >= HEIGHT || nc < 0 || nc >= WIDTH) continue; + const ncell = board[nr][nc]; + if (ncell?.kind === KIND.COUNTER && ncell.color === color) toClear.add(nr * WIDTH + nc); + } + } + + const cleared = []; + let powerCells = 0; + for (const key of toClear) { + const r = Math.floor(key / WIDTH), c = key % WIDTH; + const cell = board[r][c]; + if (cell.powerId != null) { powerCells += 1; powerGems.delete(cell.powerId); } + cleared.push({ r, c, color: cell.color, kind: cell.kind }); + board[r][c] = null; + } + // Drop powerId from any cells whose gem was destroyed (partial overlap can't + // happen — a power gem is monochrome and connected, so it clears whole — but + // guard against stale ids anyway). + for (let r = 0; r < HEIGHT; r++) { + for (let c = 0; c < WIDTH; c++) { + const cell = board[r][c]; + if (cell?.powerId != null && !powerGems.has(cell.powerId)) delete cell.powerId; + } + } + return { cleared, powerCells }; +} + +// ── AI support ─────────────────────────────────────────────────────────────── +export function legalPlacements(player) { + const placements = []; + for (let orient = 0; orient < 4; orient++) { + const [, dc] = ORIENT_DELTA[orient]; + for (let col = 0; col < WIDTH; col++) { + const bCol = col + dc; + if (bCol < 0 || bCol >= WIDTH) continue; + placements.push({ col, orient }); + } + } + return placements; +} + +function clonePlayer(player) { + return { + ...player, + board: cloneBoard(player.board), + powerGems: new Map([...player.powerGems.values()].map((g) => [g.id, { ...g }])), + piece: player.piece ? { ...player.piece, a: { ...player.piece.a }, b: { ...player.piece.b } } : null, + dropPattern: player.dropPattern, + }; +} + +// Simulate hard-dropping the current piece at (col, orient). Returns metrics +// without mutating the real match, or null if the placement is impossible. +export function simulatePlacement(match, pIdx, col, orient) { + const real = match.players[pIdx]; + if (!real.piece) return null; + const player = clonePlayer(real); + const opponent = clonePlayer(match.players[1 - pIdx]); + const sim = { players: pIdx === 0 ? [player, opponent] : [opponent, player], over: false, winner: null, headless: true }; + + const target = { ...player.piece, row: 1, col, orient }; + if (!pieceFits(player.board, target)) return null; + player.piece = target; + hardDrop(sim, pIdx); + + const spawnBlocked = !cellFree(player.board, 1, SPAWN_COL) || !cellFree(player.board, 0, SPAWN_COL); + return { + player, + attack: player.lastResolve.attack, + cleared: player.lastResolve.cleared, + chain: player.lastResolve.chain, + died: spawnBlocked, + }; +} diff --git a/public/src/games/blockfighter/tutorial.md b/public/src/games/blockfighter/tutorial.md new file mode 100644 index 0000000..358d48e --- /dev/null +++ b/public/src/games/blockfighter/tutorial.md @@ -0,0 +1,51 @@ +# Block Fighter + +Go head-to-head against a rival in a falling-gem battle. Win by making your +opponent's board overflow — the fight is lost when a board's **spawn column** +(marked by the red arrow) fills to the top. + +## The Basics + +- Pairs of gems fall onto your board. Move them with **← →** (or A/D), rotate + with **↑ / X / Z**, drop faster with **↓** (or S), and slam them down with + **Space**. On a touchscreen, use the buttons along the bottom. +- Gems come in four colors. They stack where they land — horizontal pairs split + and each half falls to its own column. + +## Crash Gems + +The round, glowing orbs are **Crash Gems**. When a Crash Gem touches any gem of +its own color, it destroys the entire connected group of that color. This is +your only way to clear gems — plan your stacks so one Crash Gem wipes out a +big cluster. + +## Power Gems + +Build a solid rectangle of one color (2×2 or bigger) and it fuses into a giant +**Power Gem**. Power Gems count extra when destroyed, so building big before +you crash sends a much bigger attack. + +## Chains + +When gems are destroyed, everything above falls. If that drop brings another +Crash Gem into contact with its color, you get a **chain** — and every link in +the chain multiplies your attack. + +## Counter Gems + +Attacks land on the enemy board as **Counter Gems** — numbered blocks that +can't be crashed right away. Each number ticks down once per piece you drop; +at zero they become normal gems. You can also destroy a Counter Gem early by +clearing gems of its color right next to it. If you attack while garbage is +queued against you, your attack cancels it out first. + +## The Diamond + +Every 25th piece carries a **Diamond**. It destroys every gem of whatever +color it lands on — a great panic button, but it sends a weaker attack. + +## The Ladder + +Each level is a single battle against one fighter. Beat them to unlock the +next — opponents get smarter and faster as you climb. Lose, and you can retry +as many times as you like. diff --git a/public/src/main.js b/public/src/main.js index 6f0ea31..f1da53a 100644 --- a/public/src/main.js +++ b/public/src/main.js @@ -65,6 +65,7 @@ import RushHourGame from './games/rushhour/RushHourGame.js'; import HexsweeperGame from './games/hexsweeper/HexsweeperGame.js'; import PuddingMonstersGame from './games/puddingmonsters/PuddingMonstersGame.js'; import ShiftGame from './games/shift/ShiftGame.js'; +import BlockFighterGame from './games/blockfighter/BlockFighterGame.js'; const config = { type: Phaser.AUTO, @@ -143,6 +144,7 @@ const config = { HexsweeperGame, PuddingMonstersGame, ShiftGame, + BlockFighterGame, ], }; diff --git a/public/src/scenes/GameRoomScene.js b/public/src/scenes/GameRoomScene.js index a81541c..461b897 100644 --- a/public/src/scenes/GameRoomScene.js +++ b/public/src/scenes/GameRoomScene.js @@ -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' }; + 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' }; if (slugDispatch[this.game.slug]) { this.scene.start(slugDispatch[this.game.slug], { game: this.game, diff --git a/public/src/scenes/PreloadScene.js b/public/src/scenes/PreloadScene.js index 44d8210..d6ce2c1 100644 --- a/public/src/scenes/PreloadScene.js +++ b/public/src/scenes/PreloadScene.js @@ -62,6 +62,7 @@ export default class PreloadScene extends Phaser.Scene { this.load.json('rushhour', '/data/rushhour.json'); this.load.json('puddingmonsters', '/data/puddingmonsters.json'); this.load.json('shift-artwork', '/data/shift-artwork.json'); + this.load.json('blockfighter', '/data/blockfighter.json'); this.load.audio('sfx-water-splash', '/assets/fx/water-splash.mp3'); this.load.audio('sfx-water-sink', '/assets/fx/water-sink.mp3'); diff --git a/server/games/registry.js b/server/games/registry.js index 4083249..06dca4e 100644 --- a/server/games/registry.js +++ b/server/games/registry.js @@ -80,3 +80,4 @@ registerGame({ slug: 'rushhour', name: 'Rush Hour', category: registerGame({ slug: 'hexsweeper', name: 'Hexsweeper', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 52 }); registerGame({ slug: 'puddingmonsters', name: 'Jell-o Monsters', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, hasTutorial: true, iconFrame: 53 }); registerGame({ slug: 'shift', name: 'Shift', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 55 }); +registerGame({ slug: 'blockfighter', name: 'Block Fighter', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, hasTutorial: true, iconFrame: 56 }); diff --git a/server/scripts/verifyBlockFighter.js b/server/scripts/verifyBlockFighter.js new file mode 100644 index 0000000..1910a01 --- /dev/null +++ b/server/scripts/verifyBlockFighter.js @@ -0,0 +1,334 @@ +// Headless verification for Block Fighter. +// node server/scripts/verifyBlockFighter.js [--quick] +// Exits non-zero on any failure. +// +// 1. Fixture tests: exact engine behavior on hand-built boards. +// 2. AI-vs-AI self-play with invariant checks after every lock. +// 3. Skill differentiation matrix (higher skill should win more). +// 4. Level bank lint (public/data/blockfighter.json vs opponents.json). + +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +import { + WIDTH, HEIGHT, SPAWN_COL, KIND, COUNTER_START, SPEED_GRAVITY_MS, + createMatch, spawnPiece, stepDown, hardDrop, + moveLeft, moveRight, rotateCW, rotateCCW, +} from '../../public/src/games/blockfighter/BlockFighterLogic.js'; +import { createAI, planPlacement, nextAction } from '../../public/src/games/blockfighter/BlockFighterAI.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const QUICK = process.argv.includes('--quick'); + +let failures = 0; +function check(name, cond, detail = '') { + if (cond) { console.log(` ok ${name}`); } + else { failures += 1; console.error(`FAIL ${name}${detail ? ` — ${detail}` : ''}`); } +} + +// ── Fixture helpers ────────────────────────────────────────────────────────── +const gem = (color) => ({ color, kind: KIND.GEM }); +const crash = (color) => ({ color, kind: KIND.CRASH }); +const counter = (color, count = COUNTER_START) => ({ color, kind: KIND.COUNTER, count }); + +function freshMatch() { return createMatch({ seed: 42 }); } + +function put(player, r, c, cell) { player.board[r][c] = cell; } + +// Give the player a specific piece and hard-drop it. +function dropPiece(match, pIdx, a, b, col, orient = 0) { + const player = match.players[pIdx]; + player.piece = { a, b, row: 1, col, orient }; + return hardDrop(match, pIdx); +} + +function cellAt(player, r, c) { return player.board[r][c]; } +function countCells(player, pred) { + let n = 0; + for (let r = 0; r < HEIGHT; r++) for (let c = 0; c < WIDTH; c++) { + if (player.board[r][c] && pred(player.board[r][c])) n++; + } + return n; +} + +const BOTTOM = HEIGHT - 1; + +// ── 1. Fixtures ────────────────────────────────────────────────────────────── +console.log('Fixtures:'); +{ + // Crash gem clears its connected same-color group. + const m = freshMatch(); + const p = m.players[0]; + put(p, BOTTOM, 0, gem(0)); + put(p, BOTTOM - 1, 0, gem(0)); + dropPiece(m, 0, crash(0), gem(1), 0); + check('crash clears connected group', countCells(p, (c) => c.color === 0) === 0); + check('non-matching half survives', countCells(p, (c) => c.color === 1) === 1); + check('cleared cells settle', cellAt(p, BOTTOM, 0)?.color === 1); +} +{ + // Lone crash gem stays put. + const m = freshMatch(); + const p = m.players[0]; + dropPiece(m, 0, crash(0), gem(1), 0); + check('lone crash gem stays', countCells(p, (c) => c.kind === KIND.CRASH) === 1); +} +{ + // 2x2 fusion into a power gem; then column extension to 2x3. + const m = freshMatch(); + const p = m.players[0]; + dropPiece(m, 0, gem(2), gem(2), 0); + dropPiece(m, 0, gem(2), gem(2), 1); + check('2x2 fuses into power gem', p.powerGems.size === 1); + const g0 = [...p.powerGems.values()][0]; + check('power gem is 2x2', g0 && g0.w === 2 && g0.h === 2); + dropPiece(m, 0, gem(2), gem(2), 2); + const g1 = [...p.powerGems.values()][0]; + check('power gem extends to 3 wide', p.powerGems.size === 1 && g1.w === 3 && g1.h === 2, + JSON.stringify([...p.powerGems.values()])); +} +{ + // Rigid power-gem gravity: gem bridges a hole and falls as a unit. + const m = freshMatch(); + const p = m.players[0]; + // pillar in col 0 only; power gem sits on rows 10-11 across cols 0-1, hole below col 1 + put(p, BOTTOM, 0, gem(3)); + put(p, BOTTOM - 1, 0, gem(3)); + const pg = { id: 99, color: 1, x: 0, y: BOTTOM - 3, w: 2, h: 2 }; + p.powerGems.set(pg.id, pg); + for (let r = pg.y; r < pg.y + pg.h; r++) for (let c = pg.x; c < pg.x + pg.w; c++) { + put(p, r, c, { color: 1, kind: KIND.GEM, powerId: 99 }); + } + // drop something far away to trigger a resolution pass + dropPiece(m, 0, gem(0), gem(2), 5); + const g = p.powerGems.get(99); + check('power gem rests on support, bridging the hole', g && g.y === BOTTOM - 3 && !cellAt(p, BOTTOM, 1), + JSON.stringify(g)); + // remove the pillar support and resolve again: gem should drop as a unit + put(p, BOTTOM, 0, null); + put(p, BOTTOM - 1, 0, null); + dropPiece(m, 0, gem(0), gem(2), 5); + const g2 = p.powerGems.get(99); + check('power gem falls rigidly when support clears', g2 && g2.y === BOTTOM - 1, + JSON.stringify(g2)); +} +{ + // Counter gems: tick per lock, mature into normal gems at 0. + const m = freshMatch(); + const p = m.players[0]; + put(p, BOTTOM, 0, counter(2, 2)); + dropPiece(m, 0, gem(0), gem(1), 5); + check('counter ticks down on lock', cellAt(p, BOTTOM, 0)?.count === 1); + dropPiece(m, 0, gem(0), gem(1), 4); + check('counter matures into gem', cellAt(p, BOTTOM, 0)?.kind === KIND.GEM); +} +{ + // Counter destroyed when an adjacent same-color group clears. + const m = freshMatch(); + const p = m.players[0]; + put(p, BOTTOM, 0, gem(0)); + put(p, BOTTOM, 1, counter(0)); + dropPiece(m, 0, crash(0), gem(1), 0); + check('adjacent same-color counter cleared', countCells(p, (c) => c.kind === KIND.COUNTER) === 0); +} +{ + // Diamond wipes the color beneath it; vanishes on bare floor. + const m = freshMatch(); + const p = m.players[0]; + put(p, BOTTOM, 0, gem(3)); + put(p, BOTTOM, 3, gem(3)); + put(p, BOTTOM, 5, counter(3)); + put(p, BOTTOM, 1, gem(2)); + dropPiece(m, 0, { color: null, kind: KIND.DIAMOND }, gem(2), 0, 0); // diamond (bottom half) lands on yellow + check('diamond wipes all of that color (incl. counters)', countCells(p, (c) => c.color === 3) === 0); + check('other colors survive diamond', countCells(p, (c) => c.color === 2) === 2); + check('diamond itself is gone', countCells(p, (c) => c.kind === KIND.DIAMOND) === 0); + const m2 = freshMatch(); + dropPiece(m2, 0, { color: null, kind: KIND.DIAMOND }, gem(2), 0, 2); + check('diamond on bare floor vanishes', countCells(m2.players[0], (c) => c.kind === KIND.DIAMOND) === 0); +} +{ + // Attack is offset by own pending garbage before reaching the opponent. + const m = freshMatch(); + const p = m.players[0]; + p.pendingGarbage = 100; + put(p, BOTTOM, 0, gem(0)); + put(p, BOTTOM - 1, 0, gem(0)); + put(p, BOTTOM, 1, gem(0)); + put(p, BOTTOM - 1, 1, gem(0)); + dropPiece(m, 0, crash(0), gem(1), 2, 3); // crash next to the 2x2... orient 3: b left + check('clear happened for offset test', p.lastResolve.cleared >= 5, `cleared=${p.lastResolve.cleared}`); + check('attack offsets own pending garbage', p.pendingGarbage < 100 && m.players[1].pendingGarbage === 0, + `pending=${p.pendingGarbage}, opp=${m.players[1].pendingGarbage}`); +} +{ + // Unoffset attack lands on the opponent; garbage drops as counters. + const m = freshMatch(); + const p = m.players[0]; + for (let r = 0; r < 4; r++) for (let c = 0; c < 2; c++) put(p, BOTTOM - r, c, gem(1)); + dropPiece(m, 0, crash(1), gem(0), 2, 3); + const sent = m.players[1].pendingGarbage; + check('attack reaches opponent', sent > 0, `sent=${sent}`); + spawnPiece(m, 1); + const counters = countCells(m.players[1], (c) => c.kind === KIND.COUNTER); + check('garbage drops as counter gems on spawn', counters === Math.min(sent, 24), + `counters=${counters} sent=${sent}`); + check('counter colors follow the drop pattern', + m.players[1].board.flat().filter(Boolean).every((c) => c.kind !== KIND.COUNTER || c.color != null)); +} +{ + // Chain: red crash dropped in col 1 clears the reds; the green crash above + // them falls beside the green gem and triggers a second clear. + const m = freshMatch(); + const p = m.players[0]; + put(p, BOTTOM, 0, gem(0)); + put(p, BOTTOM - 1, 0, gem(0)); + put(p, BOTTOM - 2, 0, crash(1)); + put(p, BOTTOM, 1, gem(1)); + dropPiece(m, 0, crash(0), gem(2), 1); // lands beside the red pair + check('chain of 2 detected', p.lastResolve.chain === 2, `chain=${p.lastResolve.chain}`); + check('chain cleared everything green', countCells(p, (c) => c.color === 1) === 0); +} +{ + // Spawn-blocked loss in the spawn column. + const m = freshMatch(); + const p = m.players[0]; + for (let r = 0; r < HEIGHT; r++) put(p, r, SPAWN_COL, gem(r % 4)); + spawnPiece(m, 0); + check('blocked spawn loses the match', p.lost && m.over && m.winner === 1); +} + +// ── 2 & 3. Self-play with invariants + skill matrix ───────────────────────── +function checkInvariants(match, tag) { + for (const p of match.players) { + for (let r = 0; r < HEIGHT - 1; r++) { + for (let c = 0; c < WIDTH; c++) { + const cell = p.board[r][c]; + if (cell && !p.board[r + 1][c] && cell.powerId == null) { + throw new Error(`${tag}: floating cell at ${r},${c}`); + } + } + } + for (const g of p.powerGems.values()) { + if (g.w < 2 || g.h < 2) throw new Error(`${tag}: degenerate power gem ${JSON.stringify(g)}`); + for (let r = g.y; r < g.y + g.h; r++) for (let c = g.x; c < g.x + g.w; c++) { + const cell = p.board[r]?.[c]; + if (!cell || cell.powerId !== g.id || cell.color !== g.color) { + throw new Error(`${tag}: power gem cell mismatch at ${r},${c}: ${JSON.stringify(g)}`); + } + } + } + for (let r = 0; r < HEIGHT; r++) for (let c = 0; c < WIDTH; c++) { + const cell = p.board[r][c]; + if (cell?.powerId != null && !p.powerGems.has(cell.powerId)) { + throw new Error(`${tag}: orphaned powerId at ${r},${c}`); + } + } + if (p.pendingGarbage < 0) throw new Error(`${tag}: negative pendingGarbage`); + } +} + +const TICK_MS = 25; +function playMatch(skillA, skillB, seed, speed = 3, pieceCap = 1200) { + const match = createMatch({ seed }); + const ais = [ + createAI({ skill: skillA, speed, seed: seed * 2 + 1 }), + createAI({ skill: skillB, speed, seed: seed * 3 + 7 }), + ]; + const gravityMs = SPEED_GRAVITY_MS[speed]; + const gravTimer = [0, 0]; + let now = 0; + let pieces = 0; + for (const i of [0, 1]) { + spawnPiece(match, i); + if (match.players[i].piece) planPlacement(ais[i], match, i); + } + while (!match.over && pieces < pieceCap) { + now += TICK_MS; + for (const i of [0, 1]) { + if (match.over) break; + const p = match.players[i]; + if (!p.piece) continue; + let locked = false; + const act = nextAction(ais[i], match, i, now); + if (act === 'left') moveLeft(match, i); + else if (act === 'right') moveRight(match, i); + else if (act === 'rotateCW') rotateCW(match, i); + else if (act === 'rotateCCW') rotateCCW(match, i); + else if (act === 'softDrop') locked = stepDown(match, i).locked; + else if (act === 'hardDrop') { hardDrop(match, i); locked = true; } + gravTimer[i] += TICK_MS; + if (!locked && p.piece && gravTimer[i] >= gravityMs) { + gravTimer[i] = 0; + locked = stepDown(match, i).locked; + } + if (locked) { + pieces += 1; + checkInvariants(match, `match(seed=${seed},${skillA}v${skillB})`); + if (!match.over) { + spawnPiece(match, i); + if (match.players[i].piece) planPlacement(ais[i], match, i); + } + } + } + } + return { winner: match.over ? match.winner : null, pieces }; +} + +console.log('\nSelf-play invariants:'); +{ + const games = QUICK ? 10 : 40; + let decided = 0, totalPieces = 0; + for (let s = 1; s <= games; s++) { + const { winner, pieces } = playMatch(5, 5, s * 101); + if (winner !== null) decided += 1; + totalPieces += pieces; + } + check(`self-play runs clean (${games} games)`, true); + check('most games reach a decision', decided >= games * 0.8, `${decided}/${games}`); + console.log(` avg pieces/game: ${(totalPieces / games).toFixed(1)}`); +} + +console.log('\nSkill differentiation:'); +{ + const games = QUICK ? 8 : 24; + const pairs = [[2, 8], [3, 6], [5, 6], [1, 10]]; + for (const [lo, hi] of pairs) { + let hiWins = 0, decided = 0; + for (let s = 1; s <= games; s++) { + // alternate sides to cancel any side bias + const flip = s % 2 === 1; + const { winner } = playMatch(flip ? lo : hi, flip ? hi : lo, s * 977 + lo * 13 + hi); + if (winner === null) continue; + decided += 1; + if ((flip && winner === 1) || (!flip && winner === 0)) hiWins += 1; + } + const rate = decided ? hiWins / decided : 0; + const need = hi - lo >= 5 ? 0.7 : 0.5; + check(`skill ${hi} beats ${lo} (${(rate * 100).toFixed(0)}% of ${decided})`, rate >= need); + } +} + +// ── 4. Level bank lint ─────────────────────────────────────────────────────── +console.log('\nLevel bank:'); +{ + const bank = JSON.parse(readFileSync(join(__dirname, '../../public/data/blockfighter.json'), 'utf8')); + const roster = JSON.parse(readFileSync(join(__dirname, '../../public/data/opponents.json'), 'utf8')); + const ids = new Set((roster.opponents ?? roster).map((o) => o.id)); + const levels = bank.levels ?? []; + check('bank has levels', levels.length > 0); + let ok = true; + levels.forEach((lv, i) => { + if (lv.level !== i + 1) ok = false; + if (!(lv.skill >= 1 && lv.skill <= 10)) ok = false; + if (!(lv.speed >= 1 && lv.speed <= 5)) ok = false; + if (lv.dropPattern && !(Array.isArray(lv.dropPattern) && lv.dropPattern.length === 4 + && lv.dropPattern.every((row) => /^[RGBY]{6}$/.test(row)))) ok = false; + if (!ids.has(lv.opponentId)) console.warn(` warn: level ${lv.level} opponent '${lv.opponentId}' not in roster`); + }); + check('levels contiguous with valid skill/speed/dropPattern', ok); +} + +console.log(failures ? `\n${failures} FAILURE(S)` : '\nAll checks passed.'); +process.exit(failures ? 1 : 0);