diff --git a/assets/images/tetrisattack/background-michael-r1.png b/assets/images/tetrisattack/background-michael-r1.png new file mode 100644 index 0000000..64a4bbb Binary files /dev/null and b/assets/images/tetrisattack/background-michael-r1.png differ diff --git a/assets/images/tetrisattack/background-michael-r2.png b/assets/images/tetrisattack/background-michael-r2.png new file mode 100644 index 0000000..2334669 Binary files /dev/null and b/assets/images/tetrisattack/background-michael-r2.png differ diff --git a/assets/images/tetrisattack/background-michael-r3.png b/assets/images/tetrisattack/background-michael-r3.png new file mode 100644 index 0000000..fa11675 Binary files /dev/null and b/assets/images/tetrisattack/background-michael-r3.png differ diff --git a/assets/images/tetrisattack/background-michael-r4.png b/assets/images/tetrisattack/background-michael-r4.png new file mode 100644 index 0000000..43ef2cb Binary files /dev/null and b/assets/images/tetrisattack/background-michael-r4.png differ diff --git a/assets/images/tetrisattack/background-michael-r5.png b/assets/images/tetrisattack/background-michael-r5.png new file mode 100644 index 0000000..6dba9d4 Binary files /dev/null and b/assets/images/tetrisattack/background-michael-r5.png differ diff --git a/assets/images/tetrisattack/tetrisattack-characters.png b/assets/images/tetrisattack/tetrisattack-characters.png index 3acb7c9..b936e2c 100644 Binary files a/assets/images/tetrisattack/tetrisattack-characters.png and b/assets/images/tetrisattack/tetrisattack-characters.png differ diff --git a/assets/images/tetrisattack/tetrisattack-characters.psd b/assets/images/tetrisattack/tetrisattack-characters.psd index 04f21e2..fbd30d0 100644 Binary files a/assets/images/tetrisattack/tetrisattack-characters.psd and b/assets/images/tetrisattack/tetrisattack-characters.psd differ diff --git a/src/games/tempest/TempestGame.js b/src/games/tempest/TempestGame.js index d3f1200..f21083d 100644 --- a/src/games/tempest/TempestGame.js +++ b/src/games/tempest/TempestGame.js @@ -383,7 +383,11 @@ export default class TempestGame extends Phaser.Scene { case 'warpStart': { playSound(this, SFX.SCIFI_RISER); const spiked = this.sim.spikes.some((tip) => tip < 1); - this.showBanner(spiked ? 'AVOID SPIKES' : 'SUPERZAPPER RECHARGE', this.band.lane, 1500); + // The zapper only regains one charge per level, so only promise it + // when there is actually room for it. + const zapBack = this.sim.zapper.uses < TUNE.ZAPPER_USES ? 'SUPERZAPPER +1' : null; + const banner = spiked ? 'AVOID SPIKES' : zapBack; + if (banner) this.showBanner(banner, this.band.lane, 1500); break; } case 'warpDone': { diff --git a/src/games/tempest/TempestLogic.js b/src/games/tempest/TempestLogic.js index acdb039..fbefa51 100644 --- a/src/games/tempest/TempestLogic.js +++ b/src/games/tempest/TempestLogic.js @@ -238,81 +238,107 @@ export const TUNE = { LIVES: 3, EXTRA_LIFE_EVERY: 20000, - MOVE_LANES_PER_SEC: 14, - FIRE_COOLDOWN_MS: 110, - MAX_SHOTS: 8, // arcade cap on live player shots + MOVE_LANES_PER_SEC: 10, + FIRE_COOLDOWN_MS: 150, + MAX_SHOTS: 4, // live player shots — few enough that aim matters SHOT_SPEED: 2.3, // t units per second, rim -> pit // Enemy climb speeds in t/sec (positive = toward the rim) and growth. - FLIPPER_SPEED: 0.16, FLIPPER_SPEED_GROWTH: 0.006, FLIPPER_SPEED_MAX: 0.42, + // The flipper's ramp is a soft cap (see `ramp`) plus a slow tail, so deep + // levels keep getting faster instead of flat-lining; HARD_MAX keeps it well + // under SHOT_SPEED so a climbing flipper always stays shootable. + FLIPPER_SPEED: 0.16, FLIPPER_SPEED_RATE: 0.05, FLIPPER_SPEED_CAP: 0.52, + FLIPPER_SPEED_TAIL: 0.0015, FLIPPER_SPEED_HARD_MAX: 1.0, TANKER_SPEED: 0.10, SPIKER_SPEED: 0.22, - FUSEBALL_SPEED: 0.30, - PULSAR_SPEED: 0.14, + FUSEBALL_SPEED: 0.50, + PULSAR_SPEED: 0.22, - // Rim flippers hunt the player: rest between hops, then a hop that can be - // shot mid-flight. Flippers also flip lane-to-lane while still climbing - // (their arcade signature) — chance ramps up and cooldown shortens with - // level so early levels stay gentle and later ones get aggressive. - FLIP_DUR_MS: 260, - FLIP_REST_MS: 520, FLIP_REST_DECAY: 8, FLIP_REST_MIN_MS: 220, - FLIP_CLIMB_CHANCE_BASE: 0.3, FLIP_CLIMB_CHANCE_GROWTH: 0.01, FLIP_CLIMB_CHANCE_MAX: 0.55, - FLIP_CLIMB_COOLDOWN_MS: 1100, FLIP_CLIMB_COOLDOWN_DECAY: 20, FLIP_CLIMB_COOLDOWN_MIN_MS: 600, + // Rim flippers hunt the player: they chain hops toward the claw without + // resting while it is more than a lane away, and a hop that lands on the + // player's lane is a grab. Flippers also flip lane-to-lane while still + // climbing (their arcade signature) — chance ramps up and cooldown shortens + // with level. + FLIP_DUR_MS: 150, + FLIP_REST_MS: 260, FLIP_REST_DECAY: 10, FLIP_REST_MIN_MS: 90, + FLIP_CLIMB_CHANCE_BASE: 0.55, FLIP_CLIMB_CHANCE_GROWTH: 0.01, FLIP_CLIMB_CHANCE_MAX: 0.92, + FLIP_CLIMB_COOLDOWN_MS: 650, FLIP_CLIMB_COOLDOWN_DECAY: 25, FLIP_CLIMB_COOLDOWN_MIN_MS: 260, SPIKE_MIN_T: 0.22, // how close to the rim a spike can grow SPIKE_TRIM: 0.06, // how much one shot shaves off a spike tip SPIKE_POINTS: 1, FUSEBALL_MOVE_MS: [500, 1100], - FUSEBALL_PAUSE_MS: [350, 900], - FUSEBALL_EDGE_HOP_CHANCE: 0.35, + FUSEBALL_PAUSE_MS: [200, 500], + FUSEBALL_EDGE_HOP_CHANCE: 0.5, + FUSEBALL_CLIMB_BIAS: 0.85, // chance a move phase heads for the rim FUSEBALL_CONTACT_T: 0.05, - PULSAR_PERIOD_MS: 3200, PULSAR_ACTIVE_MS: 700, - PULSAR_LETHAL_T: 0.18, // a pulse only kills when the pulsar is near the rim + PULSAR_PERIOD_MS: 2400, PULSAR_ACTIVE_MS: 1000, + PULSAR_LETHAL_T: 0.30, // a pulse only kills when the pulsar is near the rim PULSAR_TURN_T: 0.10, // patrol: climb to here, retreat, climb again PULSAR_RETREAT_T: 0.72, - ENEMY_SHOT_SPEED: 0.34, ENEMY_SHOT_SPEED_GROWTH: 0.012, ENEMY_SHOT_SPEED_MAX: 0.62, - ENEMY_FIRE_PER_SEC: 0.16, ENEMY_FIRE_GROWTH: 0.012, ENEMY_FIRE_MAX: 0.55, - MAX_ENEMY_SHOTS: 8, + ENEMY_SHOT_SPEED: 0.70, ENEMY_SHOT_SPEED_RATE: 0.05, ENEMY_SHOT_SPEED_CAP: 1.25, + ENEMY_SHOT_SPEED_TAIL: 0.002, ENEMY_SHOT_SPEED_HARD_MAX: 1.9, + ENEMY_FIRE_PER_SEC: 0.50, ENEMY_FIRE_RATE: 0.05, ENEMY_FIRE_CAP: 1.6, + ENEMY_FIRE_TAIL: 0.004, ENEMY_FIRE_HARD_MAX: 4.0, + ENEMY_SHOT_HIT_LANES: 0.6, // lane distance at which a rim shot connects + MAX_ENEMY_SHOTS: 14, // Spawn scheduling: per-level budget, concurrency cap, and cadence. - BUDGET_BASE: 10, BUDGET_GROWTH: 2.0, BUDGET_MAX: 40, - CONCURRENT_BASE: 4, CONCURRENT_MAX: 10, - SPAWN_MS_BASE: 1500, SPAWN_MS_DECAY: 0.97, SPAWN_MS_MIN: 480, + BUDGET_BASE: 14, BUDGET_RATE: 0.05, BUDGET_CAP: 46, BUDGET_TAIL: 0.35, BUDGET_HARD_MAX: 140, + CONCURRENT_BASE: 6, CONCURRENT_PER_LEVEL: 1 / 1.5, CONCURRENT_MAX: 16, + SPAWN_MS_BASE: 1100, SPAWN_MS_DECAY: 0.955, SPAWN_MS_MIN: 220, // Type unlock levels and relative spawn weights once unlocked. - UNLOCK: { flipper: 1, tanker: 3, spiker: 4, fuseball: 11, pulsar: 17 }, - WEIGHT: { flipper: 1.0, tanker: 0.4, spiker: 0.35, fuseball: 0.3, pulsar: 0.35 }, + UNLOCK: { flipper: 1, tanker: 2, spiker: 3, fuseball: 6, pulsar: 9 }, + WEIGHT: { flipper: 1.0, tanker: 0.5, spiker: 0.35, fuseball: 0.45, pulsar: 0.5 }, POINTS: { flipper: 150, tanker: 100, spiker: 50, pulsar: 200 }, FUSEBALL_POINTS: [250, 500, 750], // by proximity to the rim when killed - ZAPPER_USES: 2, + ZAPPER_USES: 2, // cap; a cleared level grants ZAPPER_REFILL back + ZAPPER_REFILL: 1, ZAP_CASCADE_MS: 40, DEATH_MS: 1300, - RESPITE_MS: 900, + RESPITE_MS: 600, + DEATH_CLEAR_T: 0.25, // only enemies this close to the rim die with you WARP_MS: 2600, START_BONUS_SCALE: 700, // start bonus ~ (level-1)^2 * this, rounded }; +// Approach `cap` quickly, then keep creeping by `tail` per level so endless +// play never flat-lines the way a hard Math.min clamp does. `hardMax` is a +// sanity ceiling, reached only hundreds of levels deep. +export function ramp(base, rate, cap, tail, hardMax, level) { + const l = Math.max(0, level - 1); + const soft = cap - (cap - base) * Math.exp(-rate * l); + return Math.min(hardMax, soft + tail * l); +} + export function flipperSpeed(level) { - return Math.min(TUNE.FLIPPER_SPEED_MAX, TUNE.FLIPPER_SPEED + TUNE.FLIPPER_SPEED_GROWTH * (level - 1)); + return ramp(TUNE.FLIPPER_SPEED, TUNE.FLIPPER_SPEED_RATE, TUNE.FLIPPER_SPEED_CAP, + TUNE.FLIPPER_SPEED_TAIL, TUNE.FLIPPER_SPEED_HARD_MAX, level); } export function enemyShotSpeed(level) { - return Math.min(TUNE.ENEMY_SHOT_SPEED_MAX, TUNE.ENEMY_SHOT_SPEED + TUNE.ENEMY_SHOT_SPEED_GROWTH * (level - 1)); + return ramp(TUNE.ENEMY_SHOT_SPEED, TUNE.ENEMY_SHOT_SPEED_RATE, TUNE.ENEMY_SHOT_SPEED_CAP, + TUNE.ENEMY_SHOT_SPEED_TAIL, TUNE.ENEMY_SHOT_SPEED_HARD_MAX, level); } export function enemyFireRate(level) { - return Math.min(TUNE.ENEMY_FIRE_MAX, TUNE.ENEMY_FIRE_PER_SEC + TUNE.ENEMY_FIRE_GROWTH * (level - 1)); + return ramp(TUNE.ENEMY_FIRE_PER_SEC, TUNE.ENEMY_FIRE_RATE, TUNE.ENEMY_FIRE_CAP, + TUNE.ENEMY_FIRE_TAIL, TUNE.ENEMY_FIRE_HARD_MAX, level); } export function levelBudget(level) { - return Math.min(TUNE.BUDGET_MAX, Math.round(TUNE.BUDGET_BASE + TUNE.BUDGET_GROWTH * (level - 1))); + return Math.round(ramp(TUNE.BUDGET_BASE, TUNE.BUDGET_RATE, TUNE.BUDGET_CAP, + TUNE.BUDGET_TAIL, TUNE.BUDGET_HARD_MAX, level)); } +// Capped for real: the scene redraws every live enemy as vector art each frame. export function maxConcurrent(level) { - return Math.min(TUNE.CONCURRENT_MAX, TUNE.CONCURRENT_BASE + Math.floor(level / 2)); + return Math.min(TUNE.CONCURRENT_MAX, + TUNE.CONCURRENT_BASE + Math.floor(level * TUNE.CONCURRENT_PER_LEVEL)); } export function spawnInterval(level) { return Math.max(TUNE.SPAWN_MS_MIN, TUNE.SPAWN_MS_BASE * TUNE.SPAWN_MS_DECAY ** (level - 1)); @@ -412,7 +438,14 @@ export class Sim { this.enemyShots = []; this.enemies = []; this.spikes = new Array(n).fill(1); // spike tip t per lane; 1 = no spike - this.zapper = { uses: TUNE.ZAPPER_USES, cascade: [], cascadeMs: 0 }; + // The zapper is NOT refilled per level — surviving charge carries over and + // a cleared level hands back one, so burning both is a real cost. + if (!this.zapper) this.zapper = { uses: TUNE.ZAPPER_USES, cascade: [], cascadeMs: 0 }; + else { + this.zapper.uses = Math.min(TUNE.ZAPPER_USES, this.zapper.uses + TUNE.ZAPPER_REFILL); + this.zapper.cascade = []; + this.zapper.cascadeMs = 0; + } this.spawn = { remaining: this.buildBudget(level), timerMs: 600 }; this.warpT = 0; } @@ -605,6 +638,18 @@ export class Sim { return e.lane; } + // Unsigned lane gap between a lane index and the claw's fractional position, + // taking the short way around a closed web. + laneDistanceToPlayer(lane) { + const n = laneCount(this.web); + let d = lane - this.player.pos; + if (this.web.closed) { + d = ((d % n) + n) % n; + if (d > n / 2) d -= n; + } + return Math.abs(d); + } + adjacentLaneToward(fromLane, targetLane) { const n = laneCount(this.web); if (this.web.closed) { @@ -650,11 +695,23 @@ export class Sim { if (e.flipMs >= TUNE.FLIP_DUR_MS) { e.lane = e.flipTo; e.state = e.flipReturn; - if (e.state === 'rest') e.restMs = flipRest(this.level); + // A rim flipper chains hops without resting while the claw is still + // more than a lane away — this is what makes it a pursuer rather than + // a shuffler the player can simply outrun. + if (e.state === 'rest') { + const to = this.adjacentLaneToward(e.lane, this.playerLane()); + if (to !== e.lane) { + e.state = 'flip'; e.flipFrom = e.lane; e.flipTo = to; e.flipMs = 0; + } else { + e.restMs = flipRest(this.level); + } + } } } - // A rim flipper sharing the player's lane is a grab — instant death. - if (e.t <= 0.02 && e.state !== 'climb' && this.visualLane(e) === this.playerLane() && e.state !== 'flip') { + // A rim flipper sharing the player's lane is a grab — instant death. A hop + // that lands on the claw counts: past the halfway point of a flip + // `visualLane` already reports the destination lane. + if (e.t <= 0.02 && e.state !== 'climb' && this.visualLane(e) === this.playerLane()) { this.killPlayer('grabbed'); } } @@ -695,7 +752,7 @@ export class Sim { e.state = 'move'; e.phaseMs = this.randRange(TUNE.FUSEBALL_MOVE_MS); // Mostly climbs, sometimes dives back down, sometimes hops an edge. - e.dir = this.rng() < 0.72 ? -1 : 1; + e.dir = this.rng() < TUNE.FUSEBALL_CLIMB_BIAS ? -1 : 1; if (this.rng() < TUNE.FUSEBALL_EDGE_HOP_CHANCE) { const edges = this.edgeCount(); let edge = e.edge + (this.rng() < 0.5 ? -1 : 1); @@ -727,8 +784,8 @@ export class Sim { } enemyFireTick(e, dt) { - if (e.type !== 'flipper' && e.type !== 'tanker') return; - if (e.t < 0.12 || e.t > 0.92) return; // only while riding the well + if (e.type === 'fuseball') return; // fuseballs ram, they don't shoot + if (e.t < 0.03 || e.t > 0.98) return; // only while riding the well if (this.enemyShots.length >= TUNE.MAX_ENEMY_SHOTS) return; if (this.rng() < enemyFireRate(this.level) * (dt / 1000)) { this.enemyShots.push({ lane: this.visualLane(e), t: e.t, prevT: e.t }); @@ -736,6 +793,20 @@ export class Sim { } } + // Advance both sides' shots. A player shot that reaches the pit is pinned + // there and flagged spent: it still gets its final collision pass in + // resolveShots, which then drops it. Pinning rather than letting it overshoot + // matters because a death cuts the tick short before resolveShots runs, which + // would otherwise strand shots far past t=1. + advanceShots(dt) { + for (const shot of this.shots) { + shot.prevT = shot.t; + shot.t += TUNE.SHOT_SPEED * (dt / 1000); + if (shot.t >= 1) { shot.t = 1; shot.spent = true; } + } + for (const es of this.enemyShots) { es.prevT = es.t; es.t -= enemyShotSpeed(this.level) * (dt / 1000); } + } + // -- collisions ----------------------------------------------------------- crossed(a, b) { @@ -787,7 +858,7 @@ export class Sim { this.emit('spikeTrimmed', { lane: shot.lane, t: tip }); } } - this.shots = this.shots.filter((s) => !s.dead && s.t < 1); + this.shots = this.shots.filter((s) => !s.dead && !s.spent); this.enemyShots = this.enemyShots.filter((s) => !s.dead); } @@ -823,13 +894,11 @@ export class Sim { this.emit('warpStart', { level: this.level, refly: true }); return; } - // Survivors sink back into the pit and rejoin the spawn queue; the - // fight resumes where it left off (zapper charges are NOT refilled). - for (const e of this.enemies) { - const key = e.type; - this.spawn.remaining[key] += 1; - } - this.enemies = []; + // Dying is not a reset. Only the enemies that actually cornered the claw + // go up with it — the rest of the wave stays on the web mid-climb, and + // none of them are refunded into the spawn budget. Zapper charges are NOT + // refilled either. + this.enemies = this.enemies.filter((e) => e.t >= TUNE.DEATH_CLEAR_T); this.enemyShots = []; this.shots = []; this.phase = 'respite'; @@ -854,7 +923,7 @@ export class Sim { this.warpT = Math.min(1, this.warpT + dt / TUNE.WARP_MS); this.movePlayer(dt); this.fireTick(dt); - for (const shot of this.shots) { shot.prevT = shot.t; shot.t += TUNE.SHOT_SPEED * (dt / 1000); } + this.advanceShots(dt); // Shots ahead of the diving player shave spikes down. for (const shot of this.shots) { const tip = this.spikes[shot.lane]; @@ -865,7 +934,7 @@ export class Sim { this.emit('spikeTrimmed', { lane: shot.lane, t: tip }); } } - this.shots = this.shots.filter((s) => !s.dead && s.t < 1); + this.shots = this.shots.filter((s) => !s.dead && !s.spent); // Flying into a spike costs a life and the warp restarts. const tip = this.spikes[this.playerLane()]; if (tip < 1 && prev < tip && this.warpT >= tip) { @@ -931,8 +1000,7 @@ export class Sim { } } - for (const shot of this.shots) { shot.prevT = shot.t; shot.t += TUNE.SHOT_SPEED * (dt / 1000); } - for (const es of this.enemyShots) { es.prevT = es.t; es.t -= enemyShotSpeed(this.level) * (dt / 1000); } + this.advanceShots(dt); for (const e of this.enemies.slice()) { if (e.dead) continue; @@ -949,10 +1017,11 @@ export class Sim { this.resolveShots(); if (this.phase !== 'playing') return this.events; - // Enemy shots that reach the rim in the player's lane connect. + // Enemy shots that reach the rim near the claw connect — a shot grazing + // the lane boundary counts, so half-a-lane dodges are not free. for (const es of this.enemyShots) { if (es.t <= 0) { - if (es.lane === this.playerLane()) { + if (this.laneDistanceToPlayer(es.lane) < TUNE.ENEMY_SHOT_HIT_LANES) { this.killPlayer('shot'); break; } diff --git a/tools/verifyTempest.js b/tools/verifyTempest.js index de84266..b4d3160 100644 --- a/tools/verifyTempest.js +++ b/tools/verifyTempest.js @@ -21,7 +21,7 @@ import { laneCount, webForLevel, rimPoint, depthScale, makeProjector, aimLaneForAngle, bandIndexForLevel, startBonus, startLevelOptions, flipperSpeed, enemyShotSpeed, enemyFireRate, levelBudget, maxConcurrent, - spawnInterval, flipRest, createGame, step, setAim, setFiring, superzap, + spawnInterval, flipRest, flipClimbCooldown, createGame, step, setAim, setFiring, superzap, } from '../src/games/tempest/TempestLogic.js'; let failures = 0; @@ -333,13 +333,17 @@ console.log('Enemy fixtures'); sim9.spawn.remaining = { flipper: 0, tanker: 0, spiker: 0, fuseball: 0, pulsar: 0 }; const deep = sim9.makeEnemy('pulsar', sim9.playerLane(), 0.6); sim9.enemies.push(deep); - let deepDeath = false; + let deepPulseDeath = false; let deepFired = false; for (let i = 0; i < 60 * 8; i += 1) { deep.t = 0.6; deep.dir = 1; // pin it deep in the well - for (const e of step(sim9, DT)) if (e.type === 'playerHit') deepDeath = true; + for (const e of step(sim9, DT)) { + if (e.type === 'playerHit' && e.cause === 'pulsar') deepPulseDeath = true; + if (e.type === 'enemyShotFired') deepFired = true; + } if (sim9.phase !== 'playing') break; } - check('pulsar deep in the well is harmless', !deepDeath); + check('a pulsar\'s pulse is harmless deep in the well', !deepPulseDeath); + check('but a deep pulsar still shoots', deepFired); } // ── 7. Superzapper ─────────────────────────────────────────────────────────── @@ -374,8 +378,17 @@ console.log('Superzapper'); step(sim, DT); check('empty zapper does nothing', sim.enemies.length === 1); + // A cleared level hands back one charge, not a full refill — burning both + // has to cost something. sim.enterLevel(sim.level + 1); - check('zapper recharges on a new level', sim.zapper.uses === TUNE.ZAPPER_USES); + check('a new level grants exactly one zapper charge', + sim.zapper.uses === TUNE.ZAPPER_REFILL, `uses=${sim.zapper.uses}`); + sim.enterLevel(sim.level + 1); + check('zapper charge caps at ZAPPER_USES', + sim.zapper.uses === TUNE.ZAPPER_USES, `uses=${sim.zapper.uses}`); + sim.enterLevel(sim.level + 1); + check('zapper does not exceed its cap', + sim.zapper.uses === TUNE.ZAPPER_USES, `uses=${sim.zapper.uses}`); } // ── 8. Scoring, extra lives, skill step ────────────────────────────────────── @@ -455,11 +468,13 @@ console.log('Level flow'); check('flying into a spike costs a life', spiked && sim2.lives < livesBefore); check('spike death re-flies the warp', reflew); - // Death and respite: survivors rejoin the spawn budget. + // Death is not a reset: only the enemies that cornered the claw go up with + // it, the rest of the wave stays on the web, and nothing is refunded. const sim3 = createGame({ seed: 53 }); sim3.spawn.remaining = { flipper: 0, tanker: 0, spiker: 0, fuseball: 0, pulsar: 0 }; sim3.enemies.push(sim3.makeEnemy('flipper', (sim3.playerLane() + 5) % LANES, 0.5)); sim3.enemies.push(sim3.makeEnemy('spiker', (sim3.playerLane() + 8) % LANES, 0.9)); + sim3.enemies.push(sim3.makeEnemy('flipper', (sim3.playerLane() + 2) % LANES, 0.1)); sim3.killPlayer('shot'); check('death costs a life', sim3.lives === TUNE.LIVES - 1); let respited = false; @@ -468,8 +483,13 @@ console.log('Level flow'); if (sim3.phase === 'playing') break; } check('respite follows a death with lives left', respited && sim3.phase === 'playing'); - check('survivors rejoined the spawn budget', - sim3.spawn.remaining.flipper === 1 && sim3.spawn.remaining.spiker === 1 && sim3.enemies.length === 0); + check('deep enemies survive the player\'s death', + sim3.enemies.length === 2 && sim3.enemies.every((e) => e.t >= TUNE.DEATH_CLEAR_T), + `left=${sim3.enemies.length}`); + check('the enemies that cornered the claw are cleared', + !sim3.enemies.some((e) => e.t < TUNE.DEATH_CLEAR_T)); + check('death does not refund the spawn budget', sim3.budgetTotal() === 0, + `budget=${sim3.budgetTotal()}`); // Game over at zero lives. const sim4 = createGame({ seed: 54 }); @@ -497,11 +517,30 @@ console.log('Difficulty escalation (levels 1-60)'); if (flipRest(l) > flipRest(l - 1)) ok = false; } check('all difficulty curves monotonic', ok); - check('curves stay bounded', - flipperSpeed(999) <= TUNE.FLIPPER_SPEED_MAX - && levelBudget(999) <= TUNE.BUDGET_MAX - && spawnInterval(999) >= TUNE.SPAWN_MS_MIN - && flipRest(999) >= TUNE.FLIP_REST_MIN_MS); + + // Timings genuinely cannot fall to zero, so they keep hard floors. + check('timing curves respect their floors', + spawnInterval(999) >= TUNE.SPAWN_MS_MIN + && flipRest(999) >= TUNE.FLIP_REST_MIN_MS + && flipClimbCooldown(999) >= TUNE.FLIP_CLIMB_COOLDOWN_MIN_MS); + + // The growth curves must NOT plateau — endless play has to keep ramping + // instead of replaying level 44 forever. + check('growth curves still rise deep into an endless run', + flipperSpeed(200) > flipperSpeed(100) + && enemyShotSpeed(200) > enemyShotSpeed(100) + && enemyFireRate(200) > enemyFireRate(100) + && levelBudget(200) > levelBudget(100)); + + // ...but stay sane: a flipper must always be slower than a player shot, or + // it could never be shot down on approach. + check('growth curves stay under their sanity ceilings', + flipperSpeed(9999) <= TUNE.FLIPPER_SPEED_HARD_MAX + && TUNE.FLIPPER_SPEED_HARD_MAX < TUNE.SHOT_SPEED + && enemyShotSpeed(9999) <= TUNE.ENEMY_SHOT_SPEED_HARD_MAX + && enemyFireRate(9999) <= TUNE.ENEMY_FIRE_HARD_MAX + && levelBudget(9999) <= TUNE.BUDGET_HARD_MAX + && maxConcurrent(9999) <= TUNE.CONCURRENT_MAX); check('unlock order matches the arcade schedule', TUNE.UNLOCK.flipper < TUNE.UNLOCK.tanker && TUNE.UNLOCK.tanker < TUNE.UNLOCK.spiker @@ -522,9 +561,11 @@ console.log('Difficulty escalation (levels 1-60)'); console.log('Monte-carlo bot soak (40 games)'); { let cleared = 0; let deaths = 0; let maxLevel = 0; let bad = 0; let overs = 0; + let gained = 0; // total levels *gained*, since games start at 1/5/9/13 const started = Date.now(); for (let g = 0; g < 40; g += 1) { - const sim = createGame({ seed: 9000 + g, startLevel: 1 + (g % 4) * 4 }); + const startLevel = 1 + (g % 4) * 4; + const sim = createGame({ seed: 9000 + g, startLevel }); setFiring(sim, true); let steps = 0; const stepCap = 60 * 60 * 6; // six simulated minutes per game @@ -564,11 +605,26 @@ console.log('Monte-carlo bot soak (40 games)'); if (sim.shots.length > TUNE.MAX_SHOTS) bad += 1; } maxLevel = Math.max(maxLevel, sim.level); + gained += sim.level - startLevel; } + const avgRun = gained / 40; check('no invariant violations across the soak', bad === 0, `violations=${bad}`); - check('bot makes real progress', cleared >= 40 && maxLevel >= 5, `cleared=${cleared} maxLevel=${maxLevel}`); - check('deaths occur (the game bites back)', deaths > 0, `deaths=${deaths}`); - console.log(` info soak: cleared=${cleared} deaths=${deaths} gameovers=${overs} maxLevel=${maxLevel} in ${((Date.now() - started) / 1000).toFixed(1)}s`); + + // Difficulty regression guard. This bot is deliberately naive (aim at the + // nearest enemy, hold fire, panic-zap) — it stands in for a careless player. + // Before the retune it posted deaths=2, gameovers=0, maxLevel=22 across + // these same 40 games, i.e. it could not lose. It now has to lose. + check('a careless player actually loses', overs >= 30, `gameovers=${overs}/40`); + check('deaths are common, not incidental', deaths / 40 >= 3, + `deaths=${deaths} (${(deaths / 40).toFixed(1)}/game)`); + check('but level 1 is still winnable', cleared >= 40, `cleared=${cleared}`); + // Mean levels gained per run — far stabler across seeds than a max, and the + // number to watch if this is ever retuned again. Too low means level 1 got + // unfair; too high means the retune has eroded. + check('the naive bot does not cruise', avgRun <= 6, `avgRun=${avgRun.toFixed(1)}`); + check('runs are not instantly fatal', avgRun >= 1, `avgRun=${avgRun.toFixed(1)}`); + console.log(` info soak: cleared=${cleared} deaths=${deaths} gameovers=${overs}` + + ` maxLevel=${maxLevel} avgRun=+${avgRun.toFixed(1)} in ${((Date.now() - started) / 1000).toFixed(1)}s`); } // ── Result ───────────────────────────────────────────────────────────────────