// 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', '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('== 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'); 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);