Files
MeatSlicer/test/smoke.js
T
MeatSlicer Developer 3f856e416a Add in-game field manual on the pause screen
A [?] HELP button (or H key) on the pause screen opens a seven-page
technical manual: controls, run loop, arsenal, mutations, implants,
bestiary and pressure/defense math. List pages render live from the
WEAPONS/ITEMS/PERKS/ENEMY_TYPES/BOSS_DEFS tables so values never drift.
Also unifies the sfxbank fetch cache-buster with the v36 bump.
2026-07-29 21:35:52 +10:00

1165 lines
53 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Headless smoke test: stubs the DOM/canvas, loads the game scripts,
// and drives the game through its major code paths.
// Run: node test/smoke.js
'use strict';
const fs = require('fs');
const path = require('path');
const vm = require('vm');
// ---- fake canvas 2d context ----
function fakeCtx() {
return new Proxy({}, {
get(t, prop) {
if (prop === 'measureText') return () => ({ width: 50 });
if (prop === 'getImageData') return () => ({ data: new Uint8ClampedArray(4) });
if (prop === 'createLinearGradient' || prop === 'createRadialGradient')
return () => ({ addColorStop() {} });
if (typeof prop === 'string') {
if (!(prop in t)) t[prop] = (typeof prop === 'string' && /^[a-z]/.test(prop)) ? function () {} : undefined;
}
return t[prop];
},
set(t, prop, v) { t[prop] = v; return true; },
});
}
const listeners = {};
const canvasStub = {
width: 960, height: 640, style: {},
getContext: () => fakeCtx(),
addEventListener: (ev, fn) => { (listeners['canvas:' + ev] = listeners['canvas:' + ev] || []).push(fn); },
getBoundingClientRect: () => ({ left: 0, top: 0, width: 960, height: 640 }),
};
const sandbox = {
console,
performance: { now: () => simTime },
requestAnimationFrame: () => {}, // we drive update() manually
localStorage: { getItem: () => null, setItem: () => {} },
document: { getElementById: () => canvasStub },
Image: class { constructor() { this.width = 32; this.height = 32; } set src(v) { this.onerror && this.onerror(); } },
Audio: class {
constructor() { this.volume = 0; this.paused = true; }
play() { this.paused = false; return Promise.resolve(); }
pause() { this.paused = true; }
},
AudioContext: undefined, // exercise the no-WebAudio path
setTimeout: (fn, ms) => fn(), // run sfx sequences inline
Math, JSON, Object, Array, Number, String, Boolean, Promise, Uint8ClampedArray, Proxy, Reflect, Set, Map, Date, Error, RegExp, parseInt, parseFloat, isNaN, Infinity, NaN, undefined,
};
sandbox.window = sandbox;
sandbox.globalThis = sandbox;
sandbox.window.addEventListener = (ev, fn) => { (listeners['win:' + ev] = listeners['win:' + ev] || []).push(fn); };
vm.createContext(sandbox);
// load game scripts in index.html order
const files = ['utils', 'state', 'input', 'sfx', 'sfxbank', 'audio', 'sprites', 'particles', 'weapons',
'bullets', 'pickups', 'items', 'perks', 'enemies', 'bosses', 'rooms', 'player', 'hud', 'help', 'main'];
for (const f of files) {
const code = fs.readFileSync(path.join(__dirname, '..', 'js', f + '.js'), 'utf8');
vm.runInContext(code, sandbox, { filename: f + '.js' });
}
// const/let top-level bindings live in the context's lexical scope, not on the
// global object — expose the ones the harness pokes at directly.
vm.runInContext('Object.assign(this, { G, Input, WEAPONS, Music, Sfx, SfxBank, W, H, WALL, ITEMS, PERKS, ENEMY_TYPES, BOSS_DEFS, SPRITE_MANIFEST, Sprites, ACTOR_ANIMS })', sandbox);
let simTime = 0;
const ctx = sandbox;
let autoDraft = false; // when true, perk offers are auto-picked so tests stay in play mode
function step(n, dtMs) {
for (let i = 0; i < n; i++) {
if (autoDraft && ctx.G.mode === 'levelup') ctx.choosePerk(0);
simTime += dtMs;
ctx.update(dtMs / 1000);
ctx.draw();
ctx.clearInputEdges();
}
}
function fire(ev, key) {
for (const fn of (listeners['win:' + ev] || [])) fn({ key, preventDefault() {} });
}
function press(key) { fire('keydown', key); }
function release(key) { fire('keyup', key); }
function tap(key) { press(key); step(1, 16); release(key); }
let failures = 0;
function check(name, cond) {
if (cond) console.log(' PASS ' + name);
else { console.log(' FAIL ' + name); failures++; }
}
console.log('== boot ==');
for (const fn of (listeners['win:load'] || [])) fn();
step(5, 16);
check('menu mode', ctx.G.mode === 'menu');
check('no images loaded (fallbacks ok)', ctx.G.imagesLoaded === true);
check('five complete actor actions registered', Object.keys(ctx.ACTOR_ANIMS).join(',') === 'idle,move,attack,hit,death');
let sheetDraws = 0;
let outOfBoundsDraws = 0;
const actorCtx = fakeCtx();
actorCtx.drawImage = (img, sx, sy, sw, sh) => {
sheetDraws++;
if (sx < 0 || sy < 0 || sx + sw > img.width || sy + sh > img.height) outOfBoundsDraws++;
};
const sheetCases = [
['player', 768, 2784, 96],
['enemy_shambler', 512, 1856, 64],
['boss_bonesaw', 1024, 3712, 128],
['boss_gorecrown', 1024, 3712, 128],
['boss_knifecrawl', 1024, 3712, 128],
['boss_vealmother', 1024, 3712, 128],
['boss_flenser', 1024, 3712, 128],
['boss_hookchoir', 1024, 3712, 128],
['boss_platefather', 1024, 3712, 128],
['boss_augerprime', 1024, 3712, 128],
['boss_scald', 1024, 3712, 128],
];
for (const [name, width, height, targetW] of sheetCases) {
ctx.Sprites.imgs[name + '_sheet'] = { width, height };
for (let dir = 0; dir < 8; dir++) {
for (const action of Object.keys(ctx.ACTOR_ANIMS)) {
ctx.Sprites.actor(actorCtx, name, 0, 0, dir * Math.PI / 4, action, 0.2, targetW);
}
}
delete ctx.Sprites.imgs[name + '_sheet'];
}
check('all frame sizes address 8 directions and 5 actions', sheetDraws === 440);
check('actor atlas reads stay in bounds', outOfBoundsDraws === 0);
const layeredPlayerSprites = ['player_legs', 'player_legs_sheet', ...Object.keys(ctx.WEAPONS).map(id => 'pt_' + id)];
check('layered player sprites are all preloaded', layeredPlayerSprites.every(name => ctx.SPRITE_MANIFEST.includes(name)));
let legDraws = 0, legOutOfBounds = 0;
const legRotations = [];
const legCtx = fakeCtx();
legCtx.rotate = angle => legRotations.push(angle);
legCtx.drawImage = (img, sx, sy, sw, sh) => {
legDraws++;
if (sx < 0 || sy < 0 || sx + sw > img.width || sy + sh > img.height) legOutOfBounds++;
};
ctx.Sprites.imgs.player_legs_sheet = { width: 768, height: 96 };
for (let frame = 0; frame < 8; frame++) {
ctx.Sprites.legs(legCtx, 0, 0, Math.PI / 3, frame * Math.PI / 4 + 0.01, 96);
}
delete ctx.Sprites.imgs.player_legs_sheet;
check('legs strip addresses all 8 stride frames', legDraws === 8);
check('legs strip reads stay in bounds', legOutOfBounds === 0);
check('legs strip rotates smoothly at runtime', legRotations.length === 8 && legRotations.every(angle => Math.abs(angle - Math.PI / 3) < 0.001));
check('every weapon has layered render and muzzle geometry', Object.values(ctx.WEAPONS).every(w =>
Number.isFinite(w.torsoW) && Number.isFinite(w.torsoFwd) && Number.isFinite(w.muzzle) && w.muzzle > 0));
check('every weapon has finite impact punch metadata', Object.values(ctx.WEAPONS).every(w => Number.isFinite(w.punch) && w.punch > 0));
check('arsenal still contains exactly 16 weapons', Object.keys(ctx.WEAPONS).length === 16);
check('visceral DSP and fallback events are wired',
['punch', 'duck', 'wallHit', 'lifesteal'].every(name => typeof ctx.Sfx[name] === 'function'));
const missingSfxFiles = Object.values(ctx.SfxBank.FILES).filter(file => {
const soundPath = path.join(__dirname, '..', 'assets', 'sfx', file);
return !fs.existsSync(soundPath) || fs.statSync(soundPath).size === 0;
});
check('every registered SFX file exists and is non-empty', missingSfxFiles.length === 0);
if (missingSfxFiles.length) console.log(' MISSING-SFX: ' + missingSfxFiles.join(', '));
console.log('== start run ==');
tap('Enter');
check('play mode', ctx.G.mode === 'play');
check('player exists', !!ctx.G.player);
check('Bone Popper baseline is 8 damage at 0.60s', ctx.WEAPONS.bonepopper.dmg === 8 && ctx.WEAPONS.bonepopper.interval === 0.60);
check('floor 1 rooms generated', Object.keys(ctx.G.rooms).length >= 5);
check('start room current', ctx.G.cur && ctx.G.cur.type === 'start');
step(30, 16);
console.log('== movement + firing ==');
const px = ctx.G.player.x;
press('d'); step(20, 16); release('d');
check('player moved right', ctx.G.player.x > px);
ctx.Input.mx = ctx.G.player.x; ctx.Input.my = ctx.G.player.y - 200;
press('d'); step(12, 16); release('d');
const bodyAimDelta = Math.abs(Math.atan2(Math.sin(ctx.G.player.aim - ctx.G.player.bodyFacing), Math.cos(ctx.G.player.aim - ctx.G.player.bodyFacing)));
check('body follows movement while weapon aim stays independent', bodyAimDelta > 0.7 && Math.abs(ctx.G.player.bodyFacing) < 0.45);
const idleBodyFacing = ctx.G.player.bodyFacing;
step(12, 16);
const aimDeltaBeforeIdle = Math.abs(Math.atan2(Math.sin(ctx.G.player.aim - idleBodyFacing), Math.cos(ctx.G.player.aim - idleBodyFacing)));
const aimDeltaAfterIdle = Math.abs(Math.atan2(Math.sin(ctx.G.player.aim - ctx.G.player.bodyFacing), Math.cos(ctx.G.player.aim - ctx.G.player.bodyFacing)));
check('legs rotate back toward aim after movement stops', aimDeltaAfterIdle < aimDeltaBeforeIdle);
let footstepCalls = 0, wetFootsteps = 0;
const realFootstep = ctx.Sfx.footstep;
const decalStart = ctx.G.cur.decals.length;
for (const offset of [45, 90, 135, 180]) {
ctx.G.cur.decals.push({ x: ctx.G.player.x + offset, y: ctx.G.player.y, s: 1.6, rot: 0, img: 'decal_blood1' });
}
check('gore proximity query detects blood underfoot', ctx.onGore(ctx.G.player.x + 45, ctx.G.player.y));
ctx.Sfx.footstep = (_strength, wet) => { footstepCalls++; if (wet) wetFootsteps++; };
press('d'); step(60, 16); release('d');
const movingFootsteps = footstepCalls;
step(30, 16);
ctx.Sfx.footstep = realFootstep;
ctx.G.cur.decals.splice(decalStart);
check('stride-9 cycle emits synchronized footsteps', movingFootsteps >= 2 && footstepCalls === movingFootsteps);
check('blood decals select wet footstep events', wetFootsteps >= 1);
ctx.Input.mdown = true; ctx.Input.mx = 700; ctx.Input.my = 300;
step(30, 16);
check('bullets fired', ctx.G.bullets.length > 0 || ctx.G.kills >= 0); // may have hit nothing
step(60, 16);
ctx.Input.mdown = false;
let wallHitCalls = 0;
const realWallHit = ctx.Sfx.wallHit;
ctx.Sfx.wallHit = () => { wallHitCalls++; };
ctx.G.bullets.length = 0;
ctx.G.bullets.push({
x: ctx.W - ctx.WALL - 5, y: ctx.H / 2, vx: 700, vy: 0, r: 5, dmg: 1,
pierce: 0, bounce: 0, bounces: 0, life: 1, t: 0, behavior: 'bullet',
sprite: 'bullet_bone', homing: 0, hit: null, dragTarget: null,
});
ctx.updateBullets(0.02);
ctx.Sfx.wallHit = realWallHit;
check('absorbed rounds trigger wall impact audio', wallHitCalls === 1);
console.log('== combat room: lock, clear, unlock ==');
// find a combat room adjacent to start and force-move there
const start = ctx.G.rooms['0,0'];
const dir = Object.keys(start.doors).find(d => start.doors[d]);
check('start has a door', !!dir);
// teleport into neighbor and enter it
const DIRS = { n: [0, -1], s: [0, 1], e: [1, 0], w: [-1, 0] };
const [dx, dy] = DIRS[dir];
ctx.enterRoom(start.gx + dx, start.gy + dy);
step(10, 16);
const room = ctx.G.cur;
if (room.type === 'combat') {
check('enemies spawned', ctx.G.enemies.length > 0);
check('room locked', ctx.roomLocked() === true);
// kill everything, let waves cycle
for (let guard = 0; guard < 200 && !room.cleared; guard++) {
for (const e of [...ctx.G.enemies]) ctx.damageEnemy(e, 99999, 0, false);
step(30, 16);
}
check('room cleared', room.cleared === true);
check('room unlocked', ctx.roomLocked() === false);
check('reward spawned', true); // reward pickups may already be collected
} else {
check('neighbor room handled (' + room.type + ')', true);
}
console.log('== spawn safety ==');
{
ctx.enterRoom(0, 0);
check('room entry grants spawn protection', ctx.G.player.invT > 0.5);
ctx.G.player.x = ctx.W / 2; ctx.G.player.y = ctx.H / 2;
let nearDoor = 0, tooClose = 0;
for (let i = 0; i < 80; i++) {
const pos = ctx.spawnPosAwayFromPlayer();
if (ctx.nearAnyDoor(pos.x, pos.y)) nearDoor++;
if (Math.hypot(pos.x - ctx.G.player.x, pos.y - ctx.G.player.y) < 150) tooClose++;
}
check('no spawns near doors', nearDoor === 0);
check('no spawns near player', tooClose === 0);
}
console.log('== XP / perks ==');
ctx.gainXP(100);
step(5, 16);
check('levelup triggered', ctx.G.mode === 'levelup' || ctx.G.pendingLevelups > 0);
let guard = 0;
while (ctx.G.mode === 'levelup' && guard++ < 30) { tap('1'); step(2, 16); }
check('perks drafted', ctx.G.mode === 'play');
check('player leveled', ctx.G.player.level > 1);
console.log('== rebalanced perks + automatic draft ==');
{
check('13 new perks added', ctx.PERKS.length === 23);
const perkStats = { dmgMul: 1 };
ctx.PERKS.find(k => k.id === 'sharpen').apply(perkStats, { hp: 1 });
check('core level-up damage bonus was halved', Math.abs(perkStats.dmgMul - 1.04) < 0.001);
const p = ctx.G.player;
ctx.G.pendingLevelups = 1; ctx.G.autoPerk = false; ctx.openPerkDraft();
p.stats.rerolls = 1; tap('r');
check('Reroll Rib currency refreshes a manual draft', p.stats.rerolls === 0 && ctx.G.perkChoices.length === 3);
tap('4');
check('one-off random draft choice resolves normally', ctx.G.mode === 'play' && ctx.G.pendingLevelups === 0);
const levelBefore = p.level;
ctx.G.autoPerk = true;
ctx.gainXP(180);
step(3, 16);
check('auto-draft drains queued level-ups without opening modal', p.level > levelBefore && ctx.G.pendingLevelups === 0 && ctx.G.mode === 'play');
ctx.G.autoPerk = false;
ctx.startRun(); step(3, 16);
}
autoDraft = true; // later sections must not get stuck in the perk draft
console.log('== shield heart perk ==');
{
const p = ctx.G.player;
p.hp = 6; p.shieldHp = 0; p.stats.shieldPerk = 0; p.invT = 0;
check('old armor perk is gone', !ctx.PERKS.some(k => k.id === 'thick'));
const sh = ctx.PERKS.find(k => k.id === 'shieldheart');
sh.apply(p.stats, p); sh.apply(p.stats, p);
check('two picks = two half-heart shields', p.stats.shieldPerk === 2 && p.shieldHp === 2);
const hpBefore = p.hp;
ctx.hurtPlayer(1, 0);
check('shield absorbs damage first', p.hp === hpBefore && p.shieldHp === 1);
p.invT = 0;
ctx.hurtPlayer(1, 0);
check('second hit breaks shield, hp untouched', p.hp === hpBefore && p.shieldHp === 0);
p.invT = 0;
ctx.hurtPlayer(1, 0);
check('unshielded hit hurts', p.hp === hpBefore - 1);
p.hp = hpBefore;
ctx.nextFloor(); step(3, 16);
check('shield refreshes each floor', p.shieldHp === 2);
ctx.startRun(); step(5, 16); // reset floor/state for later sections
}
console.log('== items ==');
ctx.giveItem('hollowpoints');
ctx.giveItem('orbitalknives');
ctx.giveItem('splittongue');
ctx.giveItem('ironstomach');
check('items applied', Object.keys(ctx.G.player.items).length === 4);
check('orbital knives active', ctx.G.player.stats.orbitals >= 1);
step(30, 16);
console.log('== expanded perk and item rosters ==');
{
check('27 new items added', Object.keys(ctx.ITEMS).length === 46);
const newPerks = ['critbone', 'critmeat', 'flensing', 'ember', 'frostbile', 'heavyhand', 'thickhide', 'secondwind', 'scrapfeed', 'boneknit', 'spiteflesh', 'carrion', 'sinew'];
const newItems = ['chainsinew', 'mortarbone', 'bloatrounds', 'marrowglut', 'hollowneedle', 'bloodshoteye', 'flayerkiss', 'emberjar', 'acidgland', 'hookrounds', 'sledgerounds', 'graftedtrigger', 'deadmanswitch', 'orbitcrown', 'tannedhide', 'deadmansclock', 'hollowbones', 'boneplate', 'wormgut', 'spinecage', 'secondstomach', 'spitewell', 'twinhearts', 'brassmagazine', 'crowbait', 'gorgingleech', 'rerollrib'];
check('all new perks have manifest icons or fallbacks', newPerks.every(id => ctx.SPRITE_MANIFEST.includes('perk_' + id)));
check('all new items have manifest icons or fallbacks', newItems.every(id => ctx.SPRITE_MANIFEST.includes('i_' + id)));
check('new roster entries all have names, descriptions and effects',
newPerks.every(id => { const k = ctx.PERKS.find(p => p.id === id); return k && k.name && k.desc && typeof k.apply === 'function'; }) &&
newItems.every(id => { const k = ctx.ITEMS[id]; return k && k.name && k.desc && typeof k.apply === 'function'; }));
const livePlayer = ctx.G.player;
ctx.initPlayer();
const probe = ctx.G.player;
for (const id of newItems) for (let tier = 0; tier < 9; tier++) ctx.ITEMS[id].apply(probe.stats, probe);
for (const id of newPerks) for (let tier = 0; tier < 3; tier++) ctx.PERKS.find(k => k.id === id).apply(probe.stats, probe);
check('all new effects execute repeatedly without non-finite stats',
Object.values(probe.stats).every(v => typeof v !== 'number' || Number.isFinite(v)) && Number.isFinite(probe.hp) && Number.isFinite(probe.shieldHp));
ctx.G.player = livePlayer;
}
console.log('== diminishing armor block chance ==');
{
const c1 = ctx.armorBlockChance(0.5), c2 = ctx.armorBlockChance(1), cHuge = ctx.armorBlockChance(999);
check('armor chance has diminishing absolute returns', c1 > 0 && c2 > c1 && (c2 - c1) < c1);
check('armor chance is capped at 75%', Math.abs(cHuge - 0.75) < 0.0001);
const p = ctx.G.player, hp = p.hp;
const realChance = ctx.chance;
ctx.__smokeRealChance = realChance;
vm.runInContext('chance = () => true', ctx);
p.stats.armor = 999; p.invT = 0;
ctx.hurtPlayer(1, 0);
check('successful armor roll ignores all damage', p.hp === hp);
vm.runInContext('chance = __smokeRealChance', ctx);
delete ctx.__smokeRealChance;
p.stats.armor = 0; p.invT = 0;
}
console.log('== close-range weapon impact pass ==');
check('Marrow Scatter was substantially strengthened', ctx.WEAPONS.marrow.dmg >= 11 && ctx.WEAPONS.marrow.range >= 0.38);
check('Bile Blunderbuss was substantially strengthened', ctx.WEAPONS.bile.dmg >= 7 && ctx.WEAPONS.bile.range >= 0.38);
check('Cauterizer was substantially strengthened', ctx.WEAPONS.cauterizer.dmg >= 4.5 && ctx.WEAPONS.cauterizer.range >= 0.36);
check('melee weapons were substantially strengthened', ctx.WEAPONS.tenderizer.dmg >= 60 && ctx.WEAPONS.redhand.dmg >= 7);
console.log('== continuous saw frame-rate independence ==');
{
const p = ctx.G.player;
const savedStats = p.stats;
function sawDamage(steps) {
p.stats = Object.assign({}, savedStats, {
dmgMul: 1, rateMul: 1, rangeMul: 1, shotSpeedMul: 1, sizeMul: 1, crit: 0,
split: 0, fan: 0, rear: 0, pierce: 0, bounce: 0, homing: 0,
bleed: 0, igniteChance: 0, slowOnHit: 0, stunOnHit: 0, acidOnHit: 0, pullOnHit: 0, chain: 0, mortar: 0,
});
p.aim = 0;
const e = ctx.makeEnemy('shambler', p.x + 30, p.y, 1, false);
e.hp = e.maxHp = 10000; e.spd = 0;
ctx.G.enemies = [e];
const inst = { ammo: 999 };
for (let i = 0; i < steps; i++) ctx.sawTick(p, ctx.WEAPONS.redhand, inst, 1 / steps);
return e.maxHp - e.hp;
}
const d30 = sawDamage(30), d120 = sawDamage(120);
check('chainsaw DPS is stable across simulation rates', Math.abs(d30 - d120) / Math.max(1, d30) < 0.02);
p.stats = Object.assign({}, savedStats, {
crit: 0, bleed: 0.5, splinter: 0, igniteChance: 0, slowOnHit: 0, stunOnHit: 0,
acidOnHit: 0, pullOnHit: 0, chain: 0, mortar: 0,
});
const normal = ctx.makeEnemy('shambler', 400, 300, 1, false);
const orbital = ctx.makeEnemy('shambler', 500, 300, 1, false);
ctx.G.enemies = [normal, orbital];
ctx.damageEnemy(normal, 12, 0, false, { noCrit: true });
ctx.damageEnemy(orbital, 12, 0, false, { noCrit: true, procScale: 0.5, procMagnitudeScale: 0.5 });
check('orbital proc dampening does not amplify effect magnitude', Math.abs(orbital.bleedDps - normal.bleedDps * 0.5) < 0.001);
p.stats = savedStats; ctx.G.enemies.length = 0;
}
console.log('== item upgrades (9 quality tiers) ==');
{
const p = ctx.G.player;
const dmgBefore = p.stats.dmgMul;
ctx.giveItem('hollowpoints');
check('duplicate upgrades to tier II', p.items.hollowpoints === 2);
check('upgrade re-applies the effect', Math.abs(p.stats.dmgMul - dmgBefore * 1.25) < 0.001);
for (let i = 0; i < 7; i++) ctx.giveItem('hollowpoints');
check('nine copies reach tier IX', p.items.hollowpoints === 9);
check('full linear scaling to IX', Math.abs(p.stats.dmgMul - dmgBefore * Math.pow(1.25, 8)) < 0.01);
const scoreBefore = ctx.G.score;
const dmgAtCap = p.stats.dmgMul;
ctx.giveItem('hollowpoints');
check('tenth copy capped, converts to score', p.items.hollowpoints === 9 && ctx.G.score > scoreBefore);
check('capped copy gives no extra effect', p.stats.dmgMul === dmgAtCap);
check('still one icon in the item list', Object.keys(p.items).filter(k => k === 'hollowpoints').length === 1);
}
console.log('== tier-scaled items + duplicate favoring ==');
{
const p = ctx.G.player;
ctx.giveItem('backstabber'); ctx.giveItem('backstabber');
check('backstabber stacks rear shots', p.stats.rear === 2);
ctx.G.enemies.length = 0;
ctx.G.bullets.length = 0;
p.weapon = { id: 'bonepopper', ammo: Infinity };
p.fireT = 0;
ctx.Input.mx = p.x + 100; ctx.Input.my = p.y;
ctx.Input.mdown = true; step(3, 16); ctx.Input.mdown = false;
check('rear fan fires extra shots', ctx.G.bullets.length >= 3); // fwd + split + 2 rear
ctx.giveItem('homingtumor'); ctx.giveItem('homingtumor');
check('homing tumor stacks', p.stats.homing === 2);
ctx.G.bullets.length = 0;
p.fireT = 0; // ensure a fresh volley actually fires
ctx.Input.mdown = true; step(3, 16); ctx.Input.mdown = false;
const hb = ctx.G.bullets[0];
check('homing strength scales with tier', hb && Math.abs(hb.homing - 3.0) < 0.001);
ctx.giveItem('volatilebile'); ctx.giveItem('volatilebile');
check('volatile bile tiers up', p.stats.explodeOnKill === 2);
// kill-explosion should damage a nearby enemy
ctx.G.enemies.length = 0;
const v1 = ctx.makeEnemy('shambler', 700, 320, 1, false); v1.spd = 0; v1.hp = 1;
const v2 = ctx.makeEnemy('shambler', 740, 320, 1, false); v2.spd = 0;
ctx.G.enemies.push(v1, v2);
const v2hpBefore = v2.hp;
ctx.damageEnemy(v1, 9999, 0, false);
check('kill-explosion hurts nearby enemies', v2.hp < v2hpBefore);
ctx.G.enemies.length = 0;
let ownedHits = 0;
for (let i = 0; i < 200; i++) {
if (p.items[ctx.randomItemId()]) ownedHits++;
}
check('item rolls favor owned items (' + ownedHits + '/200)', ownedHits >= 60);
}
console.log('== all weapons fire without crashing ==');
for (const wid of Object.keys(ctx.WEAPONS)) {
ctx.G.player.weapon = { id: wid, ammo: 50 };
ctx.G.player.charge = 0;
ctx.Input.mdown = true;
step(20, 16);
ctx.Input.mdown = false;
ctx.Input.mreleased = true;
step(5, 16);
ctx.Input.mreleased = false;
step(40, 16);
}
check('16 weapons exercised', true);
check('reverted to bone popper when dry', ctx.G.player.weapon.ammo > 0 || ctx.G.player.weapon.id === 'bonepopper');
console.log('== every weapon actually DAMAGES enemies (NaN regression) ==');
{
const p = ctx.G.player;
const saveHp = p.hp, saveMax = p.stats.maxHp, saveOrb = p.stats.orbitals;
p.stats.orbitals = 0; // don't let orbitals mask a broken weapon
const noDmg = [];
for (const wid of Object.keys(ctx.WEAPONS)) {
ctx.G.enemies.length = 0;
const e = ctx.makeEnemy('shambler', p.x + 42, p.y, 1, false);
e.spd = 0;
ctx.G.enemies.push(e);
p.weapon = { id: wid, ammo: 60 };
p.charge = 0; p.hp = 999; p.stats.maxHp = 999;
ctx.Input.mx = e.x; ctx.Input.my = e.y;
ctx.Input.mdown = true;
step(50, 16);
ctx.Input.mdown = false;
ctx.Input.mreleased = true;
step(8, 16);
ctx.Input.mreleased = false;
step(20, 16);
if (!(e.hp < e.maxHp)) noDmg.push(wid);
ctx.G.bullets.length = 0;
ctx.G.hazards.length = 0;
}
check('all 16 weapons deal damage', noDmg.length === 0);
if (noDmg.length) console.log(' NO-DAMAGE: ' + noDmg.join(', '));
ctx.G.enemies.length = 0;
p.weapon = { id: 'bonepopper', ammo: Infinity };
p.hp = saveHp; p.stats.maxHp = saveMax; p.stats.orbitals = saveOrb;
}
console.log('== shared on-hit items work with every weapon ==');
{
const p = ctx.G.player;
const saveBleed = p.stats.bleed, saveCrit = p.stats.crit, saveOrb = p.stats.orbitals;
p.stats.bleed = 1; p.stats.crit = 0; p.stats.orbitals = 0;
const realChance = ctx.chance;
ctx.__smokeRealChance = realChance;
vm.runInContext('chance = () => true', ctx);
const misses = [];
for (const wid of Object.keys(ctx.WEAPONS)) {
ctx.G.enemies.length = 0; ctx.G.bullets.length = 0; ctx.G.hazards.length = 0;
const e = ctx.makeEnemy('shambler', p.x + 42, p.y, 1, false);
e.hp = e.maxHp = 10000; e.spd = 0;
ctx.G.enemies.push(e);
p.weapon = { id: wid, ammo: 60 }; p.charge = 0; p.fireT = 0;
ctx.Input.mx = e.x; ctx.Input.my = e.y; ctx.Input.mdown = true;
step(50, 16);
ctx.Input.mdown = false; ctx.Input.mreleased = true; step(8, 16);
ctx.Input.mreleased = false; step(20, 16);
if (!(e.bleedT > 0)) misses.push(wid);
}
check('all 16 weapons deliver shared bleed/on-hit payloads', misses.length === 0);
if (misses.length) console.log(' NO-ON-HIT: ' + misses.join(', '));
vm.runInContext('chance = __smokeRealChance', ctx);
delete ctx.__smokeRealChance;
p.stats.bleed = saveBleed; p.stats.crit = saveCrit; p.stats.orbitals = saveOrb;
ctx.G.enemies.length = 0; ctx.G.bullets.length = 0; ctx.G.hazards.length = 0;
p.weapon = { id: 'bonepopper', ammo: Infinity };
}
console.log('== per-weapon muzzle spawn distances ==');
{
const p = ctx.G.player;
const oldAim = p.aim;
p.aim = 0;
const badMuzzles = [];
for (const w of Object.values(ctx.WEAPONS)) {
if (['slam', 'saw', 'beam'].includes(w.behavior)) continue;
ctx.G.bullets.length = 0;
ctx.fireWeapon(p, w);
const b = ctx.G.bullets[0];
if (!b || Math.abs(b.x - (p.x + w.muzzle)) > 0.001 || Math.abs(b.y - p.y) > 0.001) badMuzzles.push(w.id);
}
p.aim = oldAim;
ctx.G.bullets.length = 0;
check('projectiles originate at each weapon muzzle', badMuzzles.length === 0);
if (badMuzzles.length) console.log(' BAD-MUZZLE: ' + badMuzzles.join(', '));
}
console.log('== generic ammo drops ==');
{
const p = ctx.G.player;
p.stats.luck = 5;
ctx.G.pickups.length = 0;
p.x = 100; p.y = 100;
for (let i = 0; i < 40; i++) {
const e = ctx.makeEnemy('shambler', 700, 500, 1, false);
ctx.G.enemies.push(e);
ctx.damageEnemy(e, 9999, 0, false);
}
step(3, 16);
const ammoCount = ctx.G.pickups.filter(k => k.type === 'ammo').length;
check('ammo drops are common (' + ammoCount + ' from 40 kills)', ammoCount >= 2);
// generic ammo refills every finite-ammo weapon currently carried
p.weapon = { id: 'repeater', ammo: 10 };
p.holstered = { id: 'spinaltap', ammo: 2 };
ctx.spawnPickup('ammo', p.x, p.y);
step(2, 16);
check('ammo pickup refills held weapon', p.weapon.ammo > 10);
check('ammo pickup also refills holstered weapon', p.holstered.ammo > 2);
const holsteredAmmo = p.holstered.ammo;
p.weapon = { id: 'bonepopper', ammo: Infinity };
ctx.spawnPickup('ammo', p.x, p.y);
step(2, 16);
check('ammo reaches holstered weapon while sidearm is selected', p.holstered.ammo > holsteredAmmo);
const repeaterRefill = Math.ceil(ctx.WEAPONS.repeater.ammo * ctx.ammoRefillFraction(ctx.WEAPONS.repeater));
const spinalRefill = Math.ceil(ctx.WEAPONS.spinaltap.ammo * ctx.ammoRefillFraction(ctx.WEAPONS.spinaltap));
check('rapid low-damage weapons receive more rounds than heavy weapons', repeaterRefill > spinalRefill * 10);
const sameDamageFast = ctx.ammoRefillFraction({ dmg: 8, interval: 0.1, ammo: 50 });
const sameDamageSlow = ctx.ammoRefillFraction({ dmg: 8, interval: 1.0, ammo: 50 });
check('fire rate explicitly increases ammo refill share', sameDamageFast > sameDamageSlow);
check('persistent utility weapon overrides dampen refill size',
ctx.WEAPONS.trapqueen.ammoWeight < 1 && ctx.WEAPONS.swarmjar.ammoWeight < 1);
const repeaterCap = ctx.WEAPONS.repeater.ammo * 1.5;
p.weapon = { id: 'repeater', ammo: repeaterCap - 1 };
p.holstered = null;
const overflowScore = ctx.G.score;
ctx.collectPickup({ type: 'ammo' });
check('ammo pickup clamps at one and a half magazines', p.weapon.ammo === repeaterCap);
check('ammo overflow converts to score', ctx.G.score > overflowScore);
p.stats.luck = 0;
p.weapon = { id: 'bonepopper', ammo: Infinity };
p.holstered = null;
ctx.G.pickups.length = 0;
}
console.log('== drop economy: weapons are rare, elites feed you ==');
{
const p = ctx.G.player;
p.stats.luck = 0;
ctx.G.pickups.length = 0;
// regular monsters: no weapon drops at all
for (let i = 0; i < 60; i++) {
const e = ctx.makeEnemy('shambler', 700, 500, 1, false);
ctx.G.enemies.push(e);
ctx.damageEnemy(e, 9999, 0, false);
}
step(2, 16);
check('regular kills never drop weapons', !ctx.G.pickups.some(k => k.type === 'weapon'));
// elites: always bonus gems, mostly ammo, sometimes an item
p.x = 100; p.y = 100;
let eliteGemValue = 0, eliteAmmo = 0, eliteItems = 0, eliteWeapons = 0;
for (let i = 0; i < 30; i++) {
const e = ctx.makeEnemy('shambler', 700, 500, 1, true);
ctx.G.enemies.push(e);
ctx.G.pickups.length = 0; // isolate this kill's drops
ctx.damageEnemy(e, 9999, 0, false);
eliteGemValue += ctx.G.pickups.filter(k => k.type === 'gem').reduce((s, k) => s + k.v, 0);
eliteAmmo += ctx.G.pickups.filter(k => k.type === 'ammo').length;
eliteItems += ctx.G.pickups.filter(k => k.type === 'item').length;
eliteWeapons += ctx.G.pickups.filter(k => k.type === 'weapon').length;
}
step(3, 16);
check('elites always drop bonus gems (avg value ' + (eliteGemValue / 30).toFixed(1) + ')', eliteGemValue / 30 >= 5.5);
check('elites mostly drop ammo (' + eliteAmmo + '/30)', eliteAmmo >= 8);
check('elites sometimes drop items (' + eliteItems + '/30)', eliteItems >= 1);
check('elites never drop weapons', eliteWeapons === 0);
ctx.G.pickups.length = 0;
}
console.log('== visible XP bonus + kill-based lifesteal ==');
{
const p = ctx.G.player;
const saved = {
hp: p.hp, maxHp: p.stats.maxHp, xpMul: p.stats.xpMul,
bloodlust: p.stats.bloodlust, lifestealChance: p.stats.lifestealChance,
explodeOnKill: p.stats.explodeOnKill,
};
const realChance = ctx.chance;
ctx.__smokeRealChance = realChance;
vm.runInContext('chance = () => true', ctx);
p.stats.bloodlust = 0;
p.stats.explodeOnKill = 0;
function gemValueFor(multiplier) {
p.stats.xpMul = multiplier;
ctx.G.pickups.length = 0;
const e = ctx.makeEnemy('shambler', 700, 500, 1, false);
ctx.killEnemy(e, 0);
return ctx.G.pickups.filter(k => k.type === 'gem').reduce((sum, k) => sum + k.v, 0);
}
const baseGemValue = gemValueFor(1);
const boostedGemValue = gemValueFor(2);
check('XP multiplier adds a visible bonus crystal roll', boostedGemValue > baseGemValue);
check('Bloodrush card advertises its crystal chance',
ctx.PERKS.find(perk => perk.id === 'bloodrush').desc.includes('bonus crystal'));
p.stats.xpMul = 1;
p.stats.lifestealChance = 1;
p.hp = Math.max(0, p.stats.maxHp - 4);
const hpBeforeHit = p.hp;
const survivor = ctx.makeEnemy('shambler', 700, 500, 1, false);
survivor.hp = survivor.maxHp = 1000;
ctx.damageEnemy(survivor, 1, 0, false);
check('Dentures no longer heal on nonlethal hits', p.hp === hpBeforeHit);
let lifestealSounds = 0;
const realLifestealSfx = ctx.Sfx.lifesteal;
ctx.Sfx.lifesteal = () => { lifestealSounds++; };
const victim = ctx.makeEnemy('shambler', 700, 500, 1, false);
ctx.damageEnemy(victim, 9999, 0, false);
check('Dentures heal half a heart on a normal kill', p.hp === hpBeforeHit + 1);
check('lifesteal proc has dedicated feedback audio', lifestealSounds === 1);
// Hemophage keeps its weapon-specific on-hit drain with the same feedback.
p.hp = hpBeforeHit;
p.stats.lifestealChance = 0;
lifestealSounds = 0;
ctx.G.enemies.length = 0;
ctx.G.bullets.length = 0;
const drainTarget = ctx.makeEnemy('shambler', p.x + 20, p.y, 1, false);
drainTarget.hp = drainTarget.maxHp = 1000;
drainTarget.spd = 0;
ctx.G.enemies.push(drainTarget);
ctx.G.bullets.push({
x: drainTarget.x, y: drainTarget.y, vx: 0, vy: 0, r: 5, dmg: 1,
pierce: 0, bounce: 0, life: 1, t: 0, behavior: 'bullet',
sprite: 'bullet_syringe', homing: 0, lifesteal: ctx.WEAPONS.hemophage.lifesteal,
bounces: 0, hit: null, dragTarget: null,
});
ctx.updateBullets(0.016);
check('Hemophage still heals on a nonlethal hit', p.hp === hpBeforeHit + 1);
check('Hemophage drain uses lifesteal feedback', lifestealSounds === 1);
ctx.Sfx.lifesteal = realLifestealSfx;
vm.runInContext('chance = __smokeRealChance', ctx);
delete ctx.__smokeRealChance;
p.hp = saved.hp;
p.stats.maxHp = saved.maxHp;
p.stats.xpMul = saved.xpMul;
p.stats.bloodlust = saved.bloodlust;
p.stats.lifestealChance = saved.lifestealChance;
p.stats.explodeOnKill = saved.explodeOnKill;
ctx.G.pickups.length = 0;
ctx.G.enemies.length = 0;
}
console.log('== iron stomach is a health upgrade ==');
{
const p = ctx.G.player;
const maxBefore = p.stats.maxHp, hpBefore = p.hp;
ctx.giveItem('ironstomach');
check('iron stomach adds half a heart container', p.stats.maxHp === maxBefore + 1);
check('iron stomach heals half a heart', p.hp === Math.min(p.stats.maxHp, hpBefore + 1));
ctx.giveItem('ironstomach');
check('it stacks per tier', p.stats.maxHp === maxBefore + 2);
}
console.log('== monsters scale with player upgrades ==');
{
const p = ctx.G.player;
ctx.G.pressure = 1;
p.level = 1; p.items = {};
const base = ctx.makeEnemy('shambler', 500, 300, 1, false);
check('no upgrades = base hp', Math.abs(base.hp - 26) < 0.01);
// Weighted power: 4 perks × 0.5 + 2 item tiers = 4 power.
p.level = 5; p.items = { hollowpoints: 2 };
const e6 = ctx.makeEnemy('shambler', 500, 300, 1, false);
check('weaker perks count half as much as item tiers', Math.abs(ctx.powerScore() - 4) < 0.001);
check('weighted power gives +20% hp', Math.abs(e6.hp - 26 * 1.2) < 0.01);
check('weighted power gives ~+4% speed', e6.spd >= 55 * 1.04 * 0.9 && e6.spd <= 55 * 1.04 * 1.1);
const e64 = ctx.makeEnemy('shambler', 500, 300, 4, false);
check('floor + weighted scaling stack', Math.abs(e64.hp - 26 * 1.66 * 1.2) < 0.02);
const b = ctx.spawnBoss(1);
check('boss hp scales with weighted upgrades', Math.abs(b.hp - 520 * 1.2) < 0.01);
ctx.G.enemies.length = 0; ctx.G.boss = null; ctx.G.cur.cleared = true;
ctx.startRun(); step(5, 16);
}
console.log('== adaptive clean-room pressure ==');
{
const p = ctx.G.player;
ctx.G.pressure = 1; ctx.G.streak = 0; ctx.G.roomDamaged = false;
ctx.recordRoomClear({ type: 'combat' });
ctx.G.roomDamaged = false; ctx.recordRoomClear({ type: 'combat' });
ctx.G.roomDamaged = false; ctx.recordRoomClear({ type: 'combat' });
check('three clean rooms add exactly 3% pressure', Math.abs(ctx.G.pressure - 1.03) < 0.0001 && ctx.G.streak === 3);
const pressured = ctx.makeEnemy('shambler', 500, 300, 1, false);
check('pressure strengthens and speeds newly spawned enemies', pressured.maxHp > 26 && pressured.spd >= 55 * 1.03 * 0.9);
p.stats.armor = 0; p.stats.maxHp = 10; p.shieldHp = 0;
ctx.G.pressure = 1.2; ctx.G.recentHits = []; p.hp = 10; p.invT = 0; ctx.G.mode = 'play';
ctx.hurtPlayer(1, 0); const fullHealthRelief = 1.2 - ctx.G.pressure;
ctx.G.pressure = 1.2; ctx.G.recentHits = []; p.hp = 3; p.invT = 0; ctx.G.mode = 'play';
ctx.hurtPlayer(1, 0); const lowHealthRelief = 1.2 - ctx.G.pressure;
check('low health dynamically grants more pressure relief', lowHealthRelief > fullHealthRelief);
ctx.G.pressure = 1.2; ctx.G.streak = 4; ctx.G.roomDamaged = false;
p.hp = 10; p.shieldHp = 1; p.invT = 0; ctx.G.mode = 'play';
ctx.hurtPlayer(1, 0);
check('fully shielded hits preserve a clean-room streak', ctx.G.pressure === 1.2 && ctx.G.streak === 4 && !ctx.G.roomDamaged);
p.shieldHp = 0;
ctx.G.pressure = 0.751; ctx.G.recentHits = []; p.hp = 3; p.invT = 0; ctx.G.mode = 'play';
ctx.hurtPlayer(1, 0);
check('pressure relief is floored at 75%', ctx.G.pressure === 0.75);
ctx.G.pressure = 9; ctx.G.roomDamaged = false; ctx.recordRoomClear({ type: 'combat' });
check('pressure is capped at 160%', ctx.G.pressure === 1.6);
ctx.startRun(); step(3, 16);
check('new runs reset pressure and streak', ctx.G.pressure === 1 && ctx.G.streak === 0);
}
console.log('== boneknit is a chance-based room heal ==');
{
const p = ctx.G.player;
const savedHeal = p.stats.roomHeal, savedChance = p.stats.roomHealChance;
p.stats.roomHeal = 0; p.stats.roomHealChance = 0;
ctx.PERKS.find(k => k.id === 'boneknit').apply(p.stats, p);
check('boneknit grants a 3% roll per stack', Math.abs(p.stats.roomHealChance - 0.03) < 0.0001);
p.hp = 1; p.stats.maxHp = 10; ctx.G.roomDamaged = true; // dirty room: no pressure side-effects
const realChance = ctx.chance;
ctx.__smokeRealChance = realChance;
vm.runInContext('chance = () => true', ctx);
ctx.recordRoomClear({ type: 'combat' });
check('successful boneknit roll heals half a heart', p.hp === 2);
vm.runInContext('chance = () => false', ctx);
ctx.recordRoomClear({ type: 'combat' });
check('failed boneknit roll heals nothing', p.hp === 2);
vm.runInContext('chance = __smokeRealChance', ctx);
delete ctx.__smokeRealChance;
p.stats.roomHeal = savedHeal; p.stats.roomHealChance = savedChance;
}
console.log('== weapon swap (R) ==');
{
const p = ctx.G.player;
p.weapon = { id: 'bonepopper', ammo: Infinity };
p.holstered = null;
tap('r');
check('R with no special does nothing', p.weapon.id === 'bonepopper' && !p.holstered);
ctx.spawnPickup('weapon', p.x, p.y, { wid: 'repeater' });
step(2, 16);
check('picked up special', p.weapon.id === 'repeater');
p.weapon.ammo = 77;
const playSwapToast = ctx.G.toasts[ctx.G.toasts.length - 1];
tap('r');
check('R swaps to pistol', p.weapon.id === 'bonepopper' && p.holstered && p.holstered.id === 'repeater');
check('play weapon swap emits a toast', ctx.G.toasts[ctx.G.toasts.length - 1] !== playSwapToast);
check('holster keeps remaining ammo', p.holstered.ammo === 77);
tap('r');
check('R swaps back', p.weapon.id === 'repeater' && p.weapon.ammo === 77 && !p.holstered);
// picking up another special drops the old one with its ammo
ctx.spawnPickup('weapon', p.x, p.y, { wid: 'marrow' });
step(2, 16);
check('new special becomes active', p.weapon.id === 'marrow');
check('old special dropped with its ammo', ctx.G.pickups.some(k => k.type === 'weapon' && k.wid === 'repeater' && k.ammo === 77));
// dropped weapon has a collect delay so it isn't instantly re-grabbed
check('dropped weapon not instantly re-collected', p.weapon.id === 'marrow');
// dry weapon reverts to pistol but remains in the holster for later refill
p.weapon = { id: 'marrow', ammo: 0 };
step(2, 16);
check('dry special reverts to pistol and stays holstered', p.weapon.id === 'bonepopper' && p.holstered && p.holstered.id === 'marrow' && p.holstered.ammo === 0);
tap('r');
check('empty holstered special is unselectable', p.weapon.id === 'bonepopper' && p.holstered.id === 'marrow');
ctx.spawnPickup('ammo', p.x, p.y);
step(2, 16);
check('ammo refills empty holstered special', p.holstered && p.holstered.ammo > 0);
tap('r');
check('refilled special becomes selectable', p.weapon.id === 'marrow');
ctx.G.pickups.length = 0;
}
console.log('== splinter item ==');
{
const p = ctx.G.player;
const saveOrb = p.stats.orbitals;
p.stats.orbitals = 0;
ctx.giveItem('splinterbone');
check('splinter stat applied', p.stats.splinter === 2);
ctx.G.enemies.length = 0;
const e = ctx.makeEnemy('shambler', p.x + 50, p.y, 1, false);
e.spd = 0;
ctx.G.enemies.push(e);
p.weapon = { id: 'bonepopper', ammo: Infinity };
ctx.Input.mx = e.x; ctx.Input.my = e.y;
ctx.Input.mdown = true;
let sawShard = false;
for (let i = 0; i < 40 && !sawShard; i++) {
step(1, 16);
if (ctx.G.bullets.some(b => b.shard)) sawShard = true;
}
ctx.Input.mdown = false;
step(40, 16);
check('shots shatter into shards on hit', sawShard);
check('splinter stacks', (ctx.ITEMS.splinterbone.apply(p.stats, p), p.stats.splinter === 4));
ctx.G.enemies.length = 0;
ctx.G.bullets.length = 0;
p.stats.orbitals = saveOrb;
}
console.log('== boss fight ==');
ctx.nextFloor();
step(10, 16);
check('floor 2', ctx.G.floor === 2);
// jump to boss room
const bossRoom = Object.values(ctx.G.rooms).find(r => r.type === 'boss');
check('boss room exists', !!bossRoom);
ctx.enterRoom(bossRoom.gx, bossRoom.gy);
step(10, 16);
check('boss spawned', !!ctx.G.boss);
check('boss hp is finite', ctx.G.boss && isFinite(ctx.G.boss.hp));
check('boss music playing', ctx.Music.current && ctx.Music.current.name.includes('Boss'));
// whittle the boss down (also exercises telegraphs/patterns)
for (let g2 = 0; g2 < 400 && ctx.G.boss; g2++) {
for (const e of [...ctx.G.enemies]) ctx.damageEnemy(e, 50, 0, false);
step(20, 16);
}
check('boss dead', !ctx.G.boss);
check('boss death clears remaining minions', ctx.G.enemies.length === 0);
check('stairs spawned', ctx.G.pickups.some(p => p.type === 'stairs') || ctx.G.mode !== 'play');
check('boss drops a weapon', ctx.G.pickups.some(p => p.type === 'weapon'));
check('boss reward pedestal', ctx.G.pickups.some(p => p.type === 'item'));
console.log('== stairs persist when leaving and re-entering the boss room ==');
{
const bRoom = ctx.G.cur;
check('currently in boss room', bRoom.type === 'boss');
const bdir = Object.keys(bRoom.doors).find(d => bRoom.doors[d]);
const [bdx, bdy] = DIRS[bdir];
ctx.enterRoom(bRoom.gx + bdx, bRoom.gy + bdy);
step(5, 16);
check('left the boss room', ctx.G.cur !== bRoom);
ctx.enterRoom(bRoom.gx, bRoom.gy);
step(5, 16);
check('back in boss room', ctx.G.cur === bRoom);
check('stairs still there', ctx.G.pickups.some(p => p.type === 'stairs'));
check('pedestal item still there', ctx.G.pickups.some(p => p.type === 'item'));
}
console.log('== next floor via stairs ==');
const stairs = ctx.G.pickups.find(p => p.type === 'stairs');
if (stairs) {
ctx.G.player.x = stairs.x; ctx.G.player.y = stairs.y;
step(5, 16);
check('floor advanced', ctx.G.floor === 3);
}
console.log('== hazards / telegraphs / enemy bullets ==');
ctx.G.hazards.push({ kind: 'acid', x: 200, y: 200, r: 30, life: 2, t: 0, dps: 10 });
ctx.G.hazards.push({ kind: 'trap', x: 300, y: 300, r: 16, life: 2, t: 0, dmg: 5 });
ctx.G.telegraphs.push({ kind: 'knives', x: 400, y: 400, r: 34, t: 0, dur: 0.05, tick: 0 });
step(60, 16);
check('hazards/telegraphs resolved', ctx.G.hazards.length >= 0 && ctx.G.telegraphs.length >= 0);
console.log('== nine-boss roster and cycle mechanics ==');
{
ctx.G.player.invT = 0;
const kinds = [];
for (let floor = 1; floor <= 9; floor++) {
ctx.G.floor = floor;
ctx.G.enemies.length = 0; ctx.G.ebullets.length = 0; ctx.G.telegraphs.length = 0; ctx.G.hazards.length = 0; ctx.G.boss = null;
const boss = ctx.spawnBoss(floor);
kinds.push(boss.bossKind);
for (let i = 0; i < 180; i++) {
ctx.updateBoss(boss, 1 / 60);
ctx.updateTelegraphs(1 / 60);
ctx.updateBullets(1 / 60);
ctx.updateHazards(1 / 60);
}
check(boss.name + ' survives a pattern soak', Number.isFinite(boss.x) && Number.isFinite(boss.y) && Number.isFinite(boss.hp));
}
check('all nine boss kinds rotate without duplicates', new Set(kinds).size === 9 && kinds.length === 9);
check('every boss has a two-track music pair', ctx.BOSS_DEFS.every(d => ctx.Music.TRACKS.bosses[d.kind] && ctx.Music.TRACKS.bosses[d.kind].length === 2));
check('all 18 boss themes are selectable in the jukebox', Object.values(ctx.Music.TRACKS.bosses).flat().every(f => ctx.Music.PLAYLIST.includes(f)));
check('all new boss stills and sheets are in the sprite manifest', ctx.BOSS_DEFS.every(d => ctx.SPRITE_MANIFEST.includes(d.sprite) && ctx.SPRITE_MANIFEST.includes(d.sprite + '_sheet')));
// Gore Crown repeats on floor 11 with all original cycle gates still active.
ctx.G.floor = 11; ctx.G.enemies.length = 0; ctx.G.ebullets.length = 0; ctx.G.boss = null;
const g = ctx.spawnBoss(11);
check('gorecrown repeats with debut-relative tier', g.bossKind === 'gorecrown' && g.cycle === 3 && g.tier === 3);
let sawRing = false, sawVolley = false;
for (let i = 0; i < 6; i++) {
g.atkT = 0;
const before = ctx.G.ebullets.length;
ctx.updateBoss(g, 0.001);
const batch = ctx.G.ebullets.length - before;
if (batch >= 12) sawRing = true;
if (batch > 0 && batch <= 7) sawVolley = true;
}
check('cycle gorecrown still fires rings', sawRing);
check('cycle gorecrown adds aimed volleys', sawVolley);
ctx.G.floor = 200; ctx.G.enemies.length = 0; ctx.G.ebullets.length = 0; ctx.G.boss = null;
const deepGore = ctx.spawnBoss(200);
let deepestBatch = 0;
for (let i = 0; i < 6; i++) {
deepGore.atkT = 0; const before = ctx.G.ebullets.length; ctx.updateBoss(deepGore, 0.001);
deepestBatch = Math.max(deepestBatch, ctx.G.ebullets.length - before);
}
check('deep-floor gore rings stay bounded', deepestBatch <= 80);
ctx.G.floor = 12; ctx.G.enemies.length = 0; ctx.G.boss = null; ctx.G.telegraphs.length = 0;
const k1 = ctx.spawnBoss(12); k1.atkT = 0; ctx.updateBoss(k1, 0.001);
check('cycle knife telegraph hunts the player', k1.bossKind === 'knifecrawl' && ctx.G.telegraphs.some(t => t.track > 0));
ctx.G.floor = 21; ctx.G.enemies.length = 0; ctx.G.boss = null; ctx.G.telegraphs.length = 0; ctx.G.ebullets.length = 0;
const k2 = ctx.spawnBoss(21); k2.atkT = 0; ctx.updateBoss(k2, 0.001);
check('cycle knifecrawl fires cleaver volley', ctx.G.ebullets.some(b => b.sprite === 'bullet_cleaver'));
ctx.G.floor = 30; ctx.G.enemies.length = 0; ctx.G.boss = null;
const k3 = ctx.spawnBoss(30); k3.state = 'dash'; k3.stateT = 0.001; k3.dashes = 2; k3.chargeAng = 0;
ctx.updateBoss(k3, 0.01);
check('cycle knifecrawl chain-dashes', k3.state === 'dash' && k3.dashes === 1);
ctx.G.floor = 3;
ctx.G.enemies.length = 0; ctx.G.boss = null; ctx.G.telegraphs.length = 0; ctx.G.ebullets.length = 0; ctx.G.hazards.length = 0;
}
console.log('== new boss combat primitives ==');
{
const p = ctx.G.player;
p.hp = p.stats.maxHp = 999; p.invT = 0; p.stats.crit = 0;
ctx.G.floor = 7; ctx.G.enemies.length = 0; ctx.G.boss = null;
const plate = ctx.spawnBoss(7);
const hpBefore = plate.hp, rawToBreak = plate.maxHp * 0.06 / plate.dmgTakenMul + 1;
ctx.damageEnemy(plate, rawToBreak, 0, false);
check('Plate Father armor mitigates damage', plate.hp > hpBefore - rawToBreak && plate.dmgTakenMul === 0.25);
check('breaking a plate staggers the boss', plate.plates === 3 && plate.rootT > 0);
const atkBefore = plate.atkT;
ctx.updateEnemies(0.1);
check('plate stagger pauses the boss state machine', plate.atkT === atkBefore);
const savedStun = p.stats.stunOnHit;
const realChance = ctx.chance;
ctx.__smokeRealChance = realChance;
vm.runInContext('chance = () => true', ctx);
p.stats.stunOnHit = 1; plate.stunT = 0;
ctx.damageEnemy(plate, 1, 0, false, { noCrit: true });
check('rapid stun items cannot permanently lock bosses', plate.stunT === 0);
p.stats.stunOnHit = savedStun;
vm.runInContext('chance = __smokeRealChance', ctx);
delete ctx.__smokeRealChance;
ctx.G.floor = 5; ctx.G.enemies.length = 0; ctx.G.boss = null;
const flenser = ctx.spawnBoss(5); flenser.phased = true;
const flenserHp = flenser.hp;
ctx.damageEnemy(flenser, 100, 0, false);
check('phased Flenser is untargetable', flenser.hp === flenserHp && ctx.nearestEnemy(flenser.x, flenser.y, 500) === null);
const deadAnchor = { x: 300, y: 300, hp: 0 };
ctx.G.enemies.length = 0;
ctx.G.ebullets = [{ x: 320, y: 300, vx: 0, vy: 0, r: 6, dmg: 1, life: 2, t: 0,
sprite: 'bullet_harpoon', behavior: 'orbit', anchor: deadAnchor, orbA: 0, orbR: 20, orbSpd: 1 }];
ctx.updateBullets(0.016);
check('orphaned orbit hooks release safely', ctx.G.ebullets[0] && ctx.G.ebullets[0].behavior !== 'orbit' && ctx.G.ebullets[0].anchor === null);
p.x = 400; p.y = 300; p.invT = 0;
const hazardHp = p.hp;
ctx.G.hazards = [{ kind: 'acid', hostile: true, x: p.x, y: p.y, r: 30, life: 2, t: 0, tick: 0, dmg: 1, dps: 0 }];
ctx.updateHazards(0.016);
check('hostile boss hazards damage the player', p.hp === hazardHp - 1);
p.invT = 0;
const safeHp = p.hp;
ctx.G.hazards = [{ kind: 'acid', x: p.x, y: p.y, r: 30, life: 2, t: 0, dps: 0 }];
ctx.updateHazards(0.016);
check('ordinary weapon hazards remain player-safe', p.hp === safeHp);
ctx.G.floor = 9; ctx.G.enemies.length = 0; ctx.G.boss = null; ctx.G.telegraphs.length = 0; ctx.G.hazards.length = 0;
const scald = ctx.spawnBoss(9); scald.hp = scald.maxHp * 0.2;
for (let i = 0; i < 10; i++) { scald.atkT = 0; ctx.updateBoss(scald, 0.001); }
check('Scald hostile pool count is capped', ctx.G.telegraphs.filter(t => t.kind === 'pool').length <= 14);
ctx.G.floor = 8; ctx.G.enemies.length = 0; ctx.G.boss = null;
const cleanupBoss = ctx.spawnBoss(8);
ctx.G.telegraphs.push({ kind: 'sweep', owner: cleanupBoss, t: 0, dur: 2, ang: 0, spin: 1, len: 100, w: 20, arms: 1, tick: 0 });
ctx.G.ebullets.push({ x: 100, y: 100 });
ctx.G.hazards.push({ kind: 'acid', hostile: true, x: 100, y: 100, r: 20, life: 2, t: 0 });
ctx.onBossDeath(cleanupBoss);
check('boss death clears persistent hostile attacks', ctx.G.telegraphs.length === 0 && ctx.G.ebullets.length === 0 && !ctx.G.hazards.some(h => h.hostile));
ctx.G.enemies.length = 0; ctx.G.boss = null; ctx.G.hazards.length = 0; ctx.G.pickups.length = 0;
p.hp = Math.min(p.hp, p.stats.maxHp);
}
console.log('== item rooms sometimes stock weapons ==');
{
let withWeapon = 0;
for (let trial = 0; trial < 20; trial++) {
ctx.genFloor(1);
const ir = Object.values(ctx.G.rooms).find(r => r.type === 'item');
if (!ir) continue;
ctx.enterRoom(ir.gx, ir.gy);
if (ctx.G.pickups.some(k => k.type === 'weapon')) withWeapon++;
}
check('item rooms sometimes have weapons (' + withWeapon + '/20)', withWeapon >= 1 && withWeapon <= 19);
// restore a sane run state for later sections
ctx.startRun();
step(5, 16);
}
console.log('== performance and duplicate-hit regressions ==');
{
ctx.startRun();
ctx.G.player.stats.crit = 0;
ctx.G.enemies.length = 0; ctx.G.bullets.length = 0; ctx.G.hazards.length = 0;
const victim = ctx.makeEnemy('shambler', 400, 300, 1, false);
victim.hp = 1;
ctx.G.enemies.push(victim);
ctx.G.hazards.push({ kind: 'acid', x: 400, y: 300, r: 30, life: 2, t: 0, dps: 8 });
const killsBefore = ctx.G.kills;
ctx.damageEnemy(victim, 10, 0, false);
ctx.updateHazards(0.016);
check('same-frame hazard cannot double-kill', ctx.G.kills === killsBefore + 1);
const target = ctx.makeEnemy('shambler', 500, 300, 1, false);
target.hp = target.maxHp = 100;
ctx.G.enemies = [target];
ctx.G.bullets = [{ x: 500, y: 300, vx: 0, vy: 0, ang: 0, r: 5, dmg: 7,
pierce: 5, bounce: 0, bounces: 0, life: 1, t: 0, behavior: 'bullet', sprite: 'bullet_bone', hit: null }];
ctx.updateBullets(0.016);
const hpAfterFirst = target.hp;
ctx.updateBullets(0.016);
check('piercing shot hits one target only once', target.hp === hpAfterFirst);
ctx.G.parts.length = 0;
ctx.spawnExplosionFx(300, 300, 90);
check('explosions create layered shockwaves', ctx.G.parts.filter(p => p.type === 'shockwave').length === 2);
check('explosions create flame and smoke', ctx.G.parts.some(p => p.type === 'flame') && ctx.G.parts.some(p => p.type === 'smoke'));
ctx.G.parts.length = 0;
ctx.spawnMuzzleFx(300, 300, 0, 'flame');
check('flame weapons emit layered muzzle particles', ctx.G.parts.some(p => p.type === 'flame') && ctx.G.parts.some(p => p.type === 'smoke'));
ctx.G.parts.length = 0;
ctx.spawnBeam(100, 100, 0, 200);
ctx.updateParticles(0.016);
const beamParticle = ctx.G.parts.find(p => p.type === 'beam');
check('static beam particles remain finite', beamParticle && Number.isFinite(beamParticle.x) && Number.isFinite(beamParticle.y));
ctx.G.parts.length = 0;
for (let i = 0; i < 1000; i++) ctx.addParticle({ type: 'spark', x: 0, y: 0, vx: 0, vy: 0, life: 1, t: 0, r: 1 });
check('cosmetic particles are capped', ctx.G.parts.length <= 850);
}
console.log('== death and restart ==');
ctx.G.player.invT = 0;
ctx.hurtPlayer(9999, 0);
step(5, 16);
check('game over', ctx.G.mode === 'gameover');
check('death animation advances once per game-over frame', Math.abs(ctx.G.player.deathT - 0.08) < 0.001);
tap('r');
step(5, 16);
check('restart works', ctx.G.mode === 'play' && ctx.G.floor === 1);
console.log('== pause / mute ==');
tap('p');
check('paused', ctx.G.mode === 'pause');
ctx.G.player.weapon = { id: 'bonepopper', ammo: Infinity };
ctx.G.player.holstered = { id: 'repeater', ammo: 20 };
const pausedSwapToast = ctx.G.toasts[ctx.G.toasts.length - 1];
tap('r');
check('weapon swap works while paused', ctx.G.player.weapon.id === 'repeater');
check('paused weapon swap stays silent', ctx.G.toasts[ctx.G.toasts.length - 1] === pausedSwapToast);
tap('r');
tap('p');
check('unpaused', ctx.G.mode === 'play');
console.log('== pause help screen ==');
tap('p');
check('paused for help', ctx.G.mode === 'pause');
tap('h');
check('H opens the field manual', ctx.G.pauseHelp === true);
step(3, 16);
tap('arrowright');
check('arrow advances manual page', ctx.G.helpPage === 1);
tap('4');
check('number keys jump to a manual page', ctx.G.helpPage === 3);
tap('escape');
check('escape closes manual but stays paused', ctx.G.pauseHelp === false && ctx.G.mode === 'pause');
tap('h'); tap('h');
check('H toggles the manual closed', ctx.G.pauseHelp === false);
tap('p');
check('unpaused after manual', ctx.G.mode === 'play');
tap('m');
check('muted', ctx.G.muted === true);
console.log('== jukebox ==');
ctx.Music.cycle(1);
check('manual track selected', ctx.G.musicSel === 0 && ctx.Music.override === ctx.Music.PLAYLIST[0]);
check('manual track playing', ctx.Music.current.name === ctx.Music.PLAYLIST[0]);
tap('n'); // N hotkey during play
check('N cycles forward', ctx.G.musicSel === 1 && ctx.Music.current.name === ctx.Music.PLAYLIST[1]);
ctx.nextFloor(); step(5, 16);
check('override survives floor change', ctx.Music.current.name === ctx.Music.PLAYLIST[1]);
const jbr = Object.values(ctx.G.rooms).find(r => r.type === 'boss');
ctx.enterRoom(jbr.gx, jbr.gy); step(5, 16);
check('boss track interrupts override', ctx.Music.current.name.includes('Boss'));
ctx.Music.cycle(1); // cycling during a boss must not cut the boss track
check('boss track not interrupted', ctx.Music.current.name.includes('Boss'));
for (const e of [...ctx.G.enemies]) ctx.damageEnemy(e, 99999, 0, false);
step(10, 16);
check('returns to override after boss', ctx.Music.current.name === ctx.Music.PLAYLIST[2]);
while (ctx.G.musicSel !== -1) ctx.Music.cycle(1);
check('wrapped back to auto', ctx.Music.override === null);
step(5, 16);
check('auto plays a playlist track', ctx.Music.PLAYLIST.includes(ctx.Music.current.name));
step(120, 16); // idle soak
console.log(failures === 0 ? '\nALL CHECKS PASSED' : '\n' + failures + ' CHECKS FAILED');
process.exit(failures === 0 ? 0 : 1);