retune tempest difficulty and fix death persistence

- Replace hard-capped difficulty curves with exponential ramp function
  that continues scaling deep into endless play without plateauing
- Reduce base speeds and shot counts for more deliberate gameplay
- Increase flipper, fuseball, and pulsar speeds for more pressure
- Flipper AI now chains hops toward the claw instead of resting,
  making it a true pursuer rather than a shuffler
- Change zapper to refill +1 per level instead of full reset, making
  charge management meaningful
- Death no longer resets the board: only enemies within threshold t
  die with the player, others remain mid-climb
- Improve enemy shot hit detection with lane distance check instead
  of exact lane match
- Add advanceShots() helper for cleaner shot advancement logic
- Update verification tests for new mechanics and difficulty curves
- Add michael background sprites and update tetrisattack characters
This commit is contained in:
Brian Fertig 2026-07-21 22:52:07 -06:00
parent d0553bf30b
commit cf646282a0
10 changed files with 201 additions and 72 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 463 KiB

After

Width:  |  Height:  |  Size: 669 KiB

View File

@ -383,7 +383,11 @@ export default class TempestGame extends Phaser.Scene {
case 'warpStart': { case 'warpStart': {
playSound(this, SFX.SCIFI_RISER); playSound(this, SFX.SCIFI_RISER);
const spiked = this.sim.spikes.some((tip) => tip < 1); 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; break;
} }
case 'warpDone': { case 'warpDone': {

View File

@ -238,81 +238,107 @@ export const TUNE = {
LIVES: 3, LIVES: 3,
EXTRA_LIFE_EVERY: 20000, EXTRA_LIFE_EVERY: 20000,
MOVE_LANES_PER_SEC: 14, MOVE_LANES_PER_SEC: 10,
FIRE_COOLDOWN_MS: 110, FIRE_COOLDOWN_MS: 150,
MAX_SHOTS: 8, // arcade cap on live player shots MAX_SHOTS: 4, // live player shots — few enough that aim matters
SHOT_SPEED: 2.3, // t units per second, rim -> pit SHOT_SPEED: 2.3, // t units per second, rim -> pit
// Enemy climb speeds in t/sec (positive = toward the rim) and growth. // 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, TANKER_SPEED: 0.10,
SPIKER_SPEED: 0.22, SPIKER_SPEED: 0.22,
FUSEBALL_SPEED: 0.30, FUSEBALL_SPEED: 0.50,
PULSAR_SPEED: 0.14, PULSAR_SPEED: 0.22,
// Rim flippers hunt the player: rest between hops, then a hop that can be // Rim flippers hunt the player: they chain hops toward the claw without
// shot mid-flight. Flippers also flip lane-to-lane while still climbing // resting while it is more than a lane away, and a hop that lands on the
// (their arcade signature) — chance ramps up and cooldown shortens with // player's lane is a grab. Flippers also flip lane-to-lane while still
// level so early levels stay gentle and later ones get aggressive. // climbing (their arcade signature) — chance ramps up and cooldown shortens
FLIP_DUR_MS: 260, // with level.
FLIP_REST_MS: 520, FLIP_REST_DECAY: 8, FLIP_REST_MIN_MS: 220, FLIP_DUR_MS: 150,
FLIP_CLIMB_CHANCE_BASE: 0.3, FLIP_CLIMB_CHANCE_GROWTH: 0.01, FLIP_CLIMB_CHANCE_MAX: 0.55, FLIP_REST_MS: 260, FLIP_REST_DECAY: 10, FLIP_REST_MIN_MS: 90,
FLIP_CLIMB_COOLDOWN_MS: 1100, FLIP_CLIMB_COOLDOWN_DECAY: 20, FLIP_CLIMB_COOLDOWN_MIN_MS: 600, 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_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_TRIM: 0.06, // how much one shot shaves off a spike tip
SPIKE_POINTS: 1, SPIKE_POINTS: 1,
FUSEBALL_MOVE_MS: [500, 1100], FUSEBALL_MOVE_MS: [500, 1100],
FUSEBALL_PAUSE_MS: [350, 900], FUSEBALL_PAUSE_MS: [200, 500],
FUSEBALL_EDGE_HOP_CHANCE: 0.35, FUSEBALL_EDGE_HOP_CHANCE: 0.5,
FUSEBALL_CLIMB_BIAS: 0.85, // chance a move phase heads for the rim
FUSEBALL_CONTACT_T: 0.05, FUSEBALL_CONTACT_T: 0.05,
PULSAR_PERIOD_MS: 3200, PULSAR_ACTIVE_MS: 700, PULSAR_PERIOD_MS: 2400, PULSAR_ACTIVE_MS: 1000,
PULSAR_LETHAL_T: 0.18, // a pulse only kills when the pulsar is near the rim 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_TURN_T: 0.10, // patrol: climb to here, retreat, climb again
PULSAR_RETREAT_T: 0.72, PULSAR_RETREAT_T: 0.72,
ENEMY_SHOT_SPEED: 0.34, ENEMY_SHOT_SPEED_GROWTH: 0.012, ENEMY_SHOT_SPEED_MAX: 0.62, ENEMY_SHOT_SPEED: 0.70, ENEMY_SHOT_SPEED_RATE: 0.05, ENEMY_SHOT_SPEED_CAP: 1.25,
ENEMY_FIRE_PER_SEC: 0.16, ENEMY_FIRE_GROWTH: 0.012, ENEMY_FIRE_MAX: 0.55, ENEMY_SHOT_SPEED_TAIL: 0.002, ENEMY_SHOT_SPEED_HARD_MAX: 1.9,
MAX_ENEMY_SHOTS: 8, 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. // Spawn scheduling: per-level budget, concurrency cap, and cadence.
BUDGET_BASE: 10, BUDGET_GROWTH: 2.0, BUDGET_MAX: 40, BUDGET_BASE: 14, BUDGET_RATE: 0.05, BUDGET_CAP: 46, BUDGET_TAIL: 0.35, BUDGET_HARD_MAX: 140,
CONCURRENT_BASE: 4, CONCURRENT_MAX: 10, CONCURRENT_BASE: 6, CONCURRENT_PER_LEVEL: 1 / 1.5, CONCURRENT_MAX: 16,
SPAWN_MS_BASE: 1500, SPAWN_MS_DECAY: 0.97, SPAWN_MS_MIN: 480, SPAWN_MS_BASE: 1100, SPAWN_MS_DECAY: 0.955, SPAWN_MS_MIN: 220,
// Type unlock levels and relative spawn weights once unlocked. // Type unlock levels and relative spawn weights once unlocked.
UNLOCK: { flipper: 1, tanker: 3, spiker: 4, fuseball: 11, pulsar: 17 }, UNLOCK: { flipper: 1, tanker: 2, spiker: 3, fuseball: 6, pulsar: 9 },
WEIGHT: { flipper: 1.0, tanker: 0.4, spiker: 0.35, fuseball: 0.3, pulsar: 0.35 }, 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 }, POINTS: { flipper: 150, tanker: 100, spiker: 50, pulsar: 200 },
FUSEBALL_POINTS: [250, 500, 750], // by proximity to the rim when killed 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, ZAP_CASCADE_MS: 40,
DEATH_MS: 1300, 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, WARP_MS: 2600,
START_BONUS_SCALE: 700, // start bonus ~ (level-1)^2 * this, rounded 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) { 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) { 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) { 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) { 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) { 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) { export function spawnInterval(level) {
return Math.max(TUNE.SPAWN_MS_MIN, TUNE.SPAWN_MS_BASE * TUNE.SPAWN_MS_DECAY ** (level - 1)); 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.enemyShots = [];
this.enemies = []; this.enemies = [];
this.spikes = new Array(n).fill(1); // spike tip t per lane; 1 = no spike 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.spawn = { remaining: this.buildBudget(level), timerMs: 600 };
this.warpT = 0; this.warpT = 0;
} }
@ -605,6 +638,18 @@ export class Sim {
return e.lane; 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) { adjacentLaneToward(fromLane, targetLane) {
const n = laneCount(this.web); const n = laneCount(this.web);
if (this.web.closed) { if (this.web.closed) {
@ -650,11 +695,23 @@ export class Sim {
if (e.flipMs >= TUNE.FLIP_DUR_MS) { if (e.flipMs >= TUNE.FLIP_DUR_MS) {
e.lane = e.flipTo; e.lane = e.flipTo;
e.state = e.flipReturn; 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. // A rim flipper sharing the player's lane is a grab — instant death. A hop
if (e.t <= 0.02 && e.state !== 'climb' && this.visualLane(e) === this.playerLane() && e.state !== 'flip') { // 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'); this.killPlayer('grabbed');
} }
} }
@ -695,7 +752,7 @@ export class Sim {
e.state = 'move'; e.state = 'move';
e.phaseMs = this.randRange(TUNE.FUSEBALL_MOVE_MS); e.phaseMs = this.randRange(TUNE.FUSEBALL_MOVE_MS);
// Mostly climbs, sometimes dives back down, sometimes hops an edge. // 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) { if (this.rng() < TUNE.FUSEBALL_EDGE_HOP_CHANCE) {
const edges = this.edgeCount(); const edges = this.edgeCount();
let edge = e.edge + (this.rng() < 0.5 ? -1 : 1); let edge = e.edge + (this.rng() < 0.5 ? -1 : 1);
@ -727,8 +784,8 @@ export class Sim {
} }
enemyFireTick(e, dt) { enemyFireTick(e, dt) {
if (e.type !== 'flipper' && e.type !== 'tanker') return; if (e.type === 'fuseball') return; // fuseballs ram, they don't shoot
if (e.t < 0.12 || e.t > 0.92) return; // only while riding the well 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.enemyShots.length >= TUNE.MAX_ENEMY_SHOTS) return;
if (this.rng() < enemyFireRate(this.level) * (dt / 1000)) { if (this.rng() < enemyFireRate(this.level) * (dt / 1000)) {
this.enemyShots.push({ lane: this.visualLane(e), t: e.t, prevT: e.t }); 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 ----------------------------------------------------------- // -- collisions -----------------------------------------------------------
crossed(a, b) { crossed(a, b) {
@ -787,7 +858,7 @@ export class Sim {
this.emit('spikeTrimmed', { lane: shot.lane, t: tip }); 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); this.enemyShots = this.enemyShots.filter((s) => !s.dead);
} }
@ -823,13 +894,11 @@ export class Sim {
this.emit('warpStart', { level: this.level, refly: true }); this.emit('warpStart', { level: this.level, refly: true });
return; return;
} }
// Survivors sink back into the pit and rejoin the spawn queue; the // Dying is not a reset. Only the enemies that actually cornered the claw
// fight resumes where it left off (zapper charges are NOT refilled). // go up with it — the rest of the wave stays on the web mid-climb, and
for (const e of this.enemies) { // none of them are refunded into the spawn budget. Zapper charges are NOT
const key = e.type; // refilled either.
this.spawn.remaining[key] += 1; this.enemies = this.enemies.filter((e) => e.t >= TUNE.DEATH_CLEAR_T);
}
this.enemies = [];
this.enemyShots = []; this.enemyShots = [];
this.shots = []; this.shots = [];
this.phase = 'respite'; this.phase = 'respite';
@ -854,7 +923,7 @@ export class Sim {
this.warpT = Math.min(1, this.warpT + dt / TUNE.WARP_MS); this.warpT = Math.min(1, this.warpT + dt / TUNE.WARP_MS);
this.movePlayer(dt); this.movePlayer(dt);
this.fireTick(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. // Shots ahead of the diving player shave spikes down.
for (const shot of this.shots) { for (const shot of this.shots) {
const tip = this.spikes[shot.lane]; const tip = this.spikes[shot.lane];
@ -865,7 +934,7 @@ export class Sim {
this.emit('spikeTrimmed', { lane: shot.lane, t: tip }); 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. // Flying into a spike costs a life and the warp restarts.
const tip = this.spikes[this.playerLane()]; const tip = this.spikes[this.playerLane()];
if (tip < 1 && prev < tip && this.warpT >= tip) { 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); } this.advanceShots(dt);
for (const es of this.enemyShots) { es.prevT = es.t; es.t -= enemyShotSpeed(this.level) * (dt / 1000); }
for (const e of this.enemies.slice()) { for (const e of this.enemies.slice()) {
if (e.dead) continue; if (e.dead) continue;
@ -949,10 +1017,11 @@ export class Sim {
this.resolveShots(); this.resolveShots();
if (this.phase !== 'playing') return this.events; 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) { for (const es of this.enemyShots) {
if (es.t <= 0) { if (es.t <= 0) {
if (es.lane === this.playerLane()) { if (this.laneDistanceToPlayer(es.lane) < TUNE.ENEMY_SHOT_HIT_LANES) {
this.killPlayer('shot'); this.killPlayer('shot');
break; break;
} }

View File

@ -21,7 +21,7 @@ import {
laneCount, webForLevel, rimPoint, depthScale, makeProjector, aimLaneForAngle, laneCount, webForLevel, rimPoint, depthScale, makeProjector, aimLaneForAngle,
bandIndexForLevel, startBonus, startLevelOptions, bandIndexForLevel, startBonus, startLevelOptions,
flipperSpeed, enemyShotSpeed, enemyFireRate, levelBudget, maxConcurrent, 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'; } from '../src/games/tempest/TempestLogic.js';
let failures = 0; let failures = 0;
@ -333,13 +333,17 @@ console.log('Enemy fixtures');
sim9.spawn.remaining = { flipper: 0, tanker: 0, spiker: 0, fuseball: 0, pulsar: 0 }; sim9.spawn.remaining = { flipper: 0, tanker: 0, spiker: 0, fuseball: 0, pulsar: 0 };
const deep = sim9.makeEnemy('pulsar', sim9.playerLane(), 0.6); const deep = sim9.makeEnemy('pulsar', sim9.playerLane(), 0.6);
sim9.enemies.push(deep); sim9.enemies.push(deep);
let deepDeath = false; let deepPulseDeath = false; let deepFired = false;
for (let i = 0; i < 60 * 8; i += 1) { for (let i = 0; i < 60 * 8; i += 1) {
deep.t = 0.6; deep.dir = 1; // pin it deep in the well 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; 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 ─────────────────────────────────────────────────────────── // ── 7. Superzapper ───────────────────────────────────────────────────────────
@ -374,8 +378,17 @@ console.log('Superzapper');
step(sim, DT); step(sim, DT);
check('empty zapper does nothing', sim.enemies.length === 1); 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); 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 ────────────────────────────────────── // ── 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('flying into a spike costs a life', spiked && sim2.lives < livesBefore);
check('spike death re-flies the warp', reflew); 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 }); const sim3 = createGame({ seed: 53 });
sim3.spawn.remaining = { flipper: 0, tanker: 0, spiker: 0, fuseball: 0, pulsar: 0 }; 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('flipper', (sim3.playerLane() + 5) % LANES, 0.5));
sim3.enemies.push(sim3.makeEnemy('spiker', (sim3.playerLane() + 8) % LANES, 0.9)); 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'); sim3.killPlayer('shot');
check('death costs a life', sim3.lives === TUNE.LIVES - 1); check('death costs a life', sim3.lives === TUNE.LIVES - 1);
let respited = false; let respited = false;
@ -468,8 +483,13 @@ console.log('Level flow');
if (sim3.phase === 'playing') break; if (sim3.phase === 'playing') break;
} }
check('respite follows a death with lives left', respited && sim3.phase === 'playing'); check('respite follows a death with lives left', respited && sim3.phase === 'playing');
check('survivors rejoined the spawn budget', check('deep enemies survive the player\'s death',
sim3.spawn.remaining.flipper === 1 && sim3.spawn.remaining.spiker === 1 && sim3.enemies.length === 0); 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. // Game over at zero lives.
const sim4 = createGame({ seed: 54 }); const sim4 = createGame({ seed: 54 });
@ -497,11 +517,30 @@ console.log('Difficulty escalation (levels 1-60)');
if (flipRest(l) > flipRest(l - 1)) ok = false; if (flipRest(l) > flipRest(l - 1)) ok = false;
} }
check('all difficulty curves monotonic', ok); check('all difficulty curves monotonic', ok);
check('curves stay bounded',
flipperSpeed(999) <= TUNE.FLIPPER_SPEED_MAX // Timings genuinely cannot fall to zero, so they keep hard floors.
&& levelBudget(999) <= TUNE.BUDGET_MAX check('timing curves respect their floors',
&& spawnInterval(999) >= TUNE.SPAWN_MS_MIN spawnInterval(999) >= TUNE.SPAWN_MS_MIN
&& flipRest(999) >= TUNE.FLIP_REST_MIN_MS); && 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', check('unlock order matches the arcade schedule',
TUNE.UNLOCK.flipper < TUNE.UNLOCK.tanker TUNE.UNLOCK.flipper < TUNE.UNLOCK.tanker
&& TUNE.UNLOCK.tanker < TUNE.UNLOCK.spiker && 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)'); console.log('Monte-carlo bot soak (40 games)');
{ {
let cleared = 0; let deaths = 0; let maxLevel = 0; let bad = 0; let overs = 0; 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(); const started = Date.now();
for (let g = 0; g < 40; g += 1) { 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); setFiring(sim, true);
let steps = 0; let steps = 0;
const stepCap = 60 * 60 * 6; // six simulated minutes per game 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; if (sim.shots.length > TUNE.MAX_SHOTS) bad += 1;
} }
maxLevel = Math.max(maxLevel, sim.level); 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('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}`); // Difficulty regression guard. This bot is deliberately naive (aim at the
console.log(` info soak: cleared=${cleared} deaths=${deaths} gameovers=${overs} maxLevel=${maxLevel} in ${((Date.now() - started) / 1000).toFixed(1)}s`); // 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 ─────────────────────────────────────────────────────────────────── // ── Result ───────────────────────────────────────────────────────────────────