diff --git a/src/games/coloradodefense/ColoradoDefenseLogic.js b/src/games/coloradodefense/ColoradoDefenseLogic.js index 30dc0b9..aa268ed 100644 --- a/src/games/coloradodefense/ColoradoDefenseLogic.js +++ b/src/games/coloradodefense/ColoradoDefenseLogic.js @@ -55,9 +55,15 @@ 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_WAVE: 3, + MIRV_CHANCE_BASE: 0.05, MIRV_CHANCE_GROWTH: 0.025, MIRV_CHANCE_MAX: 0.4, MIRV_CHILDREN: [2, 3], + // Screen-space bands (fraction of GAME_HEIGHT) a MIRV missile splits + // within. Through wave 7 it splits once, in the top third; from wave 8 on + // the same missile splits again lower down, around the halfway mark. + MIRV_RESPLIT_WAVE: 8, + SPLIT_ZONE_1: [0.20, 0.333], + SPLIT_ZONE_2: [0.45, 0.55], BASE_AMMO: 10, BLAST_R_BASE: 90, BLAST_GROW_MS: 350, BLAST_HOLD_MS: 120, BLAST_FADE_MS: 200, INTERCEPTOR_SPEED: 900, @@ -100,6 +106,12 @@ export function paletteIndexForWave(wave) { function lerp(a, b, t) { return a + (b - a) * t; } +// A random screen-space Y within a [loFrac, hiFrac] band of GAME_HEIGHT. +function pickSplitY(rng, zone) { + const [lo, hi] = zone; + return HEIGHT * (lo + rng() * (hi - lo)); +} + // 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); @@ -168,11 +180,16 @@ 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); - const kind = fromOverride ? 'single' : (this.rng() < mirvChance(this.wave) ? 'mirv' : 'single'); + // 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 kind = isMirv ? 'mirv' : 'single'; + const splitsLeft = isMirv ? (this.wave >= TUNE.MIRV_RESPLIT_WAVE ? 2 : 1) : 0; 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, + t: 0, dur, kind, splitsLeft, + splitY: splitsLeft > 0 ? pickSplitY(this.rng, TUNE.SPLIT_ZONE_1) : null, }; this.enemyMissiles.push(missile); return missile; @@ -258,12 +275,18 @@ export class Sim { } // Advance enemy missiles; collect MIRV splits without mutating mid-loop. + // A MIRV keeps flying toward its own target after each split; once + // MIRV_RESPLIT_WAVE is reached it gets a second split lower down. 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 (m.kind === 'mirv' && m.splitsLeft > 0 && m.t < 1) { + const pos = lerpPos(m); + if (pos.y >= m.splitY) { + m.splitsLeft -= 1; + newChildren.push(...this.makeMirvChildren(m)); + m.splitY = m.splitsLeft > 0 ? pickSplitY(this.rng, TUNE.SPLIT_ZONE_2) : null; + } } } if (newChildren.length) this.enemyMissiles.push(...newChildren); diff --git a/tools/verifyColoradoDefense.js b/tools/verifyColoradoDefense.js index 4d95d30..6378e18 100644 --- a/tools/verifyColoradoDefense.js +++ b/tools/verifyColoradoDefense.js @@ -6,13 +6,14 @@ // 2. City-pool draw correctness + malformed-pool fallback. // 3. Difficulty escalation monotonicity across waves 1-40 (bounded). // 4. MIRV step-unlock threshold. +// 4b. MIRV split-zone geometry (top-third once, top-third + halfway from wave 8). // 5. Scripted explosion/collision fixture. // 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, slotX, mulberry32, pickCities, + BASE_SLOTS, CITY_SLOTS, GROUND_SLOTS, HEIGHT, slotX, mulberry32, pickCities, DEFAULT_CITIES, TUNE, spawnInterval, fallSpeed, waveQuota, mirvChance, PALETTES, paletteIndexForWave, createGame, step, pickFiringBase, fireInterceptor, lerpPos, explosionRadius, @@ -98,6 +99,88 @@ console.log('MIRV step-unlock'); check('mirvChance bounded at max', mirvChance(999) <= TUNE.MIRV_CHANCE_MAX); } +// ── 4b. MIRV split-zone geometry ──────────────────────────────────────────────── + +console.log('MIRV split-zone geometry'); +{ + // spawnEnemyMissile() itself: force isMirv=true (rng()=0 < any nonzero + // mirvChance) and check the resulting splitsLeft/splitY at both tiers. + const spawnLow = createGame(DEFAULT_CITIES, 3); + spawnLow.wave = TUNE.MIRV_RESPLIT_WAVE - 1; + spawnLow.rng = () => 0; + const missileLow = spawnLow.spawnEnemyMissile(); + check('low-wave spawn becomes a MIRV with splitsLeft=1', + missileLow.kind === 'mirv' && missileLow.splitsLeft === 1); + check('low-wave MIRV splitY lands in SPLIT_ZONE_1', + missileLow.splitY >= HEIGHT * TUNE.SPLIT_ZONE_1[0] && missileLow.splitY <= HEIGHT * TUNE.SPLIT_ZONE_1[1], + `splitY=${missileLow.splitY}`); + + const spawnHigh = createGame(DEFAULT_CITIES, 3); + spawnHigh.wave = TUNE.MIRV_RESPLIT_WAVE; + spawnHigh.rng = () => 0; + const missileHigh = spawnHigh.spawnEnemyMissile(); + check('wave>=MIRV_RESPLIT_WAVE spawn becomes a MIRV with splitsLeft=2', + missileHigh.kind === 'mirv' && missileHigh.splitsLeft === 2); + check('its first splitY still lands in SPLIT_ZONE_1', + missileHigh.splitY >= HEIGHT * TUNE.SPLIT_ZONE_1[0] && missileHigh.splitY <= HEIGHT * TUNE.SPLIT_ZONE_1[1], + `splitY=${missileHigh.splitY}`); + + // Full flight, low wave: exactly one split, and the children never re-split. + // (waveSpawned pinned to waveQuota so the sim's own spawn timer can't add + // unrelated missiles that would pollute the length-based split count.) + const simLow = createGame(DEFAULT_CITIES, 5); + simLow.wave = TUNE.MIRV_RESPLIT_WAVE - 1; + simLow.waveSpawned = simLow.waveQuota; + const t1 = simLow.cities[0]; + simLow.enemyMissiles.push({ + id: simLow.nextId++, fromX: t1.x, fromY: 0, toX: t1.x, toY: t1.y, + targetSlot: t1.slot, targetKind: 'city', t: 0, dur: 5000, kind: 'mirv', + splitsLeft: 1, splitY: HEIGHT * 0.25, + }); + let splitCountLow = 0; + let childKinds = []; + for (let i = 0; i < 250; i += 1) { + const before = simLow.enemyMissiles.length; + step(simLow, 16); + if (simLow.enemyMissiles.length > before) { + splitCountLow += 1; + childKinds = simLow.enemyMissiles.filter((m) => m.kind !== 'mirv').map((m) => m.kind); + } + } + check('low-wave MIRV splits exactly once over its full flight', splitCountLow === 1, `splitCount=${splitCountLow}`); + check('its children never re-split', childKinds.length > 0 && childKinds.every((k) => k === 'single')); + + // Full flight, wave >= MIRV_RESPLIT_WAVE: splits twice, second time in zone 2. + const simHigh = createGame(DEFAULT_CITIES, 6); + simHigh.wave = TUNE.MIRV_RESPLIT_WAVE; + simHigh.waveSpawned = simHigh.waveQuota; + const t2 = simHigh.cities[0]; + simHigh.enemyMissiles.push({ + id: simHigh.nextId++, fromX: t2.x, fromY: 0, toX: t2.x, toY: t2.y, + targetSlot: t2.slot, targetKind: 'city', t: 0, dur: 5000, kind: 'mirv', + splitsLeft: 2, splitY: HEIGHT * 0.25, + }); + let splitEvents = 0; + let sawZone2SplitY = false; + // Stops well before the trunk's own ~5000ms impact — once it lands and + // completeWave() fires, waveSpawned resets and the spawn timer could add + // unrelated missiles again, polluting the length-based split count. + for (let i = 0; i < 280; i += 1) { + const before = simHigh.enemyMissiles.length; + step(simHigh, 16); + if (simHigh.enemyMissiles.length > before) { + splitEvents += 1; + const trunk = simHigh.enemyMissiles.find((m) => m.kind === 'mirv'); + if (trunk && trunk.splitY !== null) { + const frac = trunk.splitY / HEIGHT; + if (frac >= TUNE.SPLIT_ZONE_2[0] && frac <= TUNE.SPLIT_ZONE_2[1]) sawZone2SplitY = true; + } + } + } + check('wave>=MIRV_RESPLIT_WAVE MIRV splits exactly twice', splitEvents === 2, `splitEvents=${splitEvents}`); + check('its second split is scheduled in SPLIT_ZONE_2', sawZone2SplitY); +} + // ── 5. Explosion/collision fixture ──────────────────────────────────────────── console.log('Explosion/collision fixture'); @@ -108,7 +191,7 @@ console.log('Explosion/collision fixture'); // 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, + targetSlot: target.slot, targetKind: 'city', t: 0.5, dur: 60000, kind: 'single', splitsLeft: 0, }); const missilePos = lerpPos(sim.enemyMissiles[0]); const base = pickFiringBase(sim, missilePos.x, missilePos.y); @@ -127,7 +210,7 @@ console.log('Explosion/collision fixture'); 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, + targetSlot: target2.slot, targetKind: 'city', t: 0.5, dur: 4000, kind: 'single', splitsLeft: 0, }); const far = lerpPos(sim2.enemyMissiles[0]); fireInterceptor(sim2, sim2.bases[1], far.x + 5000, far.y); @@ -159,7 +242,7 @@ console.log('Chain-reaction fixture'); const target = sim.cities[0]; 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, + targetSlot: target.slot, targetKind: 'city', t: 0.5, dur: 60000, kind: 'single', splitsLeft: 0, }); const primaryPos = lerpPos(sim.enemyMissiles[0]); const base = pickFiringBase(sim, primaryPos.x, primaryPos.y); @@ -192,7 +275,7 @@ console.log('Chain-reaction fixture'); if (!secondaryInjected && primaryKillElapsed !== null && elapsed >= primaryKillElapsed + 1000) { sim.enemyMissiles.push({ id: sim.nextId++, fromX: primaryPos.x - 50, fromY: primaryPos.y, toX: primaryPos.x - 50, toY: primaryPos.y, - targetSlot: sim.cities[1].slot, targetKind: 'city', t: 0.5, dur: 60000, kind: 'single', splitAt: 2, splitDone: true, + targetSlot: sim.cities[1].slot, targetKind: 'city', t: 0.5, dur: 60000, kind: 'single', splitsLeft: 0, }); secondaryInjected = true; }