diff --git a/src/games/coloradodefense/ColoradoDefenseGame.js b/src/games/coloradodefense/ColoradoDefenseGame.js index 1fcb919..62e248b 100644 --- a/src/games/coloradodefense/ColoradoDefenseGame.js +++ b/src/games/coloradodefense/ColoradoDefenseGame.js @@ -288,6 +288,25 @@ export default class ColoradoDefenseGame extends Phaser.Scene { case 'missileDestroyed': playSound(this, e.chained ? SFX.EIGHTBIT_EXPLODE_2 : SFX.EIGHTBIT_EXPLODE); break; + case 'planeSpawned': + playSound(this, SFX.WOOSH); + break; + case 'planeDestroyed': + playSound(this, SFX.EIGHTBIT_EXPLODE); + this.crt.pulse(0.5, 180); + this.showFloatingScore(e.x, e.y, e.score); + break; + case 'ufoSpawned': + playSound(this, SFX.SCIFI_RISER); + break; + case 'ufoShot': + playSound(this, SFX.LASER_ZAP); + break; + case 'ufoDestroyed': + playSound(this, SFX.EIGHTBIT_EXPLODE); + this.crt.pulse(0.8, 260); + this.showFloatingScore(e.x, e.y, e.score); + break; case 'waveComplete': this.playWaveRecap(e); break; @@ -326,9 +345,88 @@ export default class ColoradoDefenseGame extends Phaser.Scene { g.lineStyle(2, 0xffffff, 0.8); g.strokeCircle(e.x, e.y, r); } + if (this.state.plane) this.drawPlane(g, this.state.plane); + if (this.state.ufo) this.drawUfo(g, this.state.ufo); + for (const p of this.state.debris) this.drawDebris(g, p); this.updateHud(); } + // Slow bonus plane — a simple swept-wing vector silhouette, mirrored by + // flight direction, with the same glow-stroke treatment as everything else. + drawPlane(g, plane) { + const { x, y, dir } = plane; + const fx = (v) => x + v * dir; + const draw = () => { + g.beginPath(); g.moveTo(fx(30), y); g.lineTo(fx(-26), y); g.strokePath(); + g.beginPath(); g.moveTo(fx(4), y); g.lineTo(fx(-12), y - 26); g.strokePath(); + g.beginPath(); g.moveTo(fx(4), y); g.lineTo(fx(-12), y + 26); g.strokePath(); + g.beginPath(); g.moveTo(fx(-22), y); g.lineTo(fx(-30), y - 9); g.strokePath(); + g.beginPath(); g.moveTo(fx(-22), y); g.lineTo(fx(-30), y + 9); g.strokePath(); + }; + g.lineStyle(5, this.C.accent, 0.16); + draw(); + g.lineStyle(2, this.C.accent, 1); + draw(); + g.fillStyle(0xffffff, 0.9); + g.fillCircle(fx(30), y, 2); + } + + // Bonus UFO — a classic saucer + dome silhouette (glow-stroke, like + // everything else) with a tractor-beam glow underneath and a ring of + // rim lights that blink in an alternating pattern. + drawUfo(g, ufo) { + const { x, y } = ufo; + const rimW = 76; const rimH = 20; + const domeW = 38; const domeH = 24; + + const draw = () => { + g.strokeEllipse(x, y, rimW, rimH); + g.strokeEllipse(x, y - 10, domeW, domeH); + g.beginPath(); + g.moveTo(x - 14, y + 6); + g.lineTo(x, y + 28); + g.lineTo(x + 14, y + 6); + g.strokePath(); + }; + g.lineStyle(6, this.C.accent, 0.16); + draw(); + g.lineStyle(2, this.C.accent, 1); + draw(); + + g.fillStyle(this.C.chain, 0.12); + g.fillEllipse(x, y, rimW - 4, rimH - 4); + + const blink = Math.floor(this.time.now / 200) % 2 === 0; + const lightCount = 7; + for (let i = 0; i < lightCount; i += 1) { + const theta = (i / lightCount) * Math.PI * 2; + const lx = x + Math.cos(theta) * (rimW / 2 - 4); + const ly = y + Math.sin(theta) * (rimH / 2 - 2); + const on = (i % 2 === 0) === blink; + g.fillStyle(on ? 0xffffff : this.C.accent, on ? 1 : 0.4); + g.fillCircle(lx, ly, 2.4); + } + } + + // A small rotating shard for each piece of wreckage (plane or UFO). + drawDebris(g, p) { + const len = 10; + const dx = Math.cos(p.rot) * len; + const dy = Math.sin(p.rot) * len; + g.lineStyle(3, 0xffaa66, 0.9); + g.lineBetween(p.x - dx, p.y - dy, p.x + dx, p.y + dy); + } + + showFloatingScore(x, y, amount) { + const text = this.add.text(x, y, `+${amount}`, { + fontFamily: 'm6x11, "Julius Sans One"', fontSize: '26px', color: COLORS.goldHex, + }).setOrigin(0.5).setDepth(D.ui); + this.tweens.add({ + targets: text, y: y - 60, alpha: 0, duration: 900, ease: 'Cubic.easeOut', + onComplete: () => text.destroy(), + }); + } + // ── Inter-wave recap ────────────────────────────────────────────────────── wait(ms) { return new Promise((resolve) => this.time.delayedCall(ms, resolve)); diff --git a/src/games/coloradodefense/ColoradoDefenseLogic.js b/src/games/coloradodefense/ColoradoDefenseLogic.js index aa268ed..d17798e 100644 --- a/src/games/coloradodefense/ColoradoDefenseLogic.js +++ b/src/games/coloradodefense/ColoradoDefenseLogic.js @@ -74,6 +74,34 @@ export const TUNE = { CHAIN_DELAY_MS: 250, CHAIN_EXTRA_GROW_MS: 750, CHAIN_R_MULT: 1.5, + // A slow bonus plane that crosses the screen every so often from wave 2 on. + PLANE_WAVE: 2, + PLANE_CROSS_MS: 8000, + PLANE_SPAWN_MIN_MS: 15000, + PLANE_SPAWN_MAX_MS: 25000, + PLANE_Y_FRAC: 0.12, + PLANE_SCORE: 150, + PLANE_DEBRIS_COUNT: [4, 6], + PLANE_DEBRIS_H_DAMPING: 0.6, + PLANE_DEBRIS_GRAVITY: 0.0003, + // A tougher bonus UFO from wave 4 on: weaves up/down and fires its own + // MIRVs twice if left alive long enough to cross the screen. + UFO_WAVE: 4, + UFO_CROSS_MS: 10000, + UFO_SPAWN_MIN_MS: 20000, + UFO_SPAWN_MAX_MS: 32000, + UFO_Y_MIN_FRAC: 0.06, + UFO_Y_MAX_FRAC: 0.28, + UFO_MAX_ANGLE_DEG: 25, + UFO_TURNS: [2, 3], + UFO_SHOTS: 2, + UFO_SHOT_GAP_MIN_MS: 3000, + UFO_SHOT_GAP_MAX_MS: 4000, + UFO_FIRST_SHOT_MS: 2500, + UFO_SCORE: 1000, + UFO_DEBRIS_COUNT: [7, 10], + UFO_DEBRIS_H_DAMPING: 0.5, + UFO_DEBRIS_GRAVITY: 0.00025, }; export function spawnInterval(wave) { @@ -156,6 +184,16 @@ export class Sim { this.waveSpawned = 0; this.waveQuota = waveQuota(this.wave); this.spawnT = 0; + + this.plane = null; // the single bonus plane currently in flight, if any + this.ufo = null; // the single bonus UFO currently in flight, if any + this.debris = []; // shared falling wreckage for a destroyed plane or UFO + this.planeSpawnT = 0; + this.nextPlaneAt = TUNE.PLANE_SPAWN_MIN_MS + + this.rng() * (TUNE.PLANE_SPAWN_MAX_MS - TUNE.PLANE_SPAWN_MIN_MS); + this.ufoSpawnT = 0; + this.nextUfoAt = TUNE.UFO_SPAWN_MIN_MS + + this.rng() * (TUNE.UFO_SPAWN_MAX_MS - TUNE.UFO_SPAWN_MIN_MS); } emit(type, data = {}) { this.events.push({ type, ...data }); } @@ -172,7 +210,13 @@ export class Sim { return weighted[Math.floor(this.rng() * weighted.length)]; } - spawnEnemyMissile(fromOverride) { + // fromOverride lets a caller launch from an arbitrary in-air position (a + // MIRV child bursting off its parent, or a UFO's own shot) instead of the + // top edge. forceMirv is for a UFO's guaranteed MIRV payload — otherwise + // only a freshly-spawned, top-edge missile can randomly become one; a + // MIRV's own children (fromOverride, no force) are always simple 'single's + // so the split tree stays bounded. + spawnEnemyMissile(fromOverride, forceMirv = false) { const target = this.pickTargetSlot(); if (!target) return null; const fromX = fromOverride ? fromOverride.x : 80 + this.rng() * (WIDTH - 160); @@ -180,9 +224,7 @@ export class Sim { const dist = Math.hypot(target.x - fromX, target.y - fromY); const speed = fallSpeed(this.wave); const dur = Math.max(200, (dist / speed) * 1000); - // Only a freshly-spawned missile (not a MIRV child) can itself be a MIRV - // "bus" — it keeps flying toward its own target after each split. - const isMirv = !fromOverride && this.rng() < mirvChance(this.wave); + const isMirv = forceMirv || (!fromOverride && this.rng() < mirvChance(this.wave)); const kind = isMirv ? 'mirv' : 'single'; const splitsLeft = isMirv ? (this.wave >= TUNE.MIRV_RESPLIT_WAVE ? 2 : 1) : 0; const missile = { @@ -235,6 +277,61 @@ export class Sim { this.emit('explosion', { x, y, owner, lethal }); } + spawnPlane() { + const dir = this.rng() < 0.5 ? 1 : -1; + const y = HEIGHT * TUNE.PLANE_Y_FRAC; + const speed = WIDTH / TUNE.PLANE_CROSS_MS; // px/ms — crosses the full width in PLANE_CROSS_MS + const x = dir === 1 ? -60 : WIDTH + 60; + this.plane = { id: this.nextId++, x, y, dir, speed, vx: dir * speed, vy: 0 }; + this.emit('planeSpawned', { x, y, dir }); + } + + spawnUfo() { + const dir = this.rng() < 0.5 ? 1 : -1; + const y = HEIGHT * (TUNE.UFO_Y_MIN_FRAC + this.rng() * (TUNE.UFO_Y_MAX_FRAC - TUNE.UFO_Y_MIN_FRAC)); + const speed = WIDTH / TUNE.UFO_CROSS_MS; // px/ms — crosses the full width in UFO_CROSS_MS + const x = dir === 1 ? -70 : WIDTH + 70; + + const [tLo, tHi] = TUNE.UFO_TURNS; + const turns = tLo + Math.floor(this.rng() * (tHi - tLo + 1)); + const turnSchedule = []; + for (let i = 0; i < turns; i += 1) { + const frac = (i + 1) / (turns + 1); + turnSchedule.push(Math.max(200, TUNE.UFO_CROSS_MS * frac + (this.rng() - 0.5) * 800)); + } + turnSchedule.sort((a, b) => a - b); + + const shotGap = TUNE.UFO_SHOT_GAP_MIN_MS + this.rng() * (TUNE.UFO_SHOT_GAP_MAX_MS - TUNE.UFO_SHOT_GAP_MIN_MS); + this.ufo = { + id: this.nextId++, x, y, dir, speed, vx: dir * speed, vy: 0, + elapsed: 0, turnSchedule, turnIdx: 0, + shotsLeft: TUNE.UFO_SHOTS, nextShotAt: TUNE.UFO_FIRST_SHOT_MS + (this.rng() - 0.5) * 500, shotGap, + }; + this.emit('ufoSpawned', { x, y, dir }); + } + + fireUfoShot(ufo) { + const missile = this.spawnEnemyMissile({ x: ufo.x, y: ufo.y }, true); + if (missile) this.emit('ufoShot', { x: ufo.x, y: ufo.y }); + } + + spawnDebris(source, opts) { + const [lo, hi] = opts.count; + const n = lo + Math.floor(this.rng() * (hi - lo + 1)); + for (let i = 0; i < n; i += 1) { + this.debris.push({ + id: this.nextId++, + x: source.x + (this.rng() - 0.5) * 60, + y: source.y, + vx: source.vx * opts.hDamping, + vy: source.vy * opts.hDamping - 0.05 - this.rng() * 0.04, + rot: this.rng() * Math.PI * 2, + rotSpeed: (this.rng() - 0.5) * 0.01, + gravity: opts.gravity, + }); + } + } + completeWave() { // Snapshot pre-refill ammo and the survivor bonus breakdown so a // consumer (e.g. an inter-wave recap animation) can show exactly what @@ -308,6 +405,70 @@ export class Sim { } this.interceptors = stillInterceptors; + // Bonus plane: ticks on its own timer once unlocked, independent of wave + // spawning/quota, so it keeps showing up smoothly across wave transitions. + if (this.wave >= TUNE.PLANE_WAVE) { + this.planeSpawnT += dtMs; + if (!this.plane && this.planeSpawnT >= this.nextPlaneAt) { + this.spawnPlane(); + this.planeSpawnT = 0; + this.nextPlaneAt = TUNE.PLANE_SPAWN_MIN_MS + + this.rng() * (TUNE.PLANE_SPAWN_MAX_MS - TUNE.PLANE_SPAWN_MIN_MS); + } + } + if (this.plane) { + this.plane.x += this.plane.vx * dtMs; + if (this.plane.x < -100 || this.plane.x > WIDTH + 100) this.plane = null; + } + + // Bonus UFO: same unlock-timer pattern as the plane, but it weaves its + // heading a few times per flight and fires its own MIRVs if left alive. + if (this.wave >= TUNE.UFO_WAVE) { + this.ufoSpawnT += dtMs; + if (!this.ufo && this.ufoSpawnT >= this.nextUfoAt) { + this.spawnUfo(); + this.ufoSpawnT = 0; + this.nextUfoAt = TUNE.UFO_SPAWN_MIN_MS + + this.rng() * (TUNE.UFO_SPAWN_MAX_MS - TUNE.UFO_SPAWN_MIN_MS); + } + } + if (this.ufo) { + const u = this.ufo; + u.elapsed += dtMs; + if (u.turnIdx < u.turnSchedule.length && u.elapsed >= u.turnSchedule[u.turnIdx]) { + u.turnIdx += 1; + const angle = (this.rng() * 2 - 1) * TUNE.UFO_MAX_ANGLE_DEG * (Math.PI / 180); + u.vx = Math.cos(angle) * u.speed * u.dir; + u.vy = Math.sin(angle) * u.speed; + this.emit('ufoTurn', { x: u.x, y: u.y }); + } + u.x += u.vx * dtMs; + u.y += u.vy * dtMs; + const yMin = HEIGHT * TUNE.UFO_Y_MIN_FRAC; + const yMax = HEIGHT * TUNE.UFO_Y_MAX_FRAC; + if (u.y < yMin) { u.y = yMin; u.vy = 0; } + if (u.y > yMax) { u.y = yMax; u.vy = 0; } + + if (u.shotsLeft > 0) { + u.nextShotAt -= dtMs; + if (u.nextShotAt <= 0) { + this.fireUfoShot(u); + u.shotsLeft -= 1; + u.nextShotAt = u.shotGap; + } + } + + if (u.x < -100 || u.x > WIDTH + 100) this.ufo = null; + } + + this.debris = this.debris.filter((p) => { + p.vy += p.gravity * dtMs; + p.x += p.vx * dtMs; + p.y += p.vy * dtMs; + p.rot += p.rotSpeed * dtMs; + return p.y < GROUND_Y + 40; + }); + // Detonate any missile whose delayed self-destruct has come due, so its // secondary blast joins this tick's collision pass below. const readyChains = []; @@ -349,6 +510,24 @@ export class Sim { } return true; }); + + if (this.plane && Math.hypot(this.plane.x - e.x, this.plane.y - e.y) <= r) { + this.score += TUNE.PLANE_SCORE; + this.emit('planeDestroyed', { x: this.plane.x, y: this.plane.y, score: TUNE.PLANE_SCORE }); + this.spawnDebris(this.plane, { + count: TUNE.PLANE_DEBRIS_COUNT, hDamping: TUNE.PLANE_DEBRIS_H_DAMPING, gravity: TUNE.PLANE_DEBRIS_GRAVITY, + }); + this.plane = null; + } + + if (this.ufo && Math.hypot(this.ufo.x - e.x, this.ufo.y - e.y) <= r) { + this.score += TUNE.UFO_SCORE; + this.emit('ufoDestroyed', { x: this.ufo.x, y: this.ufo.y, score: TUNE.UFO_SCORE }); + this.spawnDebris(this.ufo, { + count: TUNE.UFO_DEBRIS_COUNT, hDamping: TUNE.UFO_DEBRIS_H_DAMPING, gravity: TUNE.UFO_DEBRIS_GRAVITY, + }); + this.ufo = null; + } } if (this.waveSpawned >= this.waveQuota && this.enemyMissiles.length === 0 && this.interceptors.length === 0) { diff --git a/tools/verifyColoradoDefense.js b/tools/verifyColoradoDefense.js index 6378e18..2ff0dea 100644 --- a/tools/verifyColoradoDefense.js +++ b/tools/verifyColoradoDefense.js @@ -8,12 +8,14 @@ // 4. MIRV step-unlock threshold. // 4b. MIRV split-zone geometry (top-third once, top-third + halfway from wave 8). // 5. Scripted explosion/collision fixture. +// 5c. Bonus plane fixture (unlock wave, crossing speed, hit/score/debris). +// 5d. Bonus UFO fixture (unlock wave, weaving/shots, hit/score/debris). // 6. Palette-progression monotonicity. // 7. Wave-complete bonus breakdown (recap data) matches the score delta. // 8. Monte-carlo "always fire at nearest incoming missile" bot run. import { - BASE_SLOTS, CITY_SLOTS, GROUND_SLOTS, HEIGHT, slotX, mulberry32, pickCities, + BASE_SLOTS, CITY_SLOTS, GROUND_SLOTS, HEIGHT, WIDTH, GROUND_Y, slotX, mulberry32, pickCities, DEFAULT_CITIES, TUNE, spawnInterval, fallSpeed, waveQuota, mirvChance, PALETTES, paletteIndexForWave, createGame, step, pickFiringBase, fireInterceptor, lerpPos, explosionRadius, @@ -289,6 +291,159 @@ console.log('Chain-reaction fixture'); check('chain reaction bounded to one extra tier (no dangling pending chains)', sim.pendingChains.length === 0); } +// ── 5c. Bonus plane fixture ──────────────────────────────────────────────────── + +console.log('Bonus plane fixture'); +{ + // Locked before PLANE_WAVE, even given plenty of time. waveQuota pinned to + // Infinity so a real wave can never complete mid-test and silently bump + // `wave` past the threshold being tested (ordinary missile spawning is + // left running — it's irrelevant noise for these plane-only assertions). + const simLocked = createGame(DEFAULT_CITIES, 40); + simLocked.wave = TUNE.PLANE_WAVE - 1; + simLocked.waveQuota = Infinity; + for (let i = 0; i < Math.ceil((TUNE.PLANE_SPAWN_MAX_MS + 2000) / 16); i += 1) step(simLocked, 16); + check('no plane spawns before PLANE_WAVE', simLocked.plane === null); + + // Unlocked: spawns once its timer elapses. + const simSpawn = createGame(DEFAULT_CITIES, 41); + simSpawn.wave = TUNE.PLANE_WAVE; + simSpawn.nextPlaneAt = 100; // force a near-immediate spawn, deterministically + for (let i = 0; i < 20 && !simSpawn.plane; i += 1) step(simSpawn, 16); + check('plane spawns once its timer elapses at/after PLANE_WAVE', simSpawn.plane !== null); + if (simSpawn.plane) { + const { x, dir } = simSpawn.plane; + check('plane starts just off the correct edge for its direction', + (dir === 1 && x < 0) || (dir === -1 && x > WIDTH), `dir=${dir} x=${x}`); + check('plane speed crosses the full width in exactly PLANE_CROSS_MS', + Math.abs(simSpawn.plane.speed * TUNE.PLANE_CROSS_MS - WIDTH) < 1e-6); + } + + // Hit: a lethal explosion at the plane's position destroys it, scores + // PLANE_SCORE exactly, and spawns debris that keeps the plane's heading. + const simHit = createGame(DEFAULT_CITIES, 42); + simHit.wave = TUNE.PLANE_WAVE; + simHit.waveQuota = Infinity; + const planeY = HEIGHT * TUNE.PLANE_Y_FRAC; + const planeSpeed = WIDTH / TUNE.PLANE_CROSS_MS; + simHit.plane = { id: simHit.nextId++, x: 500, y: planeY, dir: 1, speed: planeSpeed, vx: planeSpeed, vy: 0 }; + const scoreBefore = simHit.score; + simHit.spawnExplosion(500, planeY, 'player'); + let planeDestroyedEvent = null; + for (let i = 0; i < 50 && !planeDestroyedEvent; i += 1) { + const events = step(simHit, 16); + planeDestroyedEvent = events.find((e) => e.type === 'planeDestroyed') || planeDestroyedEvent; + } + check('a lethal explosion at the plane destroys it', planeDestroyedEvent !== null); + check('plane kill awards exactly PLANE_SCORE', simHit.score === scoreBefore + TUNE.PLANE_SCORE, + `before=${scoreBefore} after=${simHit.score}`); + check('plane is cleared after being destroyed', simHit.plane === null); + const [dLo, dHi] = TUNE.PLANE_DEBRIS_COUNT; + check('debris count is within PLANE_DEBRIS_COUNT range', + simHit.debris.length >= dLo && simHit.debris.length <= dHi, `count=${simHit.debris.length}`); + check("debris keeps the plane's horizontal momentum (rightward here)", + simHit.debris.length > 0 && simHit.debris.every((p) => p.vx > 0)); + + // Debris eventually falls and clears, without exceeding the ground band. + let debrisSteps = 0; + let maxY = 0; + while (simHit.debris.length > 0 && debrisSteps < 3000) { + for (const p of simHit.debris) maxY = Math.max(maxY, p.y); + step(simHit, 16); + debrisSteps += 1; + } + check('debris eventually clears (lands) within a bounded number of steps', + simHit.debris.length === 0, `steps=${debrisSteps}`); + check('debris never falls past the ground band', maxY <= GROUND_Y + 40, `maxY=${maxY}`); +} + +// ── 5d. Bonus UFO fixture ─────────────────────────────────────────────────────── + +console.log('Bonus UFO fixture'); +{ + // Locked before UFO_WAVE, even given plenty of time. + const simLocked = createGame(DEFAULT_CITIES, 50); + simLocked.wave = TUNE.UFO_WAVE - 1; + simLocked.waveQuota = Infinity; + for (let i = 0; i < Math.ceil((TUNE.UFO_SPAWN_MAX_MS + 2000) / 16); i += 1) step(simLocked, 16); + check('no UFO spawns before UFO_WAVE', simLocked.ufo === null); + + // Unlocked: spawns with the right edge/speed, weaves within ±UFO_MAX_ANGLE_DEG, + // and fires exactly UFO_SHOTS MIRVs (3-4s apart) if left alive. + const simUfo = createGame(DEFAULT_CITIES, 51); + simUfo.wave = TUNE.UFO_WAVE; + simUfo.waveQuota = Infinity; + simUfo.nextUfoAt = 100; + for (let i = 0; i < 20 && !simUfo.ufo; i += 1) step(simUfo, 16); + check('UFO spawns once its timer elapses at/after UFO_WAVE', simUfo.ufo !== null); + + if (simUfo.ufo) { + const { x, dir, speed } = simUfo.ufo; + check('UFO starts just off the correct edge for its direction', + (dir === 1 && x < 0) || (dir === -1 && x > WIDTH), `dir=${dir} x=${x}`); + check('UFO speed crosses the full width in exactly UFO_CROSS_MS', + Math.abs(speed * TUNE.UFO_CROSS_MS - WIDTH) < 1e-6); + check('UFO turn count is within UFO_TURNS range', + simUfo.ufo.turnSchedule.length >= TUNE.UFO_TURNS[0] && simUfo.ufo.turnSchedule.length <= TUNE.UFO_TURNS[1], + `turns=${simUfo.ufo.turnSchedule.length}`); + + const maxVyAllowed = speed * Math.sin(TUNE.UFO_MAX_ANGLE_DEG * Math.PI / 180) + 1e-9; + let turnsSeen = 0; + let shots = 0; + const shotTimes = []; + let elapsed = 0; + let vyMaxSeen = 0; + for (let i = 0; i < 700 && simUfo.ufo; i += 1) { + const events = step(simUfo, 16); + elapsed += 16; + for (const ev of events) { + if (ev.type === 'ufoTurn') turnsSeen += 1; + if (ev.type === 'ufoShot') { shots += 1; shotTimes.push(elapsed); } + } + if (simUfo.ufo) vyMaxSeen = Math.max(vyMaxSeen, Math.abs(simUfo.ufo.vy)); + } + check('UFO performs its scheduled direction changes', + turnsSeen >= TUNE.UFO_TURNS[0], `turnsSeen=${turnsSeen}`); + check('UFO never exceeds the ±UFO_MAX_ANGLE_DEG heading', + vyMaxSeen <= maxVyAllowed, `vyMaxSeen=${vyMaxSeen} allowed=${maxVyAllowed}`); + check('UFO fires exactly UFO_SHOTS MIRV shots if left alive', shots === TUNE.UFO_SHOTS, `shots=${shots}`); + if (shotTimes.length === 2) { + const gap = shotTimes[1] - shotTimes[0]; + check('gap between the two shots is within the configured 3-4s window', + gap >= TUNE.UFO_SHOT_GAP_MIN_MS - 16 && gap <= TUNE.UFO_SHOT_GAP_MAX_MS + 16, `gap=${gap}`); + } + } + + // Hit: any lethal explosion at the UFO's position destroys it, scores + // UFO_SCORE exactly, and spawns debris that keeps its current heading. + const simHit = createGame(DEFAULT_CITIES, 52); + simHit.wave = TUNE.UFO_WAVE; + simHit.waveQuota = Infinity; + const ufoY = HEIGHT * 0.15; + const ufoSpeed = WIDTH / TUNE.UFO_CROSS_MS; + simHit.ufo = { + id: simHit.nextId++, x: 600, y: ufoY, dir: 1, speed: ufoSpeed, + vx: ufoSpeed * 0.9, vy: ufoSpeed * 0.3, // mid-weave heading + elapsed: 0, turnSchedule: [], turnIdx: 0, shotsLeft: 0, nextShotAt: 999999, shotGap: 3500, + }; + const ufoScoreBefore = simHit.score; + simHit.spawnExplosion(600, ufoY, 'player'); + let ufoDestroyedEvent = null; + for (let i = 0; i < 50 && !ufoDestroyedEvent; i += 1) { + const events = step(simHit, 16); + ufoDestroyedEvent = events.find((e) => e.type === 'ufoDestroyed') || ufoDestroyedEvent; + } + check('a lethal explosion at the UFO destroys it', ufoDestroyedEvent !== null); + check('UFO kill awards exactly UFO_SCORE', simHit.score === ufoScoreBefore + TUNE.UFO_SCORE, + `before=${ufoScoreBefore} after=${simHit.score}`); + check('UFO is cleared after being destroyed', simHit.ufo === null); + const [uLo, uHi] = TUNE.UFO_DEBRIS_COUNT; + check('UFO debris count is within UFO_DEBRIS_COUNT range', + simHit.debris.length >= uLo && simHit.debris.length <= uHi, `count=${simHit.debris.length}`); + check("UFO debris keeps its current heading's momentum", + simHit.debris.length > 0 && simHit.debris.every((p) => p.vx > 0)); +} + // ── 6. Palette progression ──────────────────────────────────────────────────── console.log('Palette progression'); @@ -369,7 +524,8 @@ console.log('Monte-carlo bot run'); if (aliveCities > prevCities) cityCountIncreased = true; prevCities = aliveCities; - for (const list of [sim.enemyMissiles, sim.interceptors, sim.explosions]) { + const planeList = sim.plane ? [sim.plane] : []; + for (const list of [sim.enemyMissiles, sim.interceptors, sim.explosions, planeList, sim.debris]) { for (const e of list) { if (Number.isNaN(e.x ?? e.fromX) || Number.isNaN(e.y ?? e.fromY)) nanFound = true; }