stress-tests.ts

PURPOSE

Headless performance harness. Builds synthetic gameplay loads (enemies, bullets, particles, damage numbers, world events, XP orbs) and measures wall-clock cost per simulated frame at fixed dt. No rendering occurs — output is pure engine/update cost (AI movement, spatial grid, collision pipeline, weapons fire, VFX state, event update, orb collection). Used to gate phone-performance regressions and to characterize the worst realistic frame.

OWNS

  • StressResult interface — per-scenario record (frame times, avg/min/max/p95/p99, peak entity counts).
  • StressReport interface — { timestamp, results[] }.
  • STRESS_SCENARIOS table — id-to-fn registry of all runnable scenarios.
  • runAllStressTests() — executes every scenario in order, returns a StressReport.
  • runStressTestsWithReport() — same plus a formatted console.log table; stashes report on window.__stressReport when in browser.
  • Ten scenario functions, all exported:
    • stressEnemySpawnStorm(enemyCount) — ring of enemies chasing ship, spatial grid rebuild only.
    • stressBulletHell() — 3 weapons firing into 60 respawning enemies, full bullet/collision pipeline.
    • stressDamageNumberFlood() — fixed-rate DmgNumbers.add + DmgNumbers.update.
    • stressParticleExplosionChain() — periodic burst clusters (Particles.burst x3 + dmg number).
    • stressWorldEventDensity(eventCount) — concurrent magnet events + 40 enemies + particles.
    • stressShieldHeat() — close-range enemies, shield regen/decay, heat oscillation, sampled dmg numbers.
    • stressXpOrbFlood() — orb cap filled, real xpOrbs.update (magnet/merge/collect) + respawn.
    • stressLateGameCompound() — composite worst-case (enemies + weapons + events + particles + orbs).
    • stressCollisionGrid(enemyCount) — dense rings, broad-phase + narrow-phase stressor.
    • stressParticlesCapped() — particle pool filled near CFG.MAX_PARTICLES, sustained update + replenish.
  • Internal helpers: makeEnemy, makeWeapon, makeEvent, setupShip, setupGame, percentile, measureFrames, buildResult.
  • Module-local counter _nextEid seeded at 10000 for synthetic entity IDs.

READS FROM

  • ../engine/core/stategame, ship, world, camera, resetState. Mutates them directly; no isolation.
  • ../engine/core/configCFG.MAX_XP_ORBS, CFG.MAX_PARTICLES.
  • ../data/enemiesENEMY_TYPE_MAP['shooter_common'] is the canonical base def for every synthetic enemy (radius, tags, typeId).
  • ../data/weaponsWEAPONS, WEAPON_MAP, getWeaponStatAtLevel, getEffectiveLevel. Weapons referenced by id: rifle, shotgun, railgun.
  • ../engine/weapons/weaponsWeaponManager.getAutoAimAngle, WeaponManager.updateCooldowns, WeaponManager.fire, getWeaponEffectiveRange, Weapon type.
  • ../engine/weapons/bulletsupdateBullets.
  • ../engine/combat/collision-resolverCollisionResolver.rebuildEnemyGrid, CollisionResolver.resolvePlayerBulletEnemyCollisions.
  • ../engine/vfx/particlesParticles.add, Particles.burst, Particles.update, DmgNumbers.add, DmgNumbers.update.
  • ../engine/world/eventsEventManager.update, GameEvent type.
  • ../engine/world/xp-orbs (namespace import) — clear, spawn, update, count.
  • performance.now() for timing; Math.random for placement jitter.

PUSHES TO

  • world.enemies, world.events, world.particles, world.dmgNumbers, world.playerBullets, world.enemyBullets — populated/read for peak counts via the live singletons. Bullets are produced indirectly by WeaponManager.fire.
  • ship.* — overwrites x, y, vx, vy, hp, hpMax, shield, shieldMax, shieldRegenRate, shieldRegenTimer, radius, lifeSteal, heat, weapons, alive via setupShip; some scenarios further mutate heat and shield each frame and pin hp to a sentinel large value to keep the ship alive.
  • game.phase (set to 'playing'), game.time (incremented by dt per frame), game._dt (set to SIM_DT), game.timeDilation (set to 1).
  • camera.x/camera.y — pinned to ship in scenarios that exercise weapons (auto-aim uses camera-relative state).
  • window.__stressReport — set by runStressTestsWithReport when window exists.
  • console.log — formatted table only via runStressTestsWithReport.

DOES NOT

  • Does not render. No canvas, no WebGL, no draw calls. Frame cost reflects update/sim only.
  • Does not advance variable dt — every frame uses SIM_DT = 1/60 regardless of wall-clock duration.
  • Does not isolate state per scenario beyond calling resetState() once in setupGame. Scenarios run sequentially against the same global singletons; ordering matters only if a scenario leaves residue (each scenario calls setupGame first, which calls resetState).
  • Does not assert thresholds. Pure measurement — callers (vitest, console) decide pass/fail.
  • Does not exercise the real game loop (engine/loop or engine/core/game-step). Each scenario implements its own minimal per-frame sequence inline.
  • Does not feed input. Ship is stationary; aim is computed via WeaponManager.getAutoAimAngle against live enemies.
  • Does not measure GC pauses, allocation rate, or memory. Frame time only.
  • Does not write to disk or post telemetry. Report is in-memory; browser path stashes on window.

Signals

  • StressResult.frameTimes[] is the raw series; avgMs, p95Ms, p99Ms, maxMs, minMs are derived in buildResult from a sorted copy. percentile uses ceiling indexing: idx = ceil(N * p/100) - 1, clamped to >=0.
  • Peak counts (peakEnemies, peakBullets, peakParticles, peakDmgNumbers, peakEvents) are sampled once per frame from world array lengths inside measureFrames. peakBullets sums player and enemy bullet arrays.
  • DEFAULT_FRAME_COUNT = 120 (2s at 60fps) — every scenario inherits this unless it passes its own count to measureFrames (none currently do).

Entry points

  • Vitest: imported by tests/engine/perf/stress.test.ts (see source header).
  • Browser console:
    import('/src/starship-survivors/testing/stress-tests.ts')
      .then(m => m.runStressTestsWithReport())
    
    After completion, window.__stressReport holds the structured StressReport.
  • Programmatic: runAllStressTests() for the report without console output; individual stress* functions can be called in isolation.

Pattern notes

  • Modeled after gauntlet-runner.ts (per source header). Same global-singleton mutation pattern, same resetState() reset, same use of shooter_common as the canonical synthetic enemy.
  • Synthetic enemy shape is hand-rolled in makeEnemy rather than constructed by the production spawn path — must stay in sync with the shape that CollisionResolver, damage.ts, and xp-orbs expect (fields: eid, typeId, _entityType, x/y/vx/vy, angle, speed, radius, hp/hpMax, alive, _dying, _deathTimer, _spawnT, _spawnPopT, _spawnDur, _hitImmune, flashAmount, shapeKey, isBoss, dmgCapped, hasShield, shieldHp, behavior, orbitRadius, weaponId, tags). If the live enemy contract grows, this helper drifts silently.
  • Synthetic event shape in makeEvent is locked to type: 'magnet' with phase: 'active' and initiated: true. Other event types are not exercised.
  • Weapon construction uses getEffectiveLevel(level, def.scalingCurve) then getWeaponStatAtLevel(def.fireRate, effLvl) to derive cooldown = 1/fireRate (falls back to 0.5 if fireRate is <= 0). Other weapon stats (damage, range, projectiles) are resolved later inside WeaponManager.fire from the same defs.
  • Per-scenario AI loop is inlined, not delegated to a real enemy-update module. Movement is straight-line dist > 1 chase at constant speed, with dt * 0.3 scaling in the collision-grid scenario to keep the cluster from collapsing into the ship.
  • Respawn pattern (used by bullet-hell and late-game): after _deathTimer <= 0 flips alive = false, the slot is overwritten in place with a fresh makeEnemy rather than spliced — keeps array length stable and avoids reallocation noise in the measurement.
  • Ship is force-alive in scenarios where enemies are inside collision range (ship.hp = 999999 reset every frame). This isolates engine cost from death/game-over branches.
  • stressXpOrbFlood calls the real xpOrbs.update (SoA), so it does exercise the production magnet/merge/collection path — unlike the inline AI loops elsewhere.
  • runStressTestsWithReport is the intended interactive entry; runAllStressTests is the programmatic one. The console formatter pads with padEnd(28) for scenarioId and padStart(8) for numeric columns.

EXTRACT-CANDIDATE

  • The inline straight-line chase block (compute dx/dy/dist, set vx/vy, integrate x/y by dt) is duplicated across stressEnemySpawnStorm, stressBulletHell, stressWorldEventDensity, stressShieldHeat, stressLateGameCompound, and (in scaled form) stressCollisionGrid. Could be a shared chaseShip(enemies, ship, dt, scale = 1) helper local to this file.
  • The death-tick + in-place-respawn block (_dying countdown, _hitImmune decrement, slot overwrite) is duplicated between stressBulletHell and stressLateGameCompound. Could be a shared recycleDeadEnemies(world, ship, dt, makeFreshFn) helper.
  • The synthetic-entity shape in makeEnemy duplicates fields owned by the production enemy module — if a createEnemyShape(def, overrides) factory existed in data/enemies or a test util, both this file and gauntlet-runner.ts could share it and stop drifting.