diff --git a/README.md b/README.md index f56057a..41dfd47 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,7 @@ server-side, so hosting is entirely stateless. ## The game catalog -77 games are registered in `src/data/gamesRegistry.js` and grouped into five +82 games are registered in `src/data/gamesRegistry.js` and grouped into six menu categories: | Category | Count | Examples | @@ -90,6 +90,7 @@ menu categories: | **Casino** | 9 | Blackjack, Texas Hold 'Em, Baccarat, Pai Gow Poker, Video Poker, Craps, Roulette, Bingo, Slot Machines | | **Word** | 15 | Wordle Race, Scrabble, Boggle, Ghost, Word Ladder, Word Search, Hangman, Spelling Bee, Sudoku, Mini Crossword, Tectonic, Bookwork, Kiitos, Tri-Ominoes, Jumble | | **Logic & Puzzle** | 14 | 2048, Rush Hour, Hexsweeper, Jell-o Monsters, Shift, Mahjong Match, Jewel Quest, Zuma, Bejeweled Blitz, Mini Motorways, Dot Link, Katamino, Genius Square, Block Fighter | +| **Arcade, Console & PC** | 1 | Colorado Defense | Each game is a self-contained `Phaser.Scene` (plus its own logic/AI/data helper modules) under `src/games//`. Games are single-player against 0–7 @@ -223,7 +224,7 @@ drop-in spritesheets on top, so art can be added without refactoring scenes. registerGame({ slug: 'cribbage', name: 'Cribbage', - category: 'cards', // tabletop | cards | casino | word | logic + category: 'cards', // tabletop | cards | casino | word | logic | arcade-console-pc cardGame: true, // uses the shared card-back picker minPlayers: 2, maxPlayers: 2, minOpponents: 1, maxOpponents: 1, diff --git a/assets/images/game-icons.png b/assets/images/game-icons.png index db0f51e..3aae5dd 100644 Binary files a/assets/images/game-icons.png and b/assets/images/game-icons.png differ diff --git a/assets/images/game-icons.psd b/assets/images/game-icons.psd index ee059c1..c3d6d7b 100644 Binary files a/assets/images/game-icons.psd and b/assets/images/game-icons.psd differ diff --git a/assets/images/tab-icons.png b/assets/images/tab-icons.png index 3c8904f..1d3ba1f 100644 Binary files a/assets/images/tab-icons.png and b/assets/images/tab-icons.png differ diff --git a/assets/images/tab-icons.psd b/assets/images/tab-icons.psd index 2bcf848..590ec29 100644 Binary files a/assets/images/tab-icons.psd and b/assets/images/tab-icons.psd differ diff --git a/data/colorado-defense-cities.json b/data/colorado-defense-cities.json new file mode 100644 index 0000000..16ae054 --- /dev/null +++ b/data/colorado-defense-cities.json @@ -0,0 +1,14 @@ +{ + "cities": [ + "Denver", + "Pueblo", + "Colorado Springs", + "Arvada", + "Ft. Lupton", + "Ft. Collins", + "Limon", + "Glenwood Springs", + "Alamosa", + "Salida" + ] +} diff --git a/src/data/gamesRegistry.js b/src/data/gamesRegistry.js index 7b41d49..12a3392 100644 --- a/src/data/gamesRegistry.js +++ b/src/data/gamesRegistry.js @@ -108,3 +108,4 @@ registerGame({ slug: 'dungeonboss', name: 'Dungeon Boss', category: 'cards', car registerGame({ slug: 'swdbg', name: 'Star Wars', category: 'cards', cardGame: true, minPlayers: 2, maxPlayers: 2, minOpponents: 1, maxOpponents: 1, hasTutorial: true, iconFrame: 78, defaultPlayfield: 'stars' }); registerGame({ slug: 'balatro', name: 'Balatro', category: 'cards', cardGame: true, minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, hasTutorial: true, iconFrame: 79 }); registerGame({ slug: 'peggle', name: 'Peggle', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 80 }); +registerGame({ slug: 'coloradodefense', name: 'Colorado Defense', category: 'arcade-console-pc', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 81 }); diff --git a/src/games/coloradodefense/ColoradoDefenseGame.js b/src/games/coloradodefense/ColoradoDefenseGame.js new file mode 100644 index 0000000..ef7f51c --- /dev/null +++ b/src/games/coloradodefense/ColoradoDefenseGame.js @@ -0,0 +1,288 @@ +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 { applyArcadeCRTOverlay } from '../../ui/ArcadeCRTOverlay.js'; +import { + DEFAULT_CITIES, GROUND_Y, PALETTES, createGame, step, pickFiringBase, + fireInterceptor, lerpPos, explosionRadius, +} from './ColoradoDefenseLogic.js'; + +const D = { bg: -2, ground: 0, fx: 2, ui: 30, overlay: 60 }; +const BEST_KEY = 'coloradodefense-best'; + +export default class ColoradoDefenseGame extends Phaser.Scene { + constructor() { super('ColoradoDefenseGame'); } + + init(data) { + this.gameDef = data.game ?? { slug: 'coloradodefense', name: 'Colorado Defense' }; + this.overlayUp = false; + this.state = null; + this.C = PALETTES[0]; + } + + create() { + try { + const music = this.cache.json.get('music'); + if (music?.tracks) this.music = new MusicPlayer(this, music.tracks); + } catch (_) { /* optional */ } + this.input.mouse?.disableContextMenu(); + + this.bgRect = this.add + .rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, this.C.bgTop) + .setDepth(D.bg); + this.groundG = this.add.graphics().setDepth(D.ground); + this.fxG = this.add.graphics().setDepth(D.fx); + this.reticleG = this.add.graphics().setDepth(D.fx + 1); + + const pool = this.cache.json.get('colorado-defense-cities')?.cities ?? DEFAULT_CITIES; + this.state = createGame(pool, Date.now()); + + this.cityLabels = new Map(); + for (const city of this.state.cities) { + const label = this.add.text(city.x, city.y + 26, city.name, { + fontFamily: 'm6x11, "Julius Sans One"', fontSize: '16px', color: COLORS.textHex, + }).setOrigin(0.5, 0).setDepth(D.ui); + this.cityLabels.set(city.slot, label); + } + + this.crt = applyArcadeCRTOverlay(this, { accentTint: this.C.accent, scanlineTint: this.C.trail }); + this.events.once('shutdown', () => this.crt.destroy()); + + this.drawGround(); + this.buildHud(); + this.bindInput(); + } + + // ── Input ───────────────────────────────────────────────────────────────── + bindInput() { + this.input.on('pointermove', (p) => { + this.reticleG.clear(); + if (this.overlayUp || !this.state || this.state.status !== 'playing') return; + this.reticleG.lineStyle(2, this.C.accent, 0.7); + this.reticleG.strokeCircle(p.x, p.y, 14); + this.reticleG.lineBetween(p.x - 20, p.y, p.x + 20, p.y); + this.reticleG.lineBetween(p.x, p.y - 20, p.x, p.y + 20); + }); + this.input.on('pointerdown', (p) => { + if (this.overlayUp || !this.state || this.state.status !== 'playing') return; + const x = Phaser.Math.Clamp(p.x, 0, GAME_WIDTH); + const y = Phaser.Math.Clamp(p.y, 0, GROUND_Y); + const base = pickFiringBase(this.state, x, y); + if (!base) return; + fireInterceptor(this.state, base, x, y); + playSound(this, SFX.SCIFI_LAUNCH); + }); + } + + // ── Ground (silos + cities) ────────────────────────────────────────────────── + drawGround() { + const g = this.groundG; + g.clear(); + const C = this.C; + g.fillStyle(C.ground, 1); + g.fillRect(0, GROUND_Y, GAME_WIDTH, GAME_HEIGHT - GROUND_Y); + g.lineStyle(4, C.accent, 0.6); + g.lineBetween(0, GROUND_Y, GAME_WIDTH, GROUND_Y); + + for (const base of this.state.bases) this.drawSilo(g, base); + for (const city of this.state.cities) { + this.drawCity(g, city); + const label = this.cityLabels.get(city.slot); + if (label) { + label.setAlpha(city.alive ? 1 : 0.35); + label.setColor(city.alive ? COLORS.textHex : COLORS.mutedHex); + } + } + } + + drawSilo(g, base) { + const { x, y } = base; + if (base.alive) { + g.fillStyle(this.C.accent, 1); + g.fillTriangle(x - 34, y, x + 34, y, x, y - 60); + g.fillStyle(0x0c0f16, 1); + g.fillRect(x - 10, y - 22, 20, 22); + for (let i = 0; i < base.ammo; i += 1) { + g.fillStyle(0xffffff, 0.85); + g.fillCircle(x - 24 + (i % 5) * 12, y - 74 - Math.floor(i / 5) * 12, 3); + } + } else { + g.fillStyle(0x2a2a2a, 1); + g.fillTriangle(x - 34, y, x + 34, y, x, y - 18); + g.lineStyle(3, 0x000000, 0.6); + g.lineBetween(x - 18, y - 22, x + 18, y - 2); + g.lineBetween(x + 18, y - 22, x - 18, y - 2); + } + } + + drawCity(g, city) { + const { x, y } = city; + if (city.alive) { + const blocks = [{ w: 20, h: 36 }, { w: 16, h: 56 }, { w: 24, h: 28 }, { w: 18, h: 46 }]; + let cx = x - 44; + for (const b of blocks) { + g.fillStyle(this.C.trail, 0.9); + g.fillRect(cx, y - b.h, b.w, b.h); + cx += b.w + 6; + } + } else { + g.fillStyle(0x1a1a1a, 1); + g.fillRect(x - 40, y - 8, 80, 8); + g.fillStyle(0x0a0a0a, 1); + g.fillCircle(x - 20, y - 4, 6); + g.fillCircle(x + 15, y - 3, 5); + } + } + + // ── HUD ─────────────────────────────────────────────────────────────────── + buildHud() { + const best = Number(localStorage.getItem(BEST_KEY) ?? 0); + this.scoreText = this.add.text(40, 30, 'Score: 0', { + fontFamily: 'm6x11, "Julius Sans One"', fontSize: '30px', color: COLORS.textHex, + }).setDepth(D.ui); + this.waveText = this.add.text(GAME_WIDTH / 2, 30, 'Wave 1', { + fontFamily: 'm6x11, "Julius Sans One"', fontSize: '30px', color: COLORS.goldHex, + }).setOrigin(0.5, 0).setDepth(D.ui); + this.bestText = this.add.text(GAME_WIDTH - 40, 30, `Best: ${best}`, { + fontFamily: 'm6x11, "Julius Sans One"', fontSize: '24px', color: COLORS.mutedHex, + }).setOrigin(1, 0).setDepth(D.ui); + this.citiesText = this.add.text(40, 70, 'Cities: 6/6', { + fontFamily: 'm6x11, "Julius Sans One"', fontSize: '20px', color: COLORS.mutedHex, + }).setDepth(D.ui); + } + + updateHud() { + this.scoreText.setText(`Score: ${this.state.score}`); + this.waveText.setText(`Wave ${this.state.wave}`); + this.citiesText.setText(`Cities: ${this.state.aliveCities().length}/6`); + } + + // ── Frame loop ──────────────────────────────────────────────────────────── + update(time, delta) { + if (!this.state || this.overlayUp || this.state.status !== 'playing') return; + const events = step(this.state, delta); + for (const e of events) this.handleEvent(e); + this.syncGraphics(); + } + + handleEvent(e) { + switch (e.type) { + case 'cityLost': + case 'baseLost': + playSound(this, SFX.SCIFI_EXPLODE); + this.crt.pulse(0.6, 220); + this.drawGround(); + break; + case 'missileDestroyed': + playSound(this, SFX.LASER_ZAP); + break; + case 'waveComplete': + this.C = PALETTES[e.paletteIdx]; + this.crt.setIntensity({ accentTint: this.C.accent, scanlineTint: this.C.trail }); + this.bgRect.setFillStyle(this.C.bgTop); + this.drawGround(); + playSound(this, SFX.SCIFI_REVEAL); + break; + case 'gameOver': + this.onGameOver(e); + break; + default: + break; + } + } + + syncGraphics() { + const g = this.fxG; + g.clear(); + for (const m of this.state.enemyMissiles) { + const pos = lerpPos(m); + g.lineStyle(3, this.C.explosion, 0.8); + g.lineBetween(m.fromX, m.fromY, pos.x, pos.y); + g.fillStyle(this.C.explosion, 1); + g.fillCircle(pos.x, pos.y, 5); + } + for (const p of this.state.interceptors) { + const pos = lerpPos(p); + g.lineStyle(3, this.C.accent, 0.9); + g.lineBetween(p.fromX, p.fromY, pos.x, pos.y); + g.fillStyle(0xffffff, 1); + g.fillCircle(pos.x, pos.y, 5); + } + for (const e of this.state.explosions) { + const r = explosionRadius(e); + if (r <= 0) continue; + const color = e.owner === 'player' ? this.C.explosion : 0xff4433; + g.fillStyle(color, 0.55); + g.fillCircle(e.x, e.y, r); + g.lineStyle(2, 0xffffff, 0.8); + g.strokeCircle(e.x, e.y, r); + } + this.updateHud(); + } + + // ── Game over ───────────────────────────────────────────────────────────── + onGameOver(e) { + this.overlayUp = true; + this.crt.pulse(1.0, 400); + playSound(this, SFX.SCIFI_EXPLODE); + + const prevBest = Number(localStorage.getItem(BEST_KEY) ?? 0); + const newBest = e.score > prevBest; + if (newBest) localStorage.setItem(BEST_KEY, String(e.score)); + + api.post('/history/single-player', { + slug: 'coloradodefense', score: e.score, opponentScores: [], result: 'loss', + }).catch(() => { /* best effort */ }); + + this.time.delayedCall(500, () => this.showGameOverPanel(e, prevBest, newBest)); + } + + showGameOverPanel(e, prevBest, newBest) { + const cx = GAME_WIDTH / 2; const cy = GAME_HEIGHT / 2; + const root = this.add.container(0, 0).setDepth(D.overlay); + + const dim = this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.65).setInteractive(); + root.add(dim); + + const panel = this.add.graphics(); + panel.fillStyle(COLORS.panel, 0.98); + panel.fillRoundedRect(cx - 380, cy - 260, 760, 520, 22); + panel.lineStyle(3, COLORS.danger, 1); + panel.strokeRoundedRect(cx - 380, cy - 260, 760, 520, 22); + root.add(panel); + + root.add(this.add.text(cx, cy - 192, 'ALL CITIES LOST', { + fontFamily: 'm6x11, "Julius Sans One"', fontSize: '52px', color: COLORS.dangerHex, + }).setOrigin(0.5)); + root.add(this.add.text(cx, cy - 130, `You held out through wave ${e.wave}.`, { + fontFamily: 'm6x11, "Julius Sans One"', fontSize: '22px', color: COLORS.mutedHex, + }).setOrigin(0.5)); + + const scoreText = this.add.text(cx, cy - 30, '0', { + fontFamily: 'm6x11, "Julius Sans One"', fontSize: '88px', color: COLORS.goldHex, + }).setOrigin(0.5); + root.add(scoreText); + const counter = { v: 0 }; + this.tweens.add({ + targets: counter, v: e.score, duration: 900, ease: 'Cubic.easeOut', + onUpdate: () => scoreText.setText(String(Math.round(counter.v))), + }); + root.add(this.add.text(cx, cy + 30, 'SCORE', { + fontFamily: 'm6x11, "Julius Sans One"', fontSize: '18px', color: COLORS.mutedHex, + }).setOrigin(0.5)); + root.add(this.add.text(cx, cy + 74, newBest ? '★ NEW BEST ★' : (prevBest > 0 ? `Best: ${prevBest}` : ''), { + fontFamily: 'm6x11, "Julius Sans One"', fontSize: '24px', color: newBest ? COLORS.goldHex : COLORS.mutedHex, + }).setOrigin(0.5)); + + const again = new Button(this, cx - 170, cy + 190, 'Play Again', + () => this.scene.restart({ game: this.gameDef }), + { width: 280, height: 62, fontSize: 26 }); + const menu = new Button(this, cx + 170, cy + 190, 'Menu', + () => this.scene.start('GameMenu'), + { width: 280, height: 62, fontSize: 26, variant: 'ghost' }); + root.add([again, menu]); + } +} diff --git a/src/games/coloradodefense/ColoradoDefenseLogic.js b/src/games/coloradodefense/ColoradoDefenseLogic.js new file mode 100644 index 0000000..b1e3866 --- /dev/null +++ b/src/games/coloradodefense/ColoradoDefenseLogic.js @@ -0,0 +1,325 @@ +// Pure simulation for Colorado Defense (Missile Command clone). No Phaser +// dependency — fully unit-testable headlessly via tools/verifyColoradoDefense.js. + +export const WIDTH = 1920; +export const HEIGHT = 1080; + +export const DEFAULT_CITIES = [ + 'Denver', 'Pueblo', 'Colorado Springs', 'Arvada', 'Ft. Lupton', + 'Ft. Collins', 'Limon', 'Glenwood Springs', 'Alamosa', 'Salida', +]; + +// Authentic classic-arcade ground layout: 3 bases + 6 cities across 9 slots, +// symmetric: Base, City, City, City, Base, City, City, City, Base. +export const GROUND_SLOTS = 9; +export const BASE_SLOTS = [0, 4, 8]; +export const CITY_SLOTS = [1, 2, 3, 5, 6, 7]; +export const GROUND_Y = HEIGHT - 90; +const SLOT_MARGIN = 120; + +export function slotX(slot) { + return SLOT_MARGIN + slot * ((WIDTH - 2 * SLOT_MARGIN) / (GROUND_SLOTS - 1)); +} + +export 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; + }; +} + +function fisherYates(arr, rng) { + for (let i = arr.length - 1; i > 0; i -= 1) { + const j = Math.floor(rng() * (i + 1)); + [arr[i], arr[j]] = [arr[j], arr[i]]; + } + return arr; +} + +// Draws 6 town names from the pool (with replacement-by-cycling if the pool +// has fewer than 6 entries, so a hand-edited JSON can never crash the game). +export function pickCities(pool, rng) { + const names = Array.isArray(pool) && pool.length ? pool.slice() : DEFAULT_CITIES.slice(); + const shuffled = fisherYates(names, rng); + const picked = []; + for (let i = 0; picked.length < CITY_SLOTS.length; i += 1) { + picked.push(shuffled[i % shuffled.length]); + } + return picked; +} + +export const TUNE = { + SPAWN_MS_BASE: 1800, SPAWN_MS_DECAY: 0.92, SPAWN_MS_MIN: 350, + FALL_SPEED_BASE: 90, FALL_SPEED_GROWTH: 1.06, FALL_SPEED_MAX: 340, + WAVE_QUOTA_BASE: 8, WAVE_QUOTA_GROWTH: 1.15, WAVE_QUOTA_MAX: 40, + MIRV_WAVE: 6, + MIRV_CHANCE_BASE: 0.15, MIRV_CHANCE_GROWTH: 0.02, MIRV_CHANCE_MAX: 0.5, + MIRV_CHILDREN: [2, 3], + BASE_AMMO: 10, + BLAST_R_BASE: 90, BLAST_GROW_MS: 350, BLAST_HOLD_MS: 120, BLAST_FADE_MS: 200, + INTERCEPTOR_SPEED: 900, + SCORE_PER_KILL: 25, + SCORE_WAVE_BONUS: 100, +}; + +export function spawnInterval(wave) { + return Math.max(TUNE.SPAWN_MS_MIN, TUNE.SPAWN_MS_BASE * TUNE.SPAWN_MS_DECAY ** (wave - 1)); +} +export function fallSpeed(wave) { + return Math.min(TUNE.FALL_SPEED_MAX, TUNE.FALL_SPEED_BASE * TUNE.FALL_SPEED_GROWTH ** (wave - 1)); +} +export function waveQuota(wave) { + return Math.min(TUNE.WAVE_QUOTA_MAX, Math.round(TUNE.WAVE_QUOTA_BASE * TUNE.WAVE_QUOTA_GROWTH ** (wave - 1))); +} +export function mirvChance(wave) { + if (wave < TUNE.MIRV_WAVE) return 0; + return Math.min(TUNE.MIRV_CHANCE_MAX, TUNE.MIRV_CHANCE_BASE + TUNE.MIRV_CHANCE_GROWTH * (wave - TUNE.MIRV_WAVE)); +} + +// Automatic, level-driven palette progression (distinct from user-facing +// pickers like 2048's) — advances every PALETTE_WAVES waves, clamped at the end. +export const PALETTES = [ + { bgTop: 0x0a0e1a, bgBottom: 0x1a1030, trail: 0x66ffea, explosion: 0xffdd55, accent: 0x00f0ff, ground: 0x141a2e }, + { bgTop: 0x1a0a0e, bgBottom: 0x300a1a, trail: 0xff6bcb, explosion: 0xffaa33, accent: 0xff2bd6, ground: 0x2a0f1a }, + { bgTop: 0x0a1a0e, bgBottom: 0x0a301a, trail: 0x8dff6b, explosion: 0xffee44, accent: 0x45d17a, ground: 0x0f2a18 }, + { bgTop: 0x1a140a, bgBottom: 0x301c0a, trail: 0xffb366, explosion: 0xff5555, accent: 0xff8a3c, ground: 0x2a1c0f }, + { bgTop: 0x120a1a, bgBottom: 0x260a30, trail: 0xc78dff, explosion: 0xff66c4, accent: 0xb84bff, ground: 0x1e0f2a }, +]; +export const PALETTE_WAVES = 4; +export function paletteIndexForWave(wave) { + return Math.min(PALETTES.length - 1, Math.floor((wave - 1) / PALETTE_WAVES)); +} + +function lerp(a, b, t) { return a + (b - a) * t; } + +// Current x/y for any in-flight entity with fromX/fromY/toX/toY/t fields. +export function lerpPos(entity) { + const t = Math.min(1, entity.t); + return { x: lerp(entity.fromX, entity.toX, t), y: lerp(entity.fromY, entity.toY, t) }; +} + +export function explosionRadius(e) { + const { BLAST_GROW_MS: G, BLAST_HOLD_MS: H, BLAST_FADE_MS: F } = TUNE; + const t = e.t; + if (t < G) return e.maxR * (t / G); + if (t < G + H) return e.maxR; + if (t < G + H + F) return e.maxR * Math.max(0, 1 - (t - G - H) / F); + return 0; +} + +export class Sim { + constructor(cityPool, seed) { + this.rng = mulberry32(seed >>> 0); + this.wave = 1; + this.score = 0; + this.status = 'playing'; // 'playing' | 'gameover' + this.paletteIdx = 0; + this.nextId = 1; + this.events = []; + + const names = pickCities(cityPool, this.rng); + this.bases = BASE_SLOTS.map((slot) => ( + { slot, x: slotX(slot), y: GROUND_Y, alive: true, ammo: TUNE.BASE_AMMO } + )); + this.cities = CITY_SLOTS.map((slot, i) => ( + { slot, x: slotX(slot), y: GROUND_Y, name: names[i], alive: true } + )); + + this.enemyMissiles = []; + this.interceptors = []; + this.explosions = []; + + this.waveT = 0; + this.waveSpawned = 0; + this.waveQuota = waveQuota(this.wave); + this.spawnT = 0; + } + + emit(type, data = {}) { this.events.push({ type, ...data }); } + + aliveCities() { return this.cities.filter((c) => c.alive); } + aliveBases() { return this.bases.filter((b) => b.alive); } + + // Picks a currently-alive ground slot to target, weighted 2x toward cities. + pickTargetSlot() { + const weighted = []; + for (const c of this.aliveCities()) weighted.push({ kind: 'city', x: c.x, y: c.y, slot: c.slot }, { kind: 'city', x: c.x, y: c.y, slot: c.slot }); + for (const b of this.aliveBases()) weighted.push({ kind: 'base', x: b.x, y: b.y, slot: b.slot }); + if (!weighted.length) return null; + return weighted[Math.floor(this.rng() * weighted.length)]; + } + + spawnEnemyMissile(fromOverride) { + const target = this.pickTargetSlot(); + if (!target) return null; + const fromX = fromOverride ? fromOverride.x : 80 + this.rng() * (WIDTH - 160); + const fromY = fromOverride ? fromOverride.y : 0; + const dist = Math.hypot(target.x - fromX, target.y - fromY); + const speed = fallSpeed(this.wave); + const dur = Math.max(200, (dist / speed) * 1000); + const kind = fromOverride ? 'single' : (this.rng() < mirvChance(this.wave) ? 'mirv' : 'single'); + const missile = { + id: this.nextId++, fromX, fromY, toX: target.x, toY: target.y, + targetSlot: target.slot, targetKind: target.kind, + t: 0, dur, kind, splitAt: 0.4 + this.rng() * 0.25, splitDone: false, + }; + this.enemyMissiles.push(missile); + return missile; + } + + makeMirvChildren(parent) { + const options = TUNE.MIRV_CHILDREN; + const n = options[Math.floor(this.rng() * options.length)]; + const cur = lerpPos(parent); + const children = []; + for (let i = 0; i < n; i += 1) { + const m = this.spawnEnemyMissile(cur); + if (m) children.push(m); + } + return children; + } + + resolveImpact(m) { + if (m.targetKind === 'city') { + const city = this.cities.find((c) => c.slot === m.targetSlot); + if (city && city.alive) { + city.alive = false; + this.emit('cityLost', { slot: city.slot, name: city.name }); + } + } else { + const base = this.bases.find((b) => b.slot === m.targetSlot); + if (base && base.alive) { + base.alive = false; + this.emit('baseLost', { slot: base.slot }); + } + } + this.spawnExplosion(m.toX, m.toY, 'enemy'); + } + + spawnExplosion(x, y, owner) { + const totalMs = TUNE.BLAST_GROW_MS + TUNE.BLAST_HOLD_MS + TUNE.BLAST_FADE_MS; + this.explosions.push({ id: this.nextId++, x, y, t: 0, totalMs, maxR: TUNE.BLAST_R_BASE, owner }); + this.emit('explosion', { x, y, owner }); + } + + completeWave() { + this.wave += 1; + for (const b of this.bases) if (b.alive) b.ammo = TUNE.BASE_AMMO; + this.waveQuota = waveQuota(this.wave); + this.waveSpawned = 0; + this.spawnT = 0; + this.waveT = 0; + this.score += TUNE.SCORE_WAVE_BONUS; + this.paletteIdx = paletteIndexForWave(this.wave); + this.emit('waveComplete', { wave: this.wave, paletteIdx: this.paletteIdx }); + } + + step(dtMs) { + this.events = []; + if (this.status !== 'playing') return this.events; + this.waveT += dtMs; + + // Spawn new enemy missiles up to this wave's quota. + if (this.waveSpawned < this.waveQuota) { + this.spawnT += dtMs; + const interval = spawnInterval(this.wave); + while (this.spawnT >= interval && this.waveSpawned < this.waveQuota) { + this.spawnT -= interval; + if (this.spawnEnemyMissile()) this.waveSpawned += 1; + } + } + + // Advance enemy missiles; collect MIRV splits without mutating mid-loop. + const newChildren = []; + for (const m of this.enemyMissiles) { + m.t += dtMs / m.dur; + if (m.kind === 'mirv' && !m.splitDone && m.t >= m.splitAt && m.t < 1) { + m.splitDone = true; + newChildren.push(...this.makeMirvChildren(m)); + } + } + if (newChildren.length) this.enemyMissiles.push(...newChildren); + + const stillFlying = []; + for (const m of this.enemyMissiles) { + if (m.t >= 1) this.resolveImpact(m); + else stillFlying.push(m); + } + this.enemyMissiles = stillFlying; + + // Advance interceptors; on arrival they become a player explosion (blast + // radius kills, not point hits — the authentic Missile Command mechanic). + const stillInterceptors = []; + for (const p of this.interceptors) { + p.t += dtMs / p.dur; + if (p.t >= 1) this.spawnExplosion(p.toX, p.toY, 'player'); + else stillInterceptors.push(p); + } + this.interceptors = stillInterceptors; + + // Advance explosions and resolve collisions against enemy missiles. + this.explosions = this.explosions.filter((e) => { + e.t += dtMs; + return e.t < e.totalMs; + }); + for (const e of this.explosions) { + if (e.owner !== 'player') continue; + const r = explosionRadius(e); + if (r <= 0) continue; + this.enemyMissiles = this.enemyMissiles.filter((m) => { + const pos = lerpPos(m); + const d = Math.hypot(pos.x - e.x, pos.y - e.y); + if (d <= r) { + const gained = TUNE.SCORE_PER_KILL * this.wave; + this.score += gained; + this.emit('missileDestroyed', { x: pos.x, y: pos.y, score: gained }); + return false; + } + return true; + }); + } + + if (this.waveSpawned >= this.waveQuota && this.enemyMissiles.length === 0 && this.interceptors.length === 0) { + this.completeWave(); + } + + if (this.status === 'playing' && this.aliveCities().length === 0) { + this.status = 'gameover'; + this.emit('gameOver', { score: this.score, wave: this.wave }); + } + + return this.events; + } +} + +export function createGame(cityPool, seed) { + return new Sim(cityPool, seed); +} + +export function step(sim, dtMs) { + return sim.step(dtMs); +} + +// Nearest surviving, ammo-having base to the click point; null if none ready. +export function pickFiringBase(sim, x, y) { + const candidates = sim.bases.filter((b) => b.alive && b.ammo > 0); + if (!candidates.length) return null; + candidates.sort((a, b) => Math.hypot(a.x - x, a.y - y) - Math.hypot(b.x - x, b.y - y)); + return candidates[0]; +} + +export function fireInterceptor(sim, base, x, y) { + if (!base || !base.alive || base.ammo <= 0) return null; + base.ammo -= 1; + const dist = Math.hypot(x - base.x, y - base.y); + const dur = Math.max(120, (dist / TUNE.INTERCEPTOR_SPEED) * 1000); + const interceptor = { + id: sim.nextId++, fromX: base.x, fromY: base.y, toX: x, toY: y, t: 0, dur, baseSlot: base.slot, + }; + sim.interceptors.push(interceptor); + sim.emit('interceptorFired', { x: base.x, y: base.y }); + return interceptor; +} diff --git a/src/main.js b/src/main.js index 73d8253..cee50df 100644 --- a/src/main.js +++ b/src/main.js @@ -89,6 +89,7 @@ import SWDBGGame from './games/swdbg/SWDBGGame.js'; import BalatroGame from './games/balatro/BalatroGame.js'; import PeggleGame from './games/peggle/PeggleGame.js'; import PeggleEditor from './games/peggle/PeggleEditor.js'; +import ColoradoDefenseGame from './games/coloradodefense/ColoradoDefenseGame.js'; const config = { type: Phaser.AUTO, @@ -191,6 +192,7 @@ const config = { BalatroGame, PeggleGame, PeggleEditor, + ColoradoDefenseGame, ], }; diff --git a/src/scenes/GameMenuScene.js b/src/scenes/GameMenuScene.js index 1f10818..b26553e 100644 --- a/src/scenes/GameMenuScene.js +++ b/src/scenes/GameMenuScene.js @@ -14,9 +14,10 @@ const CATEGORIES = [ { key: 'casino', label: 'Casino' }, { key: 'word', label: 'Words & Numbers' }, { key: 'logic', label: 'Logic & Puzzle' }, + { key: 'arcade-console-pc', label: 'Arcade, Console & PC' }, ]; -const TAB_ICON_FRAMES = { tabletop: 0, cards: 1, casino: 2, word: 3, logic: 4 }; +const TAB_ICON_FRAMES = { tabletop: 0, cards: 1, casino: 2, word: 3, logic: 4, 'arcade-console-pc': 5 }; const ICON_INACTIVE = 56; const ICON_ACTIVE = 72; const ICON_OVERSHOOT = 86; @@ -63,7 +64,7 @@ export default class GameMenuScene extends Phaser.Scene { // Two-row tab layout: row 1 has 4 categories, row 2 has the rest const ROW1_KEYS = ['tabletop', 'cards', 'casino', 'word']; - const ROW2_KEYS = ['logic']; + const ROW2_KEYS = ['logic', 'arcade-console-pc']; const ROW1 = CATEGORIES.filter(c => ROW1_KEYS.includes(c.key)); const ROW2 = CATEGORIES.filter(c => ROW2_KEYS.includes(c.key)); const activeRow1 = ROW1.filter(({ key }) => this._gamesByCategory[key].length > 0); diff --git a/src/scenes/GameRoomScene.js b/src/scenes/GameRoomScene.js index f5b5db8..39fda0e 100644 --- a/src/scenes/GameRoomScene.js +++ b/src/scenes/GameRoomScene.js @@ -23,7 +23,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', bejeweled: 'BejeweledGame', minimotorways: 'MiniMotorwaysGame', slots: 'SlotsGame', cribbage: 'CribbageGame', canasta: 'CanastaGame', dotlink: 'DotLinkGame', '2048': '2048Game', rummikub: 'RummikubGame', ginrummy: 'GinRummyGame', risk: 'RiskGame', geniussquare: 'GeniusSquareGame', katamino: 'KataminoGame', bookwork: 'BookworkGame', paigow: 'PaiGowPokerGame', spireclimb: 'SpireClimbGame', azul: 'AzulGame', jumble: 'JumbleGame', dungeonboss: 'DungeonBossGame', swdbg: 'SWDBGGame', balatro: 'BalatroGame', peggle: 'PeggleGame' }; + 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', slots: 'SlotsGame', cribbage: 'CribbageGame', canasta: 'CanastaGame', dotlink: 'DotLinkGame', '2048': '2048Game', rummikub: 'RummikubGame', ginrummy: 'GinRummyGame', risk: 'RiskGame', geniussquare: 'GeniusSquareGame', katamino: 'KataminoGame', bookwork: 'BookworkGame', paigow: 'PaiGowPokerGame', spireclimb: 'SpireClimbGame', azul: 'AzulGame', jumble: 'JumbleGame', dungeonboss: 'DungeonBossGame', swdbg: 'SWDBGGame', balatro: 'BalatroGame', peggle: 'PeggleGame', coloradodefense: 'ColoradoDefenseGame' }; if (slugDispatch[this.game.slug]) { const sceneKey = slugDispatch[this.game.slug]; const startData = { diff --git a/src/scenes/PreloadScene.js b/src/scenes/PreloadScene.js index a9158ae..e69a96e 100644 --- a/src/scenes/PreloadScene.js +++ b/src/scenes/PreloadScene.js @@ -60,6 +60,7 @@ export default class PreloadScene extends Phaser.Scene { this.load.json('jumble', 'data/jumble.json'); this.load.json('balatro-artwork', 'data/balatro-artwork.json'); this.load.json('peggle-levels', 'assets/gamedata/peggle/levels.json'); + this.load.json('colorado-defense-cities', 'data/colorado-defense-cities.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/src/ui/ArcadeCRTOverlay.js b/src/ui/ArcadeCRTOverlay.js new file mode 100644 index 0000000..c800753 --- /dev/null +++ b/src/ui/ArcadeCRTOverlay.js @@ -0,0 +1,124 @@ +import * as Phaser from 'phaser'; +import { GAME_WIDTH, GAME_HEIGHT } from '../config.js'; + +// Reusable "arcade cabinet" screen dressing: animated scanlines + a vector +// TV-style bevel/vignette. Drop this onto any arcade/console game scene. +// +// (src/games/balatro/BalatroCrtPipeline.js is a different, WebGL-shader-based +// CRT effect that's single-consumer and Canvas-incompatible — not reused here; +// this module is GameObject-based so it works on any renderer.) +// +// Usage: +// this.crt = applyArcadeCRTOverlay(this, { accentTint: 0xff6b3a }); +// this.events.once('shutdown', () => this.crt.destroy()); +// this.crt.pulse(1.0, 400); // one-off impact flash +// this.crt.setIntensity({ accentTint: C.accent }); // re-skin on palette change + +const SCAN_TEXTURE_KEY = 'arcade-crt-scan'; +const SCAN_STRIP_HEIGHT = 12; +const SCAN_LINE_HEIGHT = 6; + +function ensureScanTexture(scene) { + if (scene.textures.exists(SCAN_TEXTURE_KEY)) return; + const g = scene.make.graphics({ x: 0, y: 0, add: false }); + g.fillStyle(0xffffff, 0.18); + g.fillRect(0, 0, GAME_WIDTH, SCAN_LINE_HEIGHT); + g.generateTexture(SCAN_TEXTURE_KEY, GAME_WIDTH, SCAN_STRIP_HEIGHT); + g.destroy(); +} + +export function applyArcadeCRTOverlay(scene, options = {}) { + const opts = { + scanlineAlpha: 0.65, + scanlineTint: 0x00f0ff, + scanlineSpeedMs: 9000, + bevelThickness: 78, + bevelColor: 0x05060a, + bevelRadius: 32, + vignetteStrength: 0.78, + accentTint: 0xc8a84b, + depth: { scan: 58, bevel: 59 }, + ...options, + }; + + ensureScanTexture(scene); + + const scan = scene.add + .tileSprite(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, SCAN_TEXTURE_KEY) + .setDepth(opts.depth.scan) + .setAlpha(opts.scanlineAlpha) + .setTint(opts.scanlineTint); + scan.setBlendMode(Phaser.BlendModes.ADD); + + const scanTween = scene.tweens.add({ + targets: scan, + tilePositionY: GAME_HEIGHT, + duration: opts.scanlineSpeedMs, + repeat: -1, + ease: 'Linear', + }); + + const bevel = scene.add.graphics().setDepth(opts.depth.bevel); + + function drawVignette(g) { + const corners = [ + [0, 0], [GAME_WIDTH, 0], [0, GAME_HEIGHT], [GAME_WIDTH, GAME_HEIGHT], + ]; + for (const [cx, cy] of corners) { + g.fillStyle(0x000000, opts.vignetteStrength * 0.42); + g.fillCircle(cx, cy, 480); + g.fillStyle(0x000000, opts.vignetteStrength * 0.3); + g.fillCircle(cx, cy, 320); + } + const rings = 7; + for (let i = 0; i < rings; i += 1) { + const t = i / (rings - 1); + const inset = opts.bevelThickness + t * 180; + const alpha = opts.vignetteStrength * (1 - t) * 0.16; + g.lineStyle(52, 0x000000, alpha); + g.strokeRoundedRect(inset, inset, GAME_WIDTH - inset * 2, GAME_HEIGHT - inset * 2, opts.bevelRadius + 8); + } + } + + function drawBevel() { + bevel.clear(); + drawVignette(bevel); + const inset = opts.bevelThickness / 2; + bevel.lineStyle(opts.bevelThickness, opts.bevelColor, 1); + bevel.strokeRoundedRect(inset, inset, GAME_WIDTH - opts.bevelThickness, GAME_HEIGHT - opts.bevelThickness, opts.bevelRadius); + bevel.lineStyle(9, opts.accentTint, 0.65); + bevel.strokeRoundedRect( + opts.bevelThickness, opts.bevelThickness, + GAME_WIDTH - opts.bevelThickness * 2, GAME_HEIGHT - opts.bevelThickness * 2, + Math.max(0, opts.bevelRadius - 8), + ); + } + + drawBevel(); + + let flashTween = null; + + const controller = { + pulse(strength = 0.5, durationMs = 150) { + scene.cameras.main.shake(durationMs, 0.004 * strength); + if (flashTween) flashTween.stop(); + scan.setAlpha(Math.min(1, opts.scanlineAlpha + 0.5 * strength)); + flashTween = scene.tweens.add({ + targets: scan, alpha: opts.scanlineAlpha, duration: durationMs * 2, + }); + }, + setIntensity(patch = {}) { + Object.assign(opts, patch); + scan.setAlpha(opts.scanlineAlpha).setTint(opts.scanlineTint); + drawBevel(); + }, + destroy() { + scanTween.stop(); + if (flashTween) flashTween.stop(); + scan.destroy(); + bevel.destroy(); + }, + }; + + return controller; +} diff --git a/tools/verifyColoradoDefense.js b/tools/verifyColoradoDefense.js new file mode 100644 index 0000000..3b215cf --- /dev/null +++ b/tools/verifyColoradoDefense.js @@ -0,0 +1,205 @@ +// Headless verification for Colorado Defense. +// node tools/verifyColoradoDefense.js +// Exits non-zero on any failure. +// +// 1. Ground-layout invariants (9 slots, 3 bases + 6 cities, symmetric spacing). +// 2. City-pool draw correctness + malformed-pool fallback. +// 3. Difficulty escalation monotonicity across waves 1-40 (bounded). +// 4. MIRV step-unlock threshold. +// 5. Scripted explosion/collision fixture. +// 6. Palette-progression monotonicity. +// 7. Monte-carlo "always fire at nearest incoming missile" bot run. + +import { + BASE_SLOTS, CITY_SLOTS, GROUND_SLOTS, slotX, mulberry32, pickCities, + DEFAULT_CITIES, TUNE, spawnInterval, fallSpeed, waveQuota, mirvChance, + PALETTES, paletteIndexForWave, createGame, step, pickFiringBase, + fireInterceptor, lerpPos, explosionRadius, +} from '../src/games/coloradodefense/ColoradoDefenseLogic.js'; + +let failures = 0; +function check(name, cond, detail = '') { + if (cond) { console.log(` ok ${name}`); return; } + failures += 1; + console.error(` FAIL ${name}${detail ? ` — ${detail}` : ''}`); +} + +// ── 1. Ground layout ───────────────────────────────────────────────────────── + +console.log('Ground layout'); +{ + check('9 slots total', GROUND_SLOTS === 9); + check('3 base slots', BASE_SLOTS.length === 3); + check('6 city slots', CITY_SLOTS.length === 6); + const allSlots = [...BASE_SLOTS, ...CITY_SLOTS].sort((a, b) => a - b); + check('slots partition 0..8 exactly', allSlots.join(',') === '0,1,2,3,4,5,6,7,8'); + check('classic arrangement Base,City,City,City,Base,City,City,City,Base', + BASE_SLOTS.join(',') === '0,4,8' && CITY_SLOTS.join(',') === '1,2,3,5,6,7'); + + const xs = Array.from({ length: GROUND_SLOTS }, (_, i) => slotX(i)); + let increasing = true; + for (let i = 1; i < xs.length; i += 1) if (xs[i] <= xs[i - 1]) increasing = false; + check('slot positions strictly increasing left to right', increasing); + const mid = (xs[0] + xs[8]) / 2; + check('layout symmetric around center', Math.abs((xs[4]) - mid) < 1, `slot4=${xs[4]} mid=${mid}`); +} + +// ── 2. City pool ────────────────────────────────────────────────────────────── + +console.log('City pool'); +{ + const rng = mulberry32(42); + for (const seed of [1, 2, 3, 999, 123456]) { + const r = mulberry32(seed); + const picked = pickCities(DEFAULT_CITIES, r); + check(`seed ${seed}: exactly 6 cities`, picked.length === 6); + check(`seed ${seed}: unique cities`, new Set(picked).size === 6, picked.join(',')); + check(`seed ${seed}: all from pool`, picked.every((n) => DEFAULT_CITIES.includes(n))); + } + const empty = pickCities([], rng); + check('empty pool falls back safely (no throw, bounded length)', empty.length === 6); + const small = pickCities(['A', 'B'], rng); + check('pool smaller than 6 cycles without throwing', small.length === 6); +} + +// ── 3. Escalation monotonicity ──────────────────────────────────────────────── + +console.log('Escalation monotonicity (waves 1-40)'); +{ + let spawnOk = true; let fallOk = true; let quotaOk = true; + let spawnBounded = true; let fallBounded = true; let quotaBounded = true; + for (let w = 2; w <= 40; w += 1) { + if (spawnInterval(w) > spawnInterval(w - 1)) spawnOk = false; + if (fallSpeed(w) < fallSpeed(w - 1)) fallOk = false; + if (waveQuota(w) < waveQuota(w - 1)) quotaOk = false; + } + for (let w = 1; w <= 40; w += 1) { + if (spawnInterval(w) < TUNE.SPAWN_MS_MIN) spawnBounded = false; + if (fallSpeed(w) > TUNE.FALL_SPEED_MAX) fallBounded = false; + if (waveQuota(w) > TUNE.WAVE_QUOTA_MAX) quotaBounded = false; + } + check('spawn interval never increases with wave', spawnOk); + check('fall speed never decreases with wave', fallOk); + check('wave quota never decreases with wave', quotaOk); + check('spawn interval bounded at floor', spawnBounded); + check('fall speed bounded at cap', fallBounded); + check('wave quota bounded at cap', quotaBounded); +} + +// ── 4. MIRV step-unlock ──────────────────────────────────────────────────────── + +console.log('MIRV step-unlock'); +{ + let belowZero = true; + for (let w = 1; w < TUNE.MIRV_WAVE; w += 1) if (mirvChance(w) !== 0) belowZero = false; + check(`mirvChance is 0 before wave ${TUNE.MIRV_WAVE}`, belowZero); + check(`mirvChance > 0 at wave ${TUNE.MIRV_WAVE}`, mirvChance(TUNE.MIRV_WAVE) > 0); + check('mirvChance bounded at max', mirvChance(999) <= TUNE.MIRV_CHANCE_MAX); +} + +// ── 5. Explosion/collision fixture ──────────────────────────────────────────── + +console.log('Explosion/collision fixture'); +{ + const sim = createGame(DEFAULT_CITIES, 7); + const target = sim.cities[0]; + // A slow-descending missile (long dur) barely drifts during the + // interceptor's flight time, so a shot at its current position still lands. + sim.enemyMissiles.push({ + id: sim.nextId++, fromX: target.x, fromY: 0, toX: target.x, toY: target.y, + targetSlot: target.slot, targetKind: 'city', t: 0.5, dur: 60000, kind: 'single', splitAt: 2, splitDone: true, + }); + const missilePos = lerpPos(sim.enemyMissiles[0]); + const base = pickFiringBase(sim, missilePos.x, missilePos.y); + fireInterceptor(sim, base, missilePos.x, missilePos.y); + let destroyed = false; + let steps = 0; + while (!destroyed && steps < 200) { + const events = step(sim, 16); + if (events.some((e) => e.type === 'missileDestroyed')) destroyed = true; + steps += 1; + } + check('interceptor blast destroys missile within radius', destroyed, `steps=${steps}`); + check('score increased on kill', sim.score > 0, `score=${sim.score}`); + + const sim2 = createGame(DEFAULT_CITIES, 8); + const target2 = sim2.cities[0]; + sim2.enemyMissiles.push({ + id: sim2.nextId++, fromX: target2.x, fromY: 0, toX: target2.x, toY: target2.y, + targetSlot: target2.slot, targetKind: 'city', t: 0.5, dur: 4000, kind: 'single', splitAt: 2, splitDone: true, + }); + const far = lerpPos(sim2.enemyMissiles[0]); + fireInterceptor(sim2, sim2.bases[1], far.x + 5000, far.y); + for (let i = 0; i < 60; i += 1) step(sim2, 16); + check('missile far outside blast radius survives', sim2.enemyMissiles.length === 1); +} + +// ── 6. Palette progression ──────────────────────────────────────────────────── + +console.log('Palette progression'); +{ + let monotonic = true; + for (let w = 2; w <= 60; w += 1) { + if (paletteIndexForWave(w) < paletteIndexForWave(w - 1)) monotonic = false; + } + check('palette index monotonic non-decreasing', monotonic); + check('palette index clamps at array end', paletteIndexForWave(9999) === PALETTES.length - 1); +} + +// ── 7. Monte-carlo bot run ──────────────────────────────────────────────────── + +console.log('Monte-carlo bot run'); +{ + for (const seed of [1, 2, 3, 4, 5]) { + const sim = createGame(DEFAULT_CITIES, seed); + let steps = 0; + let scoreRegressed = false; + let cityCountIncreased = false; + let nanFound = false; + let prevScore = 0; + let prevCities = sim.aliveCities().length; + const MAX_STEPS = 20000; // ~320s of sim time at 16ms/step + + while (sim.status === 'playing' && steps < MAX_STEPS) { + // Bot: fire at whichever enemy missile is closest to impact (highest t), + // but only one interceptor in flight at a time so ammo isn't wasted on + // redundant shots at the same target. + if (sim.enemyMissiles.length && sim.interceptors.length === 0) { + let soonest = null; let soonestT = -1; + for (const m of sim.enemyMissiles) { + if (m.t > soonestT) { soonestT = m.t; soonest = m; } + } + if (soonest) { + const pos = lerpPos(soonest); + const base = pickFiringBase(sim, pos.x, pos.y); + if (base) fireInterceptor(sim, base, pos.x, pos.y); + } + } + step(sim, 16); + steps += 1; + + if (sim.score < prevScore) scoreRegressed = true; + prevScore = sim.score; + const aliveCities = sim.aliveCities().length; + if (aliveCities > prevCities) cityCountIncreased = true; + prevCities = aliveCities; + + for (const list of [sim.enemyMissiles, sim.interceptors, sim.explosions]) { + for (const e of list) { + if (Number.isNaN(e.x ?? e.fromX) || Number.isNaN(e.y ?? e.fromY)) nanFound = true; + } + } + } + + check(`seed ${seed}: no NaN values`, !nanFound); + check(`seed ${seed}: score never regressed`, !scoreRegressed); + check(`seed ${seed}: city count never increased`, !cityCountIncreased); + check(`seed ${seed}: game over exactly when all cities dead`, + sim.status === 'gameover' ? sim.aliveCities().length === 0 : true); + check(`seed ${seed}: terminates within step bound`, steps < MAX_STEPS, `steps=${steps}`); + console.log(` seed ${seed}: status=${sim.status} wave=${sim.wave} score=${sim.score} steps=${steps}`); + } +} + +console.log(failures ? `\n${failures} FAILURE(S)` : '\nAll checks passed.'); +process.exit(failures ? 1 : 0);