151 lines
6.5 KiB
JavaScript
151 lines
6.5 KiB
JavaScript
// Repro hunt: simulate a LONG Gore Crown (floor 2 boss) fight with a
|
|
// realistically behaving player, watching for exceptions / runaway growth.
|
|
'use strict';
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const vm = require('vm');
|
|
|
|
function fakeCtx() {
|
|
return new Proxy({}, {
|
|
get(t, prop) {
|
|
if (prop === 'measureText') return () => ({ width: 50 });
|
|
if (typeof prop === 'string' && !(prop in t)) t[prop] = /^[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: () => {},
|
|
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() { return Promise.resolve(); } pause() {} },
|
|
AudioContext: undefined,
|
|
setTimeout: (fn) => fn(),
|
|
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);
|
|
for (const f of ['utils', 'state', 'input', 'sfx', 'audio', 'sprites', 'particles', 'weapons',
|
|
'bullets', 'pickups', 'items', 'perks', 'enemies', 'bosses', 'rooms', 'player', 'hud', 'main']) {
|
|
vm.runInContext(fs.readFileSync(path.join(__dirname, '..', 'js', f + '.js'), 'utf8'), sandbox, { filename: f + '.js' });
|
|
}
|
|
vm.runInContext('Object.assign(this, { G, Input, WEAPONS, Music, W, H, WALL })', sandbox);
|
|
|
|
let simTime = 0;
|
|
const ctx = sandbox;
|
|
let SYS = 'init';
|
|
function step(n) {
|
|
for (let i = 0; i < n; i++) {
|
|
simTime += 16;
|
|
// instrumented play-mode dispatch so the last label before a hang = culprit
|
|
if (ctx.G.mode === 'play') {
|
|
SYS = 'updatePlayer'; ctx.updatePlayer(0.016);
|
|
SYS = 'updateRoom'; ctx.updateRoom(0.016);
|
|
SYS = 'updateEnemies'; ctx.updateEnemies(0.016);
|
|
SYS = 'updateTelegraphs'; ctx.updateTelegraphs(0.016);
|
|
SYS = 'updateBullets'; ctx.updateBullets(0.016);
|
|
SYS = 'updateHazards'; ctx.updateHazards(0.016);
|
|
SYS = 'updatePickups'; ctx.updatePickups(0.016);
|
|
SYS = 'updateParticles'; ctx.updateParticles(0.016);
|
|
SYS = 'drawToastsUpdate'; ctx.drawToastsUpdate(0.016);
|
|
SYS = 'draw'; ctx.draw();
|
|
} else {
|
|
SYS = 'update(' + ctx.G.mode + ')';
|
|
ctx.update(0.016);
|
|
SYS = 'draw'; ctx.draw();
|
|
}
|
|
ctx.clearInputEdges();
|
|
}
|
|
}
|
|
process.on('SIGINT', () => { console.log('INT at', SYS); process.exit(1); });
|
|
for (const fn of (listeners['win:load'] || [])) fn();
|
|
step(2);
|
|
|
|
// start a run and go to floor 2's boss room
|
|
ctx.startRun();
|
|
ctx.nextFloor();
|
|
const bossRoom = Object.values(ctx.G.rooms).find(r => r.type === 'boss');
|
|
ctx.enterRoom(bossRoom.gx, bossRoom.gy);
|
|
// invincible sim player so the fight actually lasts (we're hunting freezes, not deaths)
|
|
// NOTE: keep maxHp small — drawHUD loops once per heart; huge maxHp = slow test
|
|
ctx.G.player.hp = 99999;
|
|
step(2);
|
|
console.log('boss:', ctx.G.boss && ctx.G.boss.bossKind);
|
|
if (!ctx.G.boss || ctx.G.boss.bossKind !== 'gorecrown') { console.log('SETUP FAILED'); process.exit(1); }
|
|
|
|
// realistic fight: move erratically, hold fire aimed at boss, cycle weapons,
|
|
// draft perks when offered, DON'T kill the boss (heal it) so the fight drags on
|
|
const weaponIds = Object.keys(ctx.WEAPONS);
|
|
let maxEnemies = 0, maxParts = 0, maxEbullets = 0, maxPickups = 0;
|
|
const FRAMES = 12000; // ~3.2 minutes of game time
|
|
try {
|
|
for (let f = 0; f < FRAMES; f++) {
|
|
// wiggle movement
|
|
if (f % 40 === 0) {
|
|
for (const k of ['w', 'a', 's', 'd']) ctx.Input.keys[k] = false;
|
|
ctx.Input.keys[['w', 'a', 's', 'd'][Math.floor(Math.random() * 4)]] = true;
|
|
}
|
|
// aim at boss and hold fire
|
|
if (ctx.G.boss) { ctx.Input.mx = ctx.G.boss.x; ctx.Input.my = ctx.G.boss.y; }
|
|
ctx.Input.mdown = true;
|
|
// swap weapons occasionally
|
|
if (f % 900 === 0 && f > 0) {
|
|
const wid = weaponIds[Math.floor(Math.random() * weaponIds.length)];
|
|
ctx.G.player.weapon = { id: wid, ammo: 999 };
|
|
}
|
|
// keep the boss alive: top up its hp each frame
|
|
if (ctx.G.boss) ctx.G.boss.hp = Math.min(ctx.G.boss.maxHp, ctx.G.boss.hp + 3);
|
|
// keep the sim player alive
|
|
ctx.G.player.hp = 99999;
|
|
// auto-draft perks
|
|
if (ctx.G.mode === 'levelup') ctx.choosePerk(0);
|
|
|
|
step(1);
|
|
maxEnemies = Math.max(maxEnemies, ctx.G.enemies.length);
|
|
maxParts = Math.max(maxParts, ctx.G.parts.length);
|
|
maxEbullets = Math.max(maxEbullets, ctx.G.ebullets.length);
|
|
maxPickups = Math.max(maxPickups, ctx.G.pickups.length);
|
|
if (f % 100 === 99) {
|
|
console.log(`f${f + 1}: mode=${ctx.G.mode} enemies=${ctx.G.enemies.length} parts=${ctx.G.parts.length} ebul=${ctx.G.ebullets.length} pick=${ctx.G.pickups.length} bossHp=${ctx.G.boss ? Math.round(ctx.G.boss.hp) : 'dead'} weapon=${ctx.G.player.weapon.id}`);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.log('EXCEPTION DURING FIGHT:', e.stack);
|
|
process.exit(1);
|
|
}
|
|
console.log(`survived ${FRAMES} frames. maxEnemies=${maxEnemies} maxParts=${maxParts} maxEbullets=${maxEbullets} maxPickups=${maxPickups}`);
|
|
|
|
// now kill the boss while minions are alive, then mop up
|
|
try {
|
|
ctx.Input.mdown = false;
|
|
if (ctx.G.boss) ctx.damageEnemy(ctx.G.boss, 999999, 0, false);
|
|
step(5);
|
|
console.log('boss killed with', ctx.G.enemies.length, 'minions alive; room cleared =', ctx.G.cur.cleared);
|
|
for (const e of [...ctx.G.enemies]) ctx.damageEnemy(e, 999999, 0, false);
|
|
step(30);
|
|
console.log('after mop-up: cleared =', ctx.G.cur.cleared, '| stairs =', ctx.G.pickups.some(p => p.type === 'stairs'), '| floor =', ctx.G.floor);
|
|
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);
|
|
console.log('floor after stairs =', ctx.G.floor, '| mode =', ctx.G.mode);
|
|
}
|
|
console.log('POST-FIGHT OK');
|
|
} catch (e) {
|
|
console.log('EXCEPTION AFTER FIGHT:', e.stack);
|
|
process.exit(1);
|
|
}
|