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.
1165 lines
53 KiB
JavaScript
1165 lines
53 KiB
JavaScript
// 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);
|