1894 lines
89 KiB
JavaScript
1894 lines
89 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', 'debug', '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, ACTIVES, PERKS, ENEMY_TYPES, BOSS_DEFS, SPRITE_MANIFEST, Sprites, ACTOR_ANIMS, PRESSURE_UNIT, PRESSURE_MIN, PRESSURE_MAX, PRESSURE_DIAL_MIN, PRESSURE_DIAL_MAX, ROOM_SHAPES, ROOM_THEMES, HELP_PAGES, HELP_RENDERERS, SPAWN_WARN, ENTRY_WARN, ENTRY_BUFFER, STUN_UNIT, stunChance, separateEntryWave, defaultPlayerStats, applyPressureDelta, debugRebuildStats, debugEnabled, DEBUG_PAGES, updateCamera, mxW, myW, angleTo, genFloor, roomBounds })', 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('quantitative tier rebalance: 2/4/5/4 distribution', [0, 1, 2, 3].every(t => Object.values(ctx.WEAPONS).filter(w => w.tier === t).length === [2, 4, 5, 4][t]));
|
||
check('every drop tier has at least one weapon', [0, 1, 2, 3].every(t => Object.values(ctx.WEAPONS).some(w => w.tier === t)));
|
||
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);
|
||
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));
|
||
press('d'); step(60, 16); release('d');
|
||
step(30, 16);
|
||
ctx.G.cur.decals.splice(decalStart);
|
||
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('== variable room shapes and themes ==');
|
||
{
|
||
const generated = Object.values(ctx.G.rooms);
|
||
check('generated rooms all have valid shapes and themes', generated.every(r => ctx.ROOM_SHAPES[r.shape] && ctx.ROOM_THEMES[r.theme]));
|
||
check('start and boss rooms retain full arenas', generated.filter(r => r.type === 'start' || r.type === 'boss').every(r => r.shape === 'hall'));
|
||
check('item rooms use compact chambers', generated.filter(r => r.type === 'item').every(r => r.shape === 'chamber'));
|
||
const savedRoom = ctx.G.cur, savedArena = ctx.G.arena;
|
||
const px = ctx.G.player.x, py = ctx.G.player.y;
|
||
let allBounds = true, allSpawns = true, allDraw = true;
|
||
for (const shape of Object.keys(ctx.ROOM_SHAPES)) {
|
||
const shaped = ctx.makeRoom(77, 88, 'combat');
|
||
shaped.shape = shape; shaped.theme = 'oxide'; shaped.doors = {};
|
||
ctx.G.cur = shaped; ctx.setArenaForRoom(shaped);
|
||
const a = ctx.G.arena;
|
||
ctx.G.player.x = -999; ctx.G.player.y = -999; ctx.Input.mdown = false;
|
||
ctx.updatePlayer(0.016);
|
||
allBounds = allBounds && ctx.G.player.x >= a.x0 + ctx.G.player.r && ctx.G.player.x <= a.x1 - ctx.G.player.r
|
||
&& ctx.G.player.y >= a.y0 + ctx.G.player.r && ctx.G.player.y <= a.y1 - ctx.G.player.r;
|
||
ctx.G.player.x = a.cx; ctx.G.player.y = a.cy;
|
||
for (let i = 0; i < 20; i++) {
|
||
const pos = ctx.spawnPosAwayFromPlayer();
|
||
allSpawns = allSpawns && pos.x >= a.x0 && pos.x <= a.x1 && pos.y >= a.y0 && pos.y <= a.y1;
|
||
}
|
||
try { ctx.drawFloorTiles(fakeCtx(), shaped); ctx.drawWalls(fakeCtx(), shaped); } catch (err) { allDraw = false; }
|
||
}
|
||
check('player clamps inside every room shape', allBounds);
|
||
check('enemy spawn positions stay inside every room shape', allSpawns);
|
||
check('every room shape and theme renders headlessly', allDraw);
|
||
ctx.G.cur = savedRoom; ctx.G.arena = savedArena; ctx.G.player.x = px; ctx.G.player.y = py;
|
||
}
|
||
|
||
console.log('== big rooms + camera ==');
|
||
{
|
||
// fit rooms keep camera pinned at (0,0) — current look is unchanged
|
||
const fit = ctx.makeRoom(0, 0, 'combat'); fit.shape = 'hall';
|
||
ctx.setArenaForRoom(fit);
|
||
ctx.G.player.x = 100; ctx.G.player.y = 320;
|
||
ctx.updateCamera();
|
||
check('fit rooms keep camera at origin', ctx.G.cam.x === 0 && ctx.G.cam.y === 0);
|
||
|
||
// wide room: camera follows the player, clamped to the room
|
||
const wide = ctx.makeRoom(0, 0, 'combat'); wide.shape = 'grand_hall';
|
||
ctx.setArenaForRoom(wide);
|
||
const wa = ctx.G.arena;
|
||
ctx.G.player.x = 100; ctx.G.player.y = 320; ctx.updateCamera();
|
||
check('camera clamps at left arena edge', ctx.G.cam.x === wa.x0);
|
||
ctx.G.player.x = wa.x1 - 200; ctx.G.player.y = 320; ctx.updateCamera();
|
||
check('camera clamps at right arena edge', ctx.G.cam.x === wa.x1 - ctx.W);
|
||
ctx.G.player.x = wa.cx; ctx.G.player.y = 320; ctx.updateCamera();
|
||
check('camera centers on the player mid-room', ctx.G.cam.x === wa.cx - ctx.W / 2 && ctx.G.cam.y === 0);
|
||
|
||
// world-mouse conversion
|
||
ctx.G.cam.x = wa.cx - ctx.W / 2; ctx.Input.mx = 120; ctx.Input.my = 240;
|
||
check('screen mouse maps to world coords', ctx.mxW() === 120 + ctx.G.cam.x && ctx.myW() === 240);
|
||
|
||
// end-to-end: live in a big room — update+draw path, floor direct path, aim
|
||
ctx.startRun();
|
||
const big = ctx.G.rooms['0,0'];
|
||
big.shape = 'meat_hall';
|
||
ctx.G.cur = big; ctx.setArenaForRoom(big);
|
||
ctx.G.player.x = ctx.G.arena.x0 + 80; ctx.G.player.y = ctx.G.arena.y0 + 80;
|
||
ctx.G.enemies.length = 0;
|
||
ctx.Input.mx = 300; ctx.Input.my = 200; ctx.Input.mdown = true;
|
||
step(3, 16); // runs update() (firing, weapon lob path) + draw() (direct floor, camera)
|
||
check('big room runs without error and aims at world mouse',
|
||
Math.abs(ctx.G.player.aim - ctx.angleTo(ctx.G.player.x, ctx.G.player.y, ctx.Input.mx + ctx.G.cam.x, ctx.Input.my + ctx.G.cam.y)) < 1e-9);
|
||
ctx.Input.mdown = false;
|
||
ctx.startRun(); step(3, 16);
|
||
}
|
||
|
||
// floor >= 2 mixes big/odd rooms into combat shapes; doors stay reciprocal
|
||
{
|
||
ctx.G.floor = 6;
|
||
let sawBig = false, reciprocal = true;
|
||
for (let trial = 0; trial < 40 && !sawBig; trial++) {
|
||
ctx.genFloor(6);
|
||
for (const r of Object.values(ctx.G.rooms)) {
|
||
if (r.type === 'combat' && ['grand_hall', 'deep_hall', 'meat_hall', 'odd_hall'].includes(r.shape)) sawBig = true;
|
||
if (r.doors.n && !ctx.G.rooms[(r.gx) + ',' + (r.gy - 1)]) reciprocal = false;
|
||
if (r.doors.s && !ctx.G.rooms[(r.gx) + ',' + (r.gy + 1)]) reciprocal = false;
|
||
if (r.doors.e && !ctx.G.rooms[(r.gx + 1) + ',' + (r.gy)]) reciprocal = false;
|
||
if (r.doors.w && !ctx.G.rooms[(r.gx - 1) + ',' + (r.gy)]) reciprocal = false;
|
||
}
|
||
}
|
||
check('floor 6 mixes in big and odd rooms', sawBig);
|
||
check('big-room maps keep reciprocal doors', reciprocal);
|
||
ctx.startRun(); step(3, 16);
|
||
}
|
||
|
||
console.log('== XP / perks ==');
|
||
ctx.gainXP(100);
|
||
step(5, 16);
|
||
check('levelup triggered', ctx.G.mode === 'levelup' || ctx.G.pendingLevelups > 0);
|
||
check('perk selection is locked on first presentation', (ctx.G.selectionLock || 0) > 0);
|
||
const lockedChoices = ctx.G.perkChoices;
|
||
tap('1'); // ignored while the selection lock is active
|
||
check('locked draft ignores selection input', ctx.G.perkChoices === lockedChoices);
|
||
step(100, 16); // advance 1.6s in levelup mode, clearing the 1.5s selection lock
|
||
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);
|
||
step(100, 16); // clear the 1.5s selection lock armed by openPerkDraft above
|
||
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('== nearby-item description toast ==');
|
||
{
|
||
const p = ctx.G.player;
|
||
ctx.G.pickups.length = 0;
|
||
ctx.G.toasts.length = 0;
|
||
ctx.spawnPickup('item', p.x + 50, p.y, { iid: 'hollowpoints' });
|
||
step(1, 16); // within the 90px radius -> toast once
|
||
check('nearby item toasts its description once',
|
||
ctx.G.toasts.length === 1 && ctx.G.toasts[0].text === ctx.ITEMS.hollowpoints.name && ctx.G.toasts[0].sub === ctx.ITEMS.hollowpoints.desc);
|
||
step(4, 16); // stay adjacent, must not re-fire
|
||
check('proximity toast does not repeat while adjacent', ctx.G.toasts.length === 1);
|
||
p.x += 500; step(1, 16); // leave the 90px radius -> re-arms
|
||
p.x -= 420; step(1, 16); // back within 90 (outside pickup reach) -> fires again
|
||
check('proximity toast re-arms after leaving', ctx.G.toasts.length === 2);
|
||
ctx.G.pickups.length = 0;
|
||
ctx.startRun(); step(3, 16);
|
||
}
|
||
|
||
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('== Sine Weave diminishing stagger ==');
|
||
{
|
||
const s = {}; ctx.PERKS.find(p => p.id === 'sinew').apply(s, {});
|
||
check('sine weave first stack is 3% stagger chance', Math.abs(ctx.stunChance(s.stunRaw) - 0.03) < 1e-6);
|
||
check('sine weave chance never exceeds 50%', ctx.stunChance(1e6) === 0.5);
|
||
check('sine weave gains diminishing returns', ctx.stunChance(s.stunRaw * 2) < 2 * ctx.stunChance(s.stunRaw));
|
||
ctx.PERKS.find(p => p.id === 'sinew').apply(s, {});
|
||
check('sine weave stacks raw rating', Math.abs(s.stunRaw - 2 * (0.03 / 0.97)) < 1e-9);
|
||
}
|
||
|
||
console.log('== expanded perk and item rosters ==');
|
||
{
|
||
check('41 new items added (14 phase-2 + 12 phase-3 + 10 legendaries)', Object.keys(ctx.ITEMS).length === 82);
|
||
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', 'chillgland', 'hookedsinew', 'gyroscopicribs', 'marrowpiston', 'splitcortex', 'gristlecord', 'renderedfat', 'whipcordtendon', 'rusteddiadem', 'gorgedtick', 'bonemealpowder', 'rimedfang', 'butcherstwine', 'cindersump', 'deadweight', 'cauterizedveins', 'hollowchoir', 'sawbonecoil', 'gluttonsgut', 'slaughterrhythm', 'painengine', 'thresherplate', 'bloodmoat', 'ironlung', 'meathook', 'blooddebt', 'butchersoath', 'secondskin', 'twinsidearm', 'crimsonmetronome', 'abattoirengine', 'gorecrown', 'thousandteeth', 'hollowfather', 'thelastcut', 'meatgrinder'];
|
||
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 < (ctx.ITEMS[id].cap ?? 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('== scrap feed has diminishing returns (like thick hide) ==');
|
||
{
|
||
const sf = ctx.PERKS.find(k => k.id === 'scrapfeed');
|
||
const s = { ammoEff: 1 };
|
||
const save = () => 1 - 1 / s.ammoEff; // fraction of ammo saved
|
||
sf.apply(s, {}); const s1 = save();
|
||
const d1 = s1; // marginal saving of stack 1
|
||
sf.apply(s, {}); const s2 = save();
|
||
const d2 = s2 - s1; // marginal saving of stack 2
|
||
sf.apply(s, {}); const s3 = save();
|
||
const d3 = s3 - s2; // marginal saving of stack 3
|
||
check('first stack ~5% saving', Math.abs(s1 - (1 - 1 / 1.05)) < 0.0001);
|
||
check('each stack helps less', d2 < d1 && d3 < d2);
|
||
check('saving never reaches 100%', save() < 1);
|
||
// Brass Magazine uses the same diminishing additive formula as Scrap Feed
|
||
const s2x = { ammoEff: 1, ammoPickupMul: 1 };
|
||
ctx.ITEMS.brassmagazine.apply(s2x, {});
|
||
check('brass magazine is additive-diminishing (not multiplicative)', Math.abs(s2x.ammoEff - 1.10) < 0.0001);
|
||
const s2y = { ammoEff: 1, ammoPickupMul: 1 };
|
||
for (let i = 0; i < 5; i++) ctx.ITEMS.brassmagazine.apply(s2y, {});
|
||
check('brass magazine never reaches 50% saving', 1 - 1 / s2y.ammoEff < 0.5);
|
||
}
|
||
|
||
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 (per-item tier caps) ==');
|
||
{
|
||
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('six copies reach the Hollow Points cap (VI)', p.items.hollowpoints === 6);
|
||
check('scaling stops at the cap', Math.abs(p.stats.dmgMul - dmgBefore * Math.pow(1.25, 5)) < 0.01);
|
||
const scoreBefore = ctx.G.score;
|
||
const dmgAtCap = p.stats.dmgMul;
|
||
ctx.giveItem('hollowpoints');
|
||
check('seventh copy capped, converts to score', p.items.hollowpoints === 6 && 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('== rarity pools and per-item caps ==');
|
||
{
|
||
const p = ctx.G.player;
|
||
p.items = {};
|
||
// legendaries never come from elite or item-room pools
|
||
let sawLegendary = false;
|
||
for (let i = 0; i < 400; i++) {
|
||
const eid = ctx.rollItemId('elite', 1), rid = ctx.rollItemId('room', 1);
|
||
if (ctx.ITEMS[eid].rarity === 'legendary' || ctx.ITEMS[rid].rarity === 'legendary') sawLegendary = true;
|
||
}
|
||
check('elite and room pools never roll legendaries', !sawLegendary);
|
||
// boss pool can roll rare (and would roll legendary when any exist)
|
||
let bossRares = 0;
|
||
for (let i = 0; i < 200; i++) if (ctx.ITEMS[ctx.rollItemId('boss', 3)].rarity === 'rare') bossRares++;
|
||
check('boss pool favours rare items (' + bossRares + '/200)', bossRares > 40);
|
||
// capped items are excluded from fresh rolls
|
||
p.items = { splittongue: 3 }; // rare cap 3
|
||
let reoffered = false;
|
||
for (let i = 0; i < 300; i++) if (ctx.rollItemId('room', 1) === 'splittongue') reoffered = true;
|
||
check('capped items are not re-offered by pools', !reoffered);
|
||
// stickiness still favours owned upgradable items
|
||
p.items = { hollowpoints: 1 };
|
||
let owned = 0;
|
||
for (let i = 0; i < 600; i++) if (ctx.rollItemId('room', 1) === 'hollowpoints') owned++;
|
||
const frac = owned / 600;
|
||
check('stickiness ramps toward owned items (' + owned + '/600)', frac > 0.04 && frac < 0.20);
|
||
// legacy alias still resolves
|
||
check('randomItemId legacy alias works', typeof ctx.randomItemId() === 'string');
|
||
p.items = {};
|
||
}
|
||
|
||
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 >= 40);
|
||
}
|
||
|
||
console.log('== phase-4 boss-exclusive legendaries ==');
|
||
{
|
||
const p = ctx.G.player;
|
||
p.items = {};
|
||
// boss pool is the only source that can roll legendaries
|
||
let bossLegendaries = 0;
|
||
for (let i = 0; i < 400; i++) if (ctx.ITEMS[ctx.rollItemId('boss', 5)].rarity === 'legendary') bossLegendaries++;
|
||
check('boss pool can roll legendaries (' + bossLegendaries + '/400)', bossLegendaries > 10);
|
||
// Butcher's Oath: big damage, HP clamped to 2
|
||
const savedMax = p.stats.maxHp, savedHp = p.hp;
|
||
p.stats.maxHp = 10; p.hp = 10;
|
||
ctx.giveItem('butchersoath');
|
||
check('Butcher\'s Oath clamps max HP to 2', p.stats.maxHp === 2 && p.hp === 2);
|
||
p.stats.maxHp = savedMax; p.hp = savedHp; delete p.items.butchersoath;
|
||
// Second Skin: revive once per floor at ½ heart
|
||
p.stats.secondSkin = 0; p.secondSkinUsed = false;
|
||
ctx.giveItem('secondskin');
|
||
p.hp = 1; p.invT = 0; ctx.G.mode = 'play';
|
||
ctx.hurtPlayer(9999, 0);
|
||
check('Second Skin revives at ½ heart', p.hp === 1 && p.secondSkinUsed === true && ctx.G.mode === 'play');
|
||
// second death same floor is real
|
||
p.invT = 0;
|
||
ctx.hurtPlayer(9999, 0);
|
||
check('Second Skin only works once per floor', ctx.G.mode === 'gameover');
|
||
ctx.startRun(); step(3, 16);
|
||
}
|
||
|
||
console.log('== active items (room-clear charged) ==');
|
||
{
|
||
const p = ctx.G.player;
|
||
p.active = null;
|
||
// pickup grants a full-charge active
|
||
ctx.spawnPickup('active', p.x, p.y, { aid: 'bonenova' });
|
||
step(2, 16);
|
||
check('active pickup equips fully charged', p.active && p.active.iid === 'bonenova' && p.active.charges === ctx.ACTIVES.bonenova.cost);
|
||
// swap: picking up a second active drops the first
|
||
ctx.spawnPickup('active', p.x, p.y, { aid: 'offalbomb' });
|
||
step(2, 16);
|
||
check('second active equips and drops the old one', p.active.iid === 'offalbomb' && ctx.G.pickups.some(k => k.type === 'active' && k.aid === 'bonenova'));
|
||
// use: charges spend, effect fires
|
||
const powerBefore = ctx.powerScore();
|
||
check('holding an active adds flat power', powerBefore > 0);
|
||
ctx.useActive();
|
||
check('using an active spends all charges', p.active.charges === 0);
|
||
// charge accrues from room clears
|
||
ctx.recordRoomClear({ type: 'combat' });
|
||
check('combat clear grants +1 charge', p.active.charges === 1);
|
||
ctx.recordRoomClear({ type: 'boss' });
|
||
check('boss clear grants +2 charges', p.active.charges === ctx.ACTIVES.offalbomb.cost);
|
||
// under-charged use is blocked
|
||
p.active.charges = 0;
|
||
const enemiesBefore = ctx.G.enemies.length;
|
||
ctx.useActive();
|
||
check('under-charged active does not fire', p.active.charges === 0 && ctx.G.enemies.length === enemiesBefore);
|
||
// panic room blocks firing
|
||
p.active = { iid: 'panicroom', charges: ctx.ACTIVES.panicroom.cost };
|
||
ctx.useActive();
|
||
check('panic room grants invulnerability', p.invT >= 2.4 && p.panicRoomT > 0);
|
||
p.weapon = { id: 'bonepopper', ammo: Infinity };
|
||
p.fireT = 0;
|
||
ctx.Input.mdown = true; step(3, 16); ctx.Input.mdown = false;
|
||
check('panic room prevents firing', ctx.G.bullets.length === 0);
|
||
p.panicRoomT = 0; p.invT = 1;
|
||
p.active = null;
|
||
ctx.G.pickups.length = 0;
|
||
}
|
||
|
||
console.log('== phase-3 hook passives ==');
|
||
{
|
||
const p = ctx.G.player;
|
||
const s = p.stats;
|
||
// Dead Weight: execute bonus on wounded enemies (isolate stats so no leftover items leak)
|
||
const savedStats = s;
|
||
p.stats = Object.assign({}, s, { executeBonus: 0.40, burnDamageBonus: 0, crit: 0, critMul: 2 });
|
||
const wounded = ctx.makeEnemy('shambler', 500, 300, 1, false); wounded.hp = wounded.maxHp = 100; wounded.hp = 20; // 20% of maxHp
|
||
const healthy = ctx.makeEnemy('shambler', 500, 300, 1, false); healthy.hp = healthy.maxHp = 100;
|
||
const wBefore = wounded.hp, hBefore = healthy.hp;
|
||
ctx.damageEnemy(wounded, 10, 0, false, { noCrit: true });
|
||
ctx.damageEnemy(healthy, 10, 0, false, { noCrit: true });
|
||
check('Dead Weight executes wounded enemies', Math.abs((wBefore - wounded.hp) - 14) < 0.01 && Math.abs((hBefore - healthy.hp) - 10) < 0.01);
|
||
p.stats = savedStats;
|
||
|
||
// Cauterized Veins: bonus damage to burning enemies
|
||
const savedBurn = s.burnDamageBonus;
|
||
s.burnDamageBonus = 0.25;
|
||
const burner = ctx.makeEnemy('shambler', 500, 300, 1, false); burner.burnT = 1;
|
||
const bBefore = burner.hp;
|
||
ctx.damageEnemy(burner, 10, 0, false, { noCrit: true });
|
||
check('Cauterized Veins boosts damage on burning enemies', Math.abs((bBefore - burner.hp) - 12.5) < 0.01);
|
||
s.burnDamageBonus = savedBurn;
|
||
ctx.G.enemies.length = 0;
|
||
|
||
// Pain Engine: dmgLiveMul rises after being hit
|
||
const savedPain = s.painEngine, savedArmor = s.armor, savedMax = s.maxHp;
|
||
s.painEngine = 0.30; s.armor = 0; s.maxHp = 999; p.hp = 999; p.invT = 0; p.painEngineT = 0; ctx.G.mode = 'play';
|
||
ctx.hurtPlayer(1, 0);
|
||
check('Pain Engine arms after being hit', p.painEngineT > 0);
|
||
s.painEngine = savedPain; s.armor = savedArmor; s.maxHp = savedMax; p.invT = 1; p.painEngineT = 0;
|
||
|
||
// Iron Lung: first hit in a room is blocked
|
||
const savedLung = s.ironLung;
|
||
s.ironLung = 1; p.ironLungReady = true; p.invT = 0; const hpBeforeLung = p.hp;
|
||
ctx.hurtPlayer(1, 0);
|
||
check('Iron Lung blocks the first room hit', p.hp === hpBeforeLung && p.ironLungReady === false);
|
||
s.ironLung = savedLung; p.invT = 1;
|
||
|
||
// Slaughter Rhythm: recent kills grant fire rate
|
||
const savedRhythm = s.slaughterRhythm;
|
||
s.slaughterRhythm = 0.04; p.killStamps = [ctx.G.time, ctx.G.time, ctx.G.time];
|
||
p.fireT = 0; ctx.updatePlayer(0.016);
|
||
check('Slaughter Rhythm grants live fire rate', (s.rhythmRateBonus || 0) > 0);
|
||
s.slaughterRhythm = savedRhythm; p.killStamps = [];
|
||
}
|
||
|
||
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);
|
||
|
||
check('rapid low-damage weapons receive more rounds than heavy weapons',
|
||
ctx.WEAPONS.repeater.refill > ctx.WEAPONS.spinaltap.refill * 10);
|
||
check('stream weapons are time-denominated (drain, not per-shot)',
|
||
ctx.WEAPONS.bile.drain > 0 && ctx.WEAPONS.cauterizer.drain > 0 && ctx.WEAPONS.redhand.drain > 0);
|
||
|
||
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('== ammo economy structure ==');
|
||
{
|
||
// Kills-per-refill is calibrated empirically by tools/ammo_sim.js (target
|
||
// band ~8-13). Here we guard the structural rules that keep it meaningful.
|
||
const bad = [];
|
||
for (const w of Object.values(ctx.WEAPONS)) {
|
||
if (w.ammo === Infinity) continue;
|
||
if (!Number.isInteger(w.refill) || w.refill < 1) bad.push(w.id + ':refill-missing');
|
||
else if (w.refill > w.ammo) bad.push(w.id + ':refill-exceeds-magazine');
|
||
// a full magazine must stay a usable burst: not a 1s sneeze, not endless
|
||
const seconds = w.drain ? w.ammo / w.drain
|
||
: w.ammo * (w.behavior === 'beam' ? (w.chargeTime + w.interval) : w.interval);
|
||
if (seconds < 3 || seconds > 20) bad.push(w.id + ':magazine-' + seconds.toFixed(1) + 's');
|
||
}
|
||
check('every special weapon has a sane authored refill + magazine', bad.length === 0);
|
||
if (bad.length) console.log(' BAD-AMMO: ' + bad.join(', '));
|
||
}
|
||
|
||
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 >= 6);
|
||
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);
|
||
// Rarity-weighted power: 4 perks × 0.5 + 2 common tiers × 0.5 = 3 power.
|
||
p.level = 5; p.items = { hollowpoints: 2 };
|
||
const e6 = ctx.makeEnemy('shambler', 500, 300, 1, false);
|
||
check('common item tiers weigh 0.5 power each', Math.abs(ctx.powerScore() - 3) < 0.001);
|
||
check('weighted power gives +15% hp', Math.abs(e6.hp - 26 * 1.15) < 0.01);
|
||
check('weighted power gives ~+3% speed', e6.spd >= 55 * 1.03 * 0.9 && e6.spd <= 55 * 1.03 * 1.1);
|
||
const e64 = ctx.makeEnemy('shambler', 500, 300, 4, false);
|
||
check('floor + weighted scaling stack', Math.abs(e64.hp - 26 * 1.66 * 1.15) < 0.02);
|
||
const b = ctx.spawnBoss(1);
|
||
check('boss hp scales with weighted upgrades', Math.abs(b.hp - 520 * 1.15) < 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.601; ctx.G.recentHits = []; p.hp = 3; p.invT = 0; ctx.G.mode = 'play';
|
||
ctx.hurtPlayer(1, 0);
|
||
check('pressure relief is floored at 60%', ctx.G.pressure === 0.60);
|
||
|
||
ctx.G.pressure = 9; ctx.G.roomDamaged = false; ctx.recordRoomClear({ type: 'combat' });
|
||
check('pressure is capped at 200%', ctx.G.pressure === 2.0);
|
||
ctx.startRun(); step(3, 16);
|
||
check('new runs reset pressure and streak', ctx.G.pressure === 1 && ctx.G.streak === 0);
|
||
}
|
||
|
||
console.log('== bigger gore-red minis ==');
|
||
{
|
||
check('mini hitbox grew', ctx.ENEMY_TYPES.mini.r === 14);
|
||
check('mini sprite grew', ctx.ENEMY_TYPES.mini.drawSize === 48);
|
||
}
|
||
|
||
console.log('== damaged monster health pips ==');
|
||
{
|
||
const target = ctx.makeEnemy('shambler', 160, 160, 1, false);
|
||
target.warmT = 0;
|
||
check('undamaged normal monster starts without a timed health pip', target.hpBarT === 0);
|
||
ctx.damageEnemy(target, 1, 0, false, { noProc: true, noCrit: true });
|
||
check('damaging a normal monster reveals its health pip', target.hpBarT === 2.5 && target.hp < target.maxHp);
|
||
ctx.G.enemies = [target];
|
||
ctx.updateEnemies(2.6);
|
||
check('normal monster health pip expires after the tracking window', target.hpBarT === 0);
|
||
ctx.G.enemies = [];
|
||
}
|
||
|
||
console.log('== 750ms entry-wave spawn telegraph ==');
|
||
{
|
||
ctx.startRun(); step(3, 16);
|
||
// enter a combat room to trigger a wave
|
||
const sr = ctx.G.rooms['0,0'];
|
||
const sd = Object.keys(sr.doors).find(d => sr.doors[d]);
|
||
const DIRS2 = { n: [0, -1], s: [0, 1], e: [1, 0], w: [-1, 0] };
|
||
const [ddx, ddy] = DIRS2[sd];
|
||
ctx.enterRoom(sr.gx + ddx, sr.gy + ddy);
|
||
step(1, 16);
|
||
const room2 = ctx.G.cur;
|
||
if (room2.type === 'combat' && ctx.G.enemies.length > 0) {
|
||
const spawner = ctx.G.enemies[0];
|
||
check('wave spawn is telegraphing', spawner.warmT > 0.3 && spawner.warmT <= ctx.ENTRY_WARN);
|
||
const sx = spawner.x, sy = spawner.y;
|
||
ctx.G.player.invT = 0;
|
||
const hpBefore = ctx.G.player.hp;
|
||
ctx.G.player.x = spawner.x; ctx.G.player.y = spawner.y; // overlap the sigil
|
||
step(20, 16); // ~320ms
|
||
check('telegraphed spawn is frozen', Math.abs(spawner.x - sx) < 0.01 && Math.abs(spawner.y - sy) < 0.01);
|
||
check('telegraphed spawn deals no contact damage', ctx.G.player.hp === hpBefore);
|
||
step(30, 16); // past 750ms total
|
||
check('spawn arms after the telegraph', spawner.warmT <= 0);
|
||
ctx.G.player.x = ctx.W / 2; ctx.G.player.y = ctx.H / 2; ctx.G.player.invT = 1;
|
||
} else {
|
||
check('combat room spawned for telegraph test (' + room2.type + ')', true);
|
||
}
|
||
// splitter splits stay instant
|
||
ctx.G.enemies.length = 0;
|
||
const splitParent = ctx.makeEnemy('splitter', ctx.W / 2, ctx.H / 2, 1, false);
|
||
ctx.G.enemies.push(splitParent);
|
||
ctx.damageEnemy(splitParent, 99999, 0, false);
|
||
const minis = ctx.G.enemies.filter(e => e.type === 'mini');
|
||
check('splitter splits into minis', minis.length === 2);
|
||
check('splits spawn instantly (no telegraph)', minis.every(m => (m.warmT || 0) === 0));
|
||
ctx.G.enemies.length = 0;
|
||
ctx.startRun(); step(3, 16);
|
||
}
|
||
|
||
console.log('== room-entry reaction buffer ==');
|
||
{
|
||
ctx.startRun(); step(3, 16);
|
||
// enter a combat neighbor room so a first-entry wave is staged at the arrival door
|
||
const sr = ctx.G.rooms['0,0'];
|
||
const sd = Object.keys(sr.doors).find(d => sr.doors[d]);
|
||
const DIRS3 = { n: [0, -1], s: [0, 1], e: [1, 0], w: [-1, 0] };
|
||
const [edx, edy] = DIRS3[sd];
|
||
ctx.enterRoom(sr.gx + edx, sr.gy + edy);
|
||
const er = ctx.G.cur;
|
||
if (er.type === 'combat' && ctx.G.enemies.length > 0) {
|
||
// move the player to a far corner, then run the post-teleport separation
|
||
const erA = ctx.G.arena;
|
||
ctx.G.player.x = erA.x0 + 40; ctx.G.player.y = erA.y0 + 40;
|
||
ctx.separateEntryWave();
|
||
const buf = Math.min(ctx.ENTRY_BUFFER, Math.min(erA.w, erA.h) * 0.42);
|
||
const ok = ctx.G.enemies.every(e =>
|
||
e.boss || e.hp <= 0 || ((e.x - ctx.G.player.x) ** 2 + (e.y - ctx.G.player.y) ** 2) >= (buf * 0.9) ** 2);
|
||
check('entry enemies stay past the reaction buffer', ok);
|
||
check('first-entry wave telegraphs with ENTRY_WARN', ctx.G.enemies.every(e => (e.warmT || 0) <= ctx.ENTRY_WARN));
|
||
} else {
|
||
check('combat room staged for entry-buffer test (' + er.type + ')', true);
|
||
}
|
||
ctx.G.enemies.length = 0;
|
||
ctx.startRun(); step(3, 16);
|
||
}
|
||
|
||
console.log('== five pressure-monster archetypes ==');
|
||
{
|
||
const newTypes = ['censer', 'bulwark', 'choirmaster', 'flenserling', 'broodsac'];
|
||
check('enemy roster expanded from six to eleven types', Object.keys(ctx.ENEMY_TYPES).length === 11);
|
||
check('all five new monster stills and sheets are registered', newTypes.every(type =>
|
||
ctx.SPRITE_MANIFEST.includes('enemy_' + type) && ctx.SPRITE_MANIFEST.includes('enemy_' + type + '_sheet')));
|
||
let lockedSafe = true;
|
||
for (let i = 0; i < 500; i++) if (newTypes.includes(ctx.pickEnemyType(3))) lockedSafe = false;
|
||
check('new pressure monsters stay locked through floor 3', lockedSafe);
|
||
|
||
ctx.startRun(); step(3, 16);
|
||
const p = ctx.G.player;
|
||
p.x = ctx.G.arena.cx; p.y = ctx.G.arena.cy;
|
||
|
||
const censer = ctx.makeEnemy('censer', p.x - 180, p.y, 7, false);
|
||
censer.castT = 0; ctx.G.enemies = [censer]; ctx.G.telegraphs = [];
|
||
ctx.updateEnemyAI(censer, 0.016);
|
||
check('Censer predicts and telegraphs a hostile pool', ctx.G.telegraphs.some(t => t.kind === 'pool' && t.owner === censer));
|
||
|
||
const front = ctx.makeEnemy('bulwark', 300, 300, 7, false); front.faceDir = 0;
|
||
const rear = ctx.makeEnemy('bulwark', 300, 300, 7, false); rear.faceDir = 0;
|
||
const frontBefore = front.hp, rearBefore = rear.hp;
|
||
ctx.damageEnemy(front, 10, Math.PI, false, { noProc: true, noCrit: true });
|
||
ctx.damageEnemy(rear, 10, 0, false, { noProc: true, noCrit: true });
|
||
check('Bulwark frontal armor rewards flanking', frontBefore - front.hp < (rearBefore - rear.hp) * 0.25);
|
||
|
||
const choir = ctx.makeEnemy('choirmaster', 300, 300, 7, false);
|
||
const ally = ctx.makeEnemy('shambler', 330, 300, 7, false); ally.hp = ally.maxHp / 2;
|
||
const allyHp = ally.hp; choir.supportT = 0; ctx.G.enemies = [choir, ally];
|
||
ctx.updateEnemyAI(choir, 0.016);
|
||
check('Choirmaster buffs and repairs nearby monsters', ally.choirT > 0 && ally.hp > allyHp);
|
||
|
||
const flenserling = ctx.makeEnemy('flenserling', 260, 260, 7, false);
|
||
flenserling.ambushT = 0; ctx.G.enemies = [flenserling];
|
||
ctx.updateEnemyAI(flenserling, 0.016);
|
||
const phasedHp = flenserling.hp;
|
||
const phasedHit = ctx.damageEnemy(flenserling, 10, 0, false, { noProc: true, noCrit: true });
|
||
check('Flenserling phases out and becomes untargetable', flenserling.phased && !phasedHit && flenserling.hp === phasedHp);
|
||
flenserling.phaseT = 0; ctx.updateEnemyAI(flenserling, 0.016);
|
||
check('Flenserling reappears behind the player for a lunge', !flenserling.phased && flenserling.ambushState === 'lunge');
|
||
|
||
const sac = ctx.makeEnemy('broodsac', 300, 300, 7, false);
|
||
sac.summonT = 0; ctx.G.enemies = [sac];
|
||
ctx.updateEnemyAI(sac, 0.016);
|
||
check('Brood Sac periodically spawns two minis', ctx.G.enemies.filter(e => e.type === 'mini').length === 2);
|
||
const burstSac = ctx.makeEnemy('broodsac', 400, 300, 7, false);
|
||
ctx.G.enemies = [burstSac];
|
||
ctx.damageEnemy(burstSac, 99999, 0, false, { noProc: true, noCrit: true });
|
||
check('Brood Sac death bursts into four minis', ctx.G.enemies.filter(e => e.type === 'mini').length === 4);
|
||
|
||
const cappedChoir = ctx.makeEnemy('choirmaster', 200, 200, 10, false);
|
||
const cappedSacA = ctx.makeEnemy('broodsac', 240, 200, 10, false);
|
||
const cappedSacB = ctx.makeEnemy('broodsac', 280, 200, 10, false);
|
||
ctx.G.enemies = [cappedChoir, cappedSacA, cappedSacB];
|
||
let capSafe = true;
|
||
for (let i = 0; i < 300; i++) {
|
||
const type = ctx.pickWaveEnemyType(10);
|
||
if (type === 'choirmaster' || type === 'broodsac') capSafe = false;
|
||
}
|
||
check('support and summoner per-room caps are enforced', capSafe);
|
||
|
||
const thin = ctx.makeRoom(0, 0, 'combat'); thin.shape = 'wide_hall'; thin.doors = {};
|
||
ctx.G.cur = thin; ctx.setArenaForRoom(thin); ctx.G.enemies = [];
|
||
let hallSafe = true;
|
||
for (let i = 0; i < 300; i++) {
|
||
const type = ctx.pickWaveEnemyType(10);
|
||
if (type === 'censer' || type === 'broodsac') hallSafe = false;
|
||
}
|
||
check('zone denial and summoners stay out of thin halls', hallSafe);
|
||
ctx.startRun(); step(3, 16);
|
||
}
|
||
|
||
console.log('== pressure dial scaling ==');
|
||
{
|
||
const GAIN = [-2, -1.6, -1.2, -0.8, -0.4, 0, 0.2, 0.4, 0.6, 0.8, 1, 1.8, 2.6, 3.4, 4.2, 5, 6, 7, 8, 9, 10];
|
||
const DROP = [8, 7.4, 6.8, 6.2, 5.6, 5, 4.6, 4.2, 3.8, 3.4, 3, 2.4, 1.8, 1.2, 0.6, 0, 0, 0, 0, 0, 0];
|
||
let gainOK = true, dropOK = true;
|
||
for (let d = -10; d <= 10; d++) {
|
||
if (Math.abs(ctx.pressureGainUnits(d) - GAIN[d + 10]) > 1e-6) gainOK = false;
|
||
if (Math.abs(ctx.pressureDropUnits(d) - DROP[d + 10]) > 1e-6) dropOK = false;
|
||
}
|
||
check('gain units match all 21 notches', gainOK);
|
||
check('drop units match all 21 notches', dropOK);
|
||
ctx.G.pressureDial = 10;
|
||
ctx.G.pressure = 1; ctx.G.roomDamaged = false;
|
||
ctx.recordRoomClear({ type: 'combat' });
|
||
check('dial +10 clean room adds 10%', Math.abs(ctx.G.pressure - 1.10) < 1e-4);
|
||
// dial +5: gain 0.05/clean room, zero relief on hit
|
||
ctx.G.pressureDial = 5;
|
||
ctx.G.pressure = 1; ctx.G.roomDamaged = false;
|
||
ctx.recordRoomClear({ type: 'combat' });
|
||
check('dial +5 clean room adds 5%', Math.abs(ctx.G.pressure - 1.05) < 1e-4);
|
||
ctx.G.pressure = 1.2; ctx.G.recentHits = []; ctx.G.player.hp = 5; ctx.G.player.stats.maxHp = 10;
|
||
ctx.G.player.invT = 0; ctx.G.mode = 'play';
|
||
ctx.hurtPlayer(1, 0);
|
||
check('dial +5 gives no hit relief', Math.abs(ctx.G.pressure - 1.2) < 1e-6);
|
||
// dial -5: zero gain, relief scaled 5/3 vs dial 0 for the identical hit
|
||
ctx.G.pressureDial = -5;
|
||
ctx.G.pressure = 1; ctx.G.roomDamaged = false;
|
||
ctx.recordRoomClear({ type: 'combat' });
|
||
check('dial -5 clean room adds nothing', Math.abs(ctx.G.pressure - 1) < 1e-6);
|
||
ctx.G.pressureDial = -10;
|
||
ctx.G.pressure = 1; ctx.G.roomDamaged = false;
|
||
ctx.recordRoomClear({ type: 'combat' });
|
||
check('dial -10 clean room bleeds 2% pressure', Math.abs(ctx.G.pressure - 0.98) < 1e-6);
|
||
ctx.G.pressure = 0.605; ctx.G.roomDamaged = false;
|
||
ctx.recordRoomClear({ type: 'combat' });
|
||
check('negative clean-room gain respects the 60% floor', ctx.G.pressure === 0.60);
|
||
const mkHit = (dial) => {
|
||
ctx.G.pressureDial = dial;
|
||
ctx.G.pressure = 1.2; ctx.G.recentHits = [];
|
||
ctx.G.player.hp = 10; ctx.G.player.stats.maxHp = 10; ctx.G.player.invT = 0; ctx.G.mode = 'play';
|
||
ctx.hurtPlayer(1, 0);
|
||
return 1.2 - ctx.G.pressure;
|
||
};
|
||
const reliefNeg = mkHit(-5);
|
||
const reliefExtreme = mkHit(-10);
|
||
const reliefZero = mkHit(0);
|
||
check('dial -5 relief beats dial 0 (' + reliefNeg.toFixed(4) + ' vs ' + reliefZero.toFixed(4) + ')',
|
||
reliefNeg > reliefZero && Math.abs(reliefNeg - reliefZero * (5 / 3)) < 1e-4);
|
||
check('dial -10 relief is 8/3 of dial 0', reliefExtreme > reliefNeg && Math.abs(reliefExtreme - reliefZero * (8 / 3)) < 1e-4);
|
||
// score multiplier
|
||
ctx.G.pressureDial = 0;
|
||
ctx.G.pressure = 1.5;
|
||
const scBefore = ctx.G.score; ctx.addScore(100);
|
||
check('score scales with live pressure (1.5x)', ctx.G.score - scBefore === 150);
|
||
ctx.G.pressure = 0.75;
|
||
const scBefore2 = ctx.G.score; ctx.addScore(1);
|
||
check('small awards floor at +1', ctx.G.score - scBefore2 === 1);
|
||
ctx.G.pressure = 1; ctx.G.pressureDial = 0;
|
||
ctx.startRun(); step(3, 16);
|
||
}
|
||
|
||
console.log('== item stickiness ramps with owned count ==');
|
||
{
|
||
ctx.startRun(); step(3, 16);
|
||
const p = ctx.G.player;
|
||
p.items = {}; // no owned items -> always a fresh roll
|
||
// 1 upgradable item owned -> ~10% stick
|
||
p.items = { hollowpoints: 1 };
|
||
let owned1 = 0;
|
||
for (let i = 0; i < 600; i++) if (ctx.randomItemId() === 'hollowpoints') owned1++;
|
||
const frac1 = owned1 / 600;
|
||
check('1 owned item sticks ~7% (' + owned1 + '/600)', frac1 > 0.03 && frac1 < 0.16);
|
||
// 5+ upgradable items -> 50% stick × 0.7 room multiplier ≈ 35%
|
||
p.items = { hollowpoints: 1, twitch: 1, scalpel: 1, leadmarrow: 1, piercegaze: 1 };
|
||
let owned5 = 0;
|
||
for (let i = 0; i < 600; i++) if (p.items[ctx.randomItemId()]) owned5++;
|
||
const frac5 = owned5 / 600;
|
||
check('5+ owned items stick ~35% (' + owned5 + '/600)', frac5 > 0.25 && frac5 < 0.48);
|
||
p.items = {};
|
||
ctx.startRun(); step(3, 16);
|
||
}
|
||
|
||
console.log('== help manual grows with the implant roster ==');
|
||
{
|
||
check('12 help pages (5 implants + actives)', ctx.HELP_PAGES.length === 12);
|
||
check('12 help renderers', ctx.HELP_RENDERERS.length === 12);
|
||
check('help tabs never overflow the panel', (() => {
|
||
const last = ctx.helpTabRect(ctx.HELP_PAGES.length - 1);
|
||
const first = ctx.helpTabRect(0);
|
||
return first.x >= 0 && last.x + last.w <= ctx.W;
|
||
})());
|
||
ctx.G.player = ctx.G.player || {};
|
||
const fctx = fakeCtx();
|
||
let renderOK = true;
|
||
try {
|
||
for (let i = 0; i < ctx.HELP_PAGES.length; i++) {
|
||
ctx.G.helpPage = i;
|
||
ctx.drawPauseHelp(fctx);
|
||
}
|
||
} catch (e) { renderOK = false; console.log(' render error: ' + e.message); }
|
||
check('every help page renders without throwing', renderOK);
|
||
}
|
||
|
||
console.log('== HUD alpha slider ==');
|
||
{
|
||
ctx.startRun(); step(3, 16);
|
||
ctx.setHudAlpha(0.5);
|
||
check('hud alpha sets', Math.abs(ctx.G.hudAlpha - 0.5) < 1e-6);
|
||
ctx.setHudAlpha(0.05);
|
||
check('hud alpha clamps to 25% floor', Math.abs(ctx.G.hudAlpha - 0.25) < 1e-6);
|
||
ctx.setHudAlpha(5);
|
||
check('hud alpha clamps to 100%', Math.abs(ctx.G.hudAlpha - 1) < 1e-6);
|
||
const fctx2 = fakeCtx();
|
||
let hudOK = true;
|
||
try { ctx.G.hudAlpha = 0.25; ctx.drawHUD(fctx2); } catch (e) { hudOK = false; console.log(' hud error: ' + e.message); }
|
||
check('HUD renders at 25% opacity', hudOK);
|
||
ctx.G.hudAlpha = 1;
|
||
ctx.startRun(); step(3, 16);
|
||
}
|
||
|
||
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 = 5;
|
||
ctx.spawnPickup('weapon', p.x, p.y, { wid: 'repeater' });
|
||
step(2, 16);
|
||
check('duplicate active weapon refills ammo instead of replacing it', p.weapon.id === 'repeater' && p.weapon.ammo > 5 && !p.holstered);
|
||
check('duplicate active weapon does not create a dropped-weapon loop', !ctx.G.pickups.some(k => k.type === 'weapon'));
|
||
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);
|
||
p.holstered.ammo = 5;
|
||
ctx.spawnPickup('weapon', p.x, p.y, { wid: 'repeater' });
|
||
step(2, 16);
|
||
check('duplicate holstered weapon refills ammo without forcing a swap', p.weapon.id === 'bonepopper' && p.holstered.id === 'repeater' && p.holstered.ammo > 5);
|
||
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 stairs begin with a three-second safety seal', ctx.G.pickups.some(p => p.type === 'stairs' && p.delay > 0 && p.delay <= 3));
|
||
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('sealed boss stairs cannot advance the floor', ctx.G.floor === 2);
|
||
step(190, 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);
|
||
check('death starts a three-second input lock', ctx.G.deathLockT === 3);
|
||
check('player death creates a layered gore burst', ctx.G.parts.some(p => p.type === 'gib') && ctx.G.parts.some(p => p.type === 'shockwave'));
|
||
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');
|
||
check('restart input is ignored during death lock', ctx.G.mode === 'gameover');
|
||
step(190, 16);
|
||
tap('r');
|
||
step(5, 16);
|
||
check('restart works', ctx.G.mode === 'play' && ctx.G.floor === 1);
|
||
|
||
console.log('== confirmed destructive actions ==');
|
||
{
|
||
check('web renderer disables desktop-only exit', ctx.canQuitDesktop() === false);
|
||
let fired = 0;
|
||
ctx.confirmAction('probe', () => fired++);
|
||
check('first destructive action arms confirmation without firing', ctx.G.confirmAction === 'probe' && ctx.G.confirmT > 2.9 && fired === 0);
|
||
ctx.confirmAction('probe', () => fired++);
|
||
check('second matching action confirms and clears state', fired === 1 && ctx.G.confirmAction === null && ctx.G.confirmT === 0);
|
||
ctx.confirmAction('expire', () => fired++);
|
||
ctx.G.mode = 'inspect';
|
||
ctx.update(3.1);
|
||
check('confirmation expires without firing', fired === 1 && ctx.G.confirmAction === null && ctx.G.confirmT === 0);
|
||
ctx.startRun(); step(3, 16);
|
||
ctx.G.score = 999; ctx.G.mode = 'pause'; ctx.G.deathLockT = 2;
|
||
ctx.confirmAction('menu', ctx.returnToMenu);
|
||
ctx.confirmAction('menu', ctx.returnToMenu);
|
||
check('confirmed return to menu fully resets the abandoned run', ctx.G.mode === 'menu' && ctx.G.score === 0 && ctx.G.deathLockT === 0);
|
||
ctx.startRun(); step(3, 16);
|
||
}
|
||
|
||
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('== debug console ==');
|
||
// gating: backtick is inert in the web harness (no dev flag)
|
||
const modeBeforeDebug = ctx.G.mode;
|
||
ctx.G.devMode = false;
|
||
tap('`');
|
||
check('debug console stays closed without dev flag', ctx.G.mode === modeBeforeDebug);
|
||
// enable dev, open, navigate tabs, close
|
||
ctx.G.devMode = true;
|
||
ctx.G.mode = 'play';
|
||
tap('`');
|
||
check('debug console opens on backtick when dev', ctx.G.mode === 'debug');
|
||
const pageBefore = ctx.G.debugPage;
|
||
tap('arrowright');
|
||
check('tab advances', ctx.G.debugPage === (pageBefore + 1) % ctx.DEBUG_PAGES.length);
|
||
tap('`');
|
||
check('debug console closes back to play', ctx.G.mode === 'play');
|
||
|
||
// defaultPlayerStats is a clean, self-contained snapshot of the starting stats
|
||
(function () {
|
||
const def = ctx.defaultPlayerStats();
|
||
const def2 = ctx.defaultPlayerStats();
|
||
check('defaultPlayerStats returns independent copies', def !== def2 && def.maxHp === 6 && def.dmgMul === 1 && def.critMul === 2);
|
||
// a freshly-initialised player always matches the factory output
|
||
const saved = ctx.G.player;
|
||
ctx.initPlayer();
|
||
const fresh = ctx.G.player.stats;
|
||
check('defaultPlayerStats matches initPlayer stats', Object.keys(def).every(k => def[k] === fresh[k]) && Object.keys(fresh).length === Object.keys(def).length);
|
||
check('initPlayer seeds empty perk tracking', Array.isArray(ctx.G.player.perks) && ctx.G.player.perks.length === 0);
|
||
ctx.G.player = saved;
|
||
})();
|
||
|
||
// stat rebuild reproduces a build, then removal strips its contribution
|
||
(function () {
|
||
ctx.G.player.items = {};
|
||
ctx.G.player.perks = [];
|
||
ctx.G.player.stats = ctx.defaultPlayerStats();
|
||
const baseDmg = ctx.G.player.stats.dmgMul;
|
||
ctx.giveItem('marrowglut'); // dmgMul *= 1.20
|
||
ctx.grantPerk(ctx.PERKS.find(p => p.id === 'critbone')); // crit += 0.02
|
||
const afterDmg = ctx.G.player.stats.dmgMul;
|
||
const afterCrit = ctx.G.player.stats.crit;
|
||
check('item + perk apply', Math.abs(afterDmg - baseDmg * 1.20) < 1e-9 && Math.abs(afterCrit - 0.07) < 1e-9);
|
||
ctx.debugRebuildStats();
|
||
check('rebuild reproduces stats', Math.abs(ctx.G.player.stats.dmgMul - afterDmg) < 1e-9 && Math.abs(ctx.G.player.stats.crit - afterCrit) < 1e-9);
|
||
delete ctx.G.player.items['marrowglut'];
|
||
ctx.debugRebuildStats();
|
||
check('item removal strips its stat', Math.abs(ctx.G.player.stats.dmgMul - baseDmg) < 1e-9);
|
||
check('perk survives item removal', Math.abs(ctx.G.player.stats.crit - afterCrit) < 1e-9);
|
||
})();
|
||
|
||
// pressure delta helper: respects bounds and the freeze lock
|
||
(function () {
|
||
ctx.G.pressure = 1; ctx.G.debugFlags = {};
|
||
ctx.applyPressureDelta(5); check('pressure clamps to max', ctx.G.pressure === ctx.PRESSURE_MAX);
|
||
ctx.applyPressureDelta(-99); check('pressure clamps to min', ctx.G.pressure === ctx.PRESSURE_MIN);
|
||
ctx.G.pressure = 1; ctx.G.debugFlags.pressureLock = true;
|
||
ctx.applyPressureDelta(0.5); check('freeze locks pressure', ctx.G.pressure === 1);
|
||
ctx.G.debugFlags = {};
|
||
})();
|
||
|
||
// taint blocks best-score persistence
|
||
(function () {
|
||
ctx.G.debugUsed = true;
|
||
ctx.G.best = 100;
|
||
ctx.G.score = 500;
|
||
const old = ctx.G.player.hp;
|
||
ctx.G.player.hp = 1;
|
||
ctx.gameOver();
|
||
check('debug-tainted run does not save best', ctx.G.best === 100);
|
||
ctx.G.debugUsed = false; ctx.G.mode = 'play';
|
||
ctx.G.player.hp = old;
|
||
})();
|
||
|
||
// god mode and OHKO guards
|
||
(function () {
|
||
ctx.G.debugFlags.god = true;
|
||
const hp = ctx.G.player.hp;
|
||
ctx.hurtPlayer(5, 0, null);
|
||
check('god mode blocks damage', ctx.G.player.hp === hp);
|
||
ctx.G.debugFlags.god = false;
|
||
ctx.G.enemies = [];
|
||
const victim = ctx.makeEnemy('shambler', 100, 100, 1, false);
|
||
ctx.G.enemies.push(victim);
|
||
ctx.G.debugFlags.ohko = true;
|
||
ctx.damageEnemy(victim, 1, 0, false);
|
||
check('ohko kills a full-health enemy', victim.hp <= 0);
|
||
ctx.G.debugFlags.ohko = false;
|
||
ctx.G.enemies = [];
|
||
})();
|
||
|
||
// pinned debug console = live sim (player can be hurt); un-pinned = protected
|
||
(function () {
|
||
ctx.G.player.invT = 0;
|
||
const hp = ctx.G.player.hp;
|
||
ctx.G.mode = 'debug'; ctx.G.debugPin = true;
|
||
ctx.hurtPlayer(1, 0, null);
|
||
check('pinned debug keeps the player hittable', ctx.G.player.hp === hp - 1);
|
||
ctx.G.debugPin = false; ctx.G.player.invT = 0; const hp2 = ctx.G.player.hp;
|
||
ctx.hurtPlayer(1, 0, null);
|
||
check('un-pinned debug protects the player', ctx.G.player.hp === hp2);
|
||
ctx.G.mode = 'play';
|
||
})();
|
||
|
||
// useActive(force) fires from the debug console; without force it stays gated
|
||
(function () {
|
||
ctx.G.mode = 'debug';
|
||
ctx.G.player.active = { iid: 'bonenova', charges: 2 };
|
||
ctx.useActive(); // not forced: no-op in debug mode
|
||
check('useActive is mode-gated without force', ctx.G.player.active.charges === 2);
|
||
ctx.useActive(true); // debug FIRE button path
|
||
check('useActive(force) fires in debug mode', ctx.G.player.active.charges === 0);
|
||
ctx.G.mode = 'play';
|
||
})();
|
||
|
||
console.log(failures === 0 ? '\nALL CHECKS PASSED' : '\n' + failures + ' CHECKS FAILED');
|
||
process.exit(failures === 0 ? 0 : 1);
|