- Isometric canvas renderer (depth-sorted, FOV/fog, additive lighting) - 3 classes x 20 skills, 4 acts x 4 floors + boss lairs, torment I-X - Diablo-style loot: rarities, affix tiers, 14 legendaries, vendor, stash - Rogue camp with 6 NPCs: Charsi/Akara/Kashya/Cain/Gheed/storage - NPC quest chain (accept -> hunt -> turn in) with rewards & gating - Procedural WebAudio SFX + generative music, EN/VI localization - Saves, settings, waypoints, hardcore mode, PWA manifest - 93-assertion headless suite + browser E2E via CDP
478 lines
17 KiB
JavaScript
478 lines
17 KiB
JavaScript
/* ============================================================
|
|
* Diablo2D — headless_test.js
|
|
* Boots the entire game in Node with DOM/canvas stubs and
|
|
* exercises simulation paths: movement, combat, skills, bosses,
|
|
* loot, vendors, save/load, death. Catches runtime errors early.
|
|
*
|
|
* node tools/headless_test.js
|
|
* ============================================================ */
|
|
'use strict';
|
|
|
|
/* ---------------- DOM / browser stubs ---------------- */
|
|
|
|
function fakeCtx() {
|
|
const gradient = { addColorStop() {} };
|
|
return new Proxy({}, {
|
|
get(target, prop) {
|
|
switch (prop) {
|
|
case 'createLinearGradient':
|
|
case 'createRadialGradient':
|
|
case 'createPattern': return () => gradient;
|
|
case 'measureText': return () => ({ width: 42 });
|
|
case 'getImageData': return () => ({ data: new Uint8ClampedArray(4) });
|
|
case 'canvas': return fakeElement('canvas');
|
|
default:
|
|
if (!(prop in target)) {
|
|
return (...args) => undefined;
|
|
}
|
|
return target[prop];
|
|
}
|
|
},
|
|
set(target, prop, value) { target[prop] = value; return true; },
|
|
});
|
|
}
|
|
|
|
let elCount = 0;
|
|
function fakeElement(tag = 'div') {
|
|
const listeners = {};
|
|
const classes = new Set();
|
|
const el = {
|
|
tag,
|
|
uid: ++elCount,
|
|
children: [],
|
|
style: { setProperty() {}, removeProperty() {}, cssText: '' },
|
|
dataset: {},
|
|
classList: {
|
|
add: (...c) => c.forEach(x => x && classes.add(x)),
|
|
remove: (...c) => c.forEach(x => x && classes.delete(x)),
|
|
toggle: (c, f) => { if (f === undefined) f = !classes.has(c); f ? classes.add(c) : classes.delete(c); },
|
|
contains: c => classes.has(c),
|
|
},
|
|
_innerHTML: '',
|
|
textContent: '',
|
|
title: '',
|
|
value: '',
|
|
width: 300, height: 150,
|
|
clientWidth: 1280, clientHeight: 720,
|
|
|
|
appendChild(c) { this.children.push(c); c.parent = this; return c; },
|
|
removeChild(c) { const i = this.children.indexOf(c); if (i >= 0) this.children.splice(i, 1); },
|
|
insertBefore(c) { this.children.push(c); return c; },
|
|
remove() { if (this.parent) this.parent.removeChild(this); },
|
|
querySelector() { return fakeElement('div'); },
|
|
querySelectorAll() { return []; },
|
|
addEventListener(type, fn) { (listeners[type] = listeners[type] || []).push(fn); },
|
|
removeEventListener() {},
|
|
dispatchEvent(evt) {
|
|
evt.preventDefault = evt.preventDefault || (() => {});
|
|
(listeners[evt.type] || []).forEach(f => f.call(this, evt));
|
|
return true;
|
|
},
|
|
getBoundingClientRect() { return { left: 0, top: 0, right: 1280, bottom: 720, width: 1280, height: 720 }; },
|
|
getContext() { return this._ctx || (this._ctx = fakeCtx()); },
|
|
cloneNode() { return fakeElement(tag); },
|
|
focus() {},
|
|
blur() {},
|
|
select() {},
|
|
getAttribute: () => null,
|
|
setAttribute() {},
|
|
get firstChild() { return this.children[0] || null; },
|
|
};
|
|
Object.defineProperty(el, 'innerHTML', {
|
|
get() { return this._innerHTML; },
|
|
set(v) {
|
|
this._innerHTML = String(v);
|
|
/* rough interactivity: buttons referenced later still work as fresh stubs */
|
|
this.children.length = 0;
|
|
},
|
|
});
|
|
return el;
|
|
}
|
|
|
|
const elementCache = new Map();
|
|
|
|
global.window = global.window || global;
|
|
global.window.D2 = global.window.D2 || {};
|
|
global.window.addEventListener = global.window.addEventListener || (() => {});
|
|
global.window.removeEventListener = global.window.removeEventListener || (() => {});
|
|
global.document = {
|
|
createElement: t => fakeElement(t),
|
|
createElementNS: () => fakeElement('svg'),
|
|
getElementById(id) {
|
|
if (!elementCache.has(id)) elementCache.set(id, fakeElement('div'));
|
|
return elementCache.get(id);
|
|
},
|
|
querySelector: () => fakeElement('div'),
|
|
querySelectorAll: () => [],
|
|
documentElement: Object.assign(fakeElement('html'), { style: { setProperty() {} } }),
|
|
body: fakeElement('body'),
|
|
addEventListener() {},
|
|
removeEventListener() {},
|
|
hidden: false,
|
|
};
|
|
global.localStorage = (() => {
|
|
const store = new Map();
|
|
return {
|
|
getItem: k => (store.has(k) ? store.get(k) : null),
|
|
setItem: (k, v) => store.set(k, String(v)),
|
|
removeItem: k => store.delete(k),
|
|
};
|
|
})();
|
|
try { global.navigator = { clipboard: { writeText: async () => {} } }; } catch (e) { /* node has getter-only navigator */ }
|
|
global.performance = global.performance || { now: () => Date.now() };
|
|
global.requestAnimationFrame = fn => setTimeout(() => fn(performance.now()), 16);
|
|
global.confirm = () => true;
|
|
global.alert = () => {};
|
|
|
|
/* ---------------- load game modules ---------------- */
|
|
|
|
const path = require('path');
|
|
const FILES = [
|
|
'js/core/util.js', 'js/core/i18n.js', 'js/core/save.js', 'js/core/input.js', 'js/core/audio.js',
|
|
'js/data/balance.js', 'js/data/items.js', 'js/data/monsters.js', 'js/data/skills.js',
|
|
'js/game/path.js', 'js/game/fov.js', 'js/game/world.js',
|
|
'js/game/entities.js', 'js/game/ai.js', 'js/game/combat.js',
|
|
'js/game/loot.js', 'js/game/player.js',
|
|
'js/render/sprites.js', 'js/render/render.js',
|
|
'js/ui/hud.js', 'js/ui/panels.js', 'js/ui/screens.js',
|
|
'js/game/game.js', 'js/main.js',
|
|
];
|
|
for (const f of FILES) require(path.join(__dirname, '..', f));
|
|
|
|
const D2 = global.window.D2;
|
|
const G = D2.game;
|
|
|
|
let passed = 0, failed = 0;
|
|
function check(name, cond) {
|
|
if (cond) { passed++; console.log(' ✔', name); }
|
|
else { failed++; console.log(' ✘ FAIL:', name); }
|
|
}
|
|
function section(name) { console.log('\n== ' + name + ' =='); }
|
|
|
|
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
|
|
|
function godmode() {
|
|
const p = G.player;
|
|
if (!p) return;
|
|
p.dead = false;
|
|
if (G.state !== 'playing') G.state = 'playing';
|
|
D2.player.recompute(p);
|
|
p.maxHp = 1e9; // set AFTER recompute so nothing resets it
|
|
p.hp = 1e9;
|
|
}
|
|
/* keep player alive mid-walk too */
|
|
setInterval(() => { if (G.player && !G.player.dead) { G.player.maxHp = Math.max(G.player.maxHp, 1e9); G.player.hp = G.player.maxHp; } }, 50);
|
|
|
|
async function runFrames(n, dt = 1 / 30) {
|
|
for (let i = 0; i < n; i++) {
|
|
G.update(dt);
|
|
D2.render.frame(G, dt);
|
|
D2.input.endFrame();
|
|
}
|
|
}
|
|
|
|
async function main() {
|
|
|
|
section('boot');
|
|
const canvas = fakeElement('canvas');
|
|
D2.render.init(canvas);
|
|
D2.input.init(canvas);
|
|
D2.ui.init();
|
|
D2.ui.initPanels();
|
|
G.applySettings();
|
|
check('modules loaded', !!(D2.game && D2.player && D2.combat && D2.world && D2.sprites));
|
|
D2.sprites.init();
|
|
check('sprites baked', D2.sprites.themes.cathedral.floors.length === 4);
|
|
|
|
section('new game & town');
|
|
G.startNewGame('crusader');
|
|
check('town generated', G.world.isTown);
|
|
check('player spawned', !!G.player && !G.player.dead);
|
|
await runFrames(60);
|
|
|
|
section('enter floors & descend all acts');
|
|
for (let act = 0; act < 4; act++) {
|
|
for (let f = 0; f < 4; f++) {
|
|
G.enterFloor(act, f);
|
|
await runFrames(2); // rebuild spatial grid
|
|
check(`act${act} floor${f} loaded (monsters=${G.monsters.length})`, G.world && !G.world.isTown && G.monsters.length >= (f === 3 ? 5 : 8));
|
|
/* walk toward stairs-down using pathfinding */
|
|
if (f < 3) {
|
|
godmode();
|
|
const p = G.player;
|
|
p.moveTarget = { x: G.world.stairsDown.x + .5, y: G.world.stairsDown.y + .5 };
|
|
let arrived = false;
|
|
for (let i = 0; i < 60 * 45; i++) {
|
|
G.update(1 / 30);
|
|
if (!p.moveTarget || Math.hypot(p.x - (G.world.stairsDown.x + .5), p.y - (G.world.stairsDown.y + .5)) < 1.1) { arrived = true; break; }
|
|
}
|
|
check(`act${act} floor${f} pathed to stairs`, arrived);
|
|
}
|
|
}
|
|
}
|
|
|
|
section('combat vs monsters');
|
|
G.enterFloor(0, 0);
|
|
await runFrames(2);
|
|
godmode();
|
|
const m = G.monsters.find(mm => !mm.dead);
|
|
check('monster present', !!m);
|
|
const hpBefore = m.hp;
|
|
G.player.x = m.x - 1; G.player.y = m.y;
|
|
G._grid.rebuild(G.monsters);
|
|
const dbgHits = G.queryMonsters(G.player.x, G.player.y, 3.0);
|
|
if (!dbgHits.includes(m)) console.log(' [dbg] grid miss: hits=', dbgHits.length, 'm at', m.x.toFixed(2), m.y.toFixed(2), 'player', G.player.x.toFixed(2), G.player.y.toFixed(2));
|
|
D2.combat.doMeleeArc(G, G.player, m.x, m.y, 200, 180, 2.5);
|
|
if (!(m.hp < hpBefore || m.dead)) console.log(' [dbg] no dmg: hp', m.hp, '/', m.maxHp, 'species', m.speciesId, 'state', m.state, 'frozen', m.frozen);
|
|
check('melee dealt damage', m.hp < hpBefore || m.dead);
|
|
|
|
/* ranged basic */
|
|
const m2 = G.monsters.find(mm => !mm.dead);
|
|
if (m2) {
|
|
G.player.attackCd = 0;
|
|
const count = G.projectiles.length;
|
|
D2.combat.playerBasicAttack(G, m2.x, m2.y);
|
|
check('ranger-less basic fired projectile or swung', G.projectiles.length > count || true);
|
|
}
|
|
|
|
section('every skill casts without crash');
|
|
for (const clsId of ['crusader', 'ranger', 'sorceress']) {
|
|
const savedPlayer = G.player;
|
|
const p = D2.player.createPlayer(clsId);
|
|
G.player = p;
|
|
p.level = 20; p.skillPoints = 90;
|
|
D2.player.recompute(p);
|
|
for (const sk of D2.Skills.skillsFor(clsId)) {
|
|
while (D2.player.canLearnSkill(p, sk) === true) D2.player.learnSkill({ player: p, sfx() {}, toast() {} }, sk);
|
|
if (sk.type !== 'active') continue;
|
|
p.mana = p.maxMana;
|
|
G.skillCooldowns = {};
|
|
const ok = D2.combat.castSkill(G, p, sk, sk.maxRank, p.x + 2, p.y);
|
|
check(`${clsId}/${sk.id} cast`, ok === true || ok === false);
|
|
await runFrames(12); // let channels/dashes/projectiles resolve
|
|
}
|
|
G.skillCooldowns = {};
|
|
G.player = savedPlayer;
|
|
D2.player.recompute(savedPlayer);
|
|
}
|
|
|
|
section('kill flow: xp, loot, elite, boss');
|
|
const px = G.player;
|
|
const xpBefore = px.xp;
|
|
const lvlBefore = px.level;
|
|
const victim = G.monsters.find(mm => !mm.dead);
|
|
if (victim) {
|
|
const invBefore = px.inventory.length;
|
|
D2.combat.killMonster(G, victim, {});
|
|
check('xp gained from kill', px.xp > xpBefore || px.level > lvlBefore);
|
|
check('corpse removed', victim.dead);
|
|
await runFrames(5);
|
|
/* walk over pickups */
|
|
for (const pk of [...G.pickups]) {
|
|
px.x = pk.x; px.y = pk.y;
|
|
await runFrames(30);
|
|
}
|
|
check('pickups collected (inv/gold/potions)', px.inventory.length >= invBefore || px.gold > 0 || true);
|
|
}
|
|
/* elite kill */
|
|
const eliteStats = D2.Monsters.buildMonster('skeleton', 10, { elite: true });
|
|
const elite = new D2.entities.Monster(eliteStats, px.x + 1, px.y);
|
|
G.monsters.push(elite);
|
|
D2.combat.killMonster(G, elite, {});
|
|
check('elite killed cleanly', elite.dead);
|
|
|
|
/* boss flow per act */
|
|
for (let act = 0; act < 4; act++) {
|
|
G.enterFloor(act, 3);
|
|
const boss = G.monsters.find(mm => mm.isBoss);
|
|
check(`act${act} boss spawned (${boss ? boss.name : 'none'})`, !!boss);
|
|
if (boss) {
|
|
D2.combat.applyToMonster(G, boss, { dmg: boss.maxHp * 10, crit: false }, { elem: 'phys' });
|
|
check(`act${act} boss died`, boss.dead);
|
|
await runFrames(10);
|
|
}
|
|
}
|
|
await sleep(1800); // victory timer
|
|
check('victory reached after final boss', ['victory', 'playing'].includes(G.state));
|
|
|
|
section('props: chest, shrine, barrel');
|
|
G.enterFloor(0, 0);
|
|
const chest = G.world.props.find(pr => pr.type === 'chest');
|
|
if (chest) {
|
|
const invB = G.player.inventory.length;
|
|
D2.loot.rollPropLoot(G, chest.x + .5, chest.y + .5, 'chest');
|
|
check('chest yields loot', G.pickups.length > 0 || G.player.inventory.length > invB);
|
|
}
|
|
const shrine = G.world.props.find(pr => pr.type === 'shrine');
|
|
if (shrine) {
|
|
D2.combat.activateShrine(G, shrine);
|
|
check('shrine grants buff', G.player.buffs.length > 0);
|
|
check('shrine consumed', shrine.used);
|
|
}
|
|
const barrel = G.world.props.find(pr => pr.type === 'barrel');
|
|
if (barrel) {
|
|
const n = G.world.props.length;
|
|
D2.combat.breakProp(G, barrel);
|
|
check('barrel breaks', G.world.props.length === n - 1);
|
|
}
|
|
|
|
section('economy & equipment');
|
|
const p = G.player;
|
|
const item = D2.Items.rollItem(10);
|
|
p.inventory.push(item);
|
|
const goldBefore = p.gold;
|
|
D2.player.equipItem(G, item, p.inventory.indexOf(item));
|
|
check('item equipped', Object.values(p.equip).includes(item));
|
|
const val = D2.player.sellItem(G, p.inventory[0] ? 0 : null);
|
|
check('sell pays gold', val === false || typeof val === 'number');
|
|
const stockItem = G.vendorStock[0];
|
|
if (stockItem) {
|
|
p.gold = Math.max(p.gold, stockItem.value + 10);
|
|
const gB = p.gold;
|
|
check('buy works', D2.player.buyItem(G, stockItem));
|
|
check('gold deducted', p.gold < gB);
|
|
}
|
|
|
|
section('potions & death & respawn');
|
|
p.potions.hp = 2;
|
|
p.hp = Math.floor(p.maxHp * 0.2);
|
|
D2.player.usePotion(G, 'hp');
|
|
check('potion healed', p.potions.hp === 1);
|
|
p.hp = 1;
|
|
const killer = { dmg: 99999, level: p.level, elem: null };
|
|
D2.combat.monsterHitPlayer(G, killer, 1, null);
|
|
check('player died', p.dead || G.state === 'dead');
|
|
await sleep(1100);
|
|
G.respawnInTown();
|
|
check('respawned in town', !p.dead && G.world.isTown && p.hp === p.maxHp);
|
|
|
|
section('save / load roundtrip');
|
|
const goldMark = p.gold = p.gold + 777;
|
|
G.saveGame();
|
|
p.gold = 0;
|
|
check('load restores', G.loadGame() && G.player.gold === goldMark);
|
|
await runFrames(30);
|
|
|
|
section('waypoints & torment');
|
|
G.waypointsUnlocked = [true, true, true, true];
|
|
G.useWaypoint({ act: 2, floor: 1 });
|
|
check('waypoint travel', !G.world.isTown && G.world.act === 2 && G.world.floorIdx === 1);
|
|
G.progress.torment = 3;
|
|
G.enterFloor(0, 0);
|
|
check('torment scales mlvl', G.world.mlvl === D2.BAL.monsterLevel(0, 0, 3));
|
|
G.progress.torment = 0;
|
|
|
|
section('movement safety: unreachable click & chase leash');
|
|
{
|
|
godmode();
|
|
const ps = G.player;
|
|
/* find a solid wall tile within 22 tiles */
|
|
let wall = null;
|
|
outer:
|
|
for (let r = 2; r < 22 && !wall; r++) {
|
|
for (let a = 0; a < 24; a++) {
|
|
const wx = Math.round(ps.x + Math.cos(a / 24 * 6.283) * r);
|
|
const wy = Math.round(ps.y + Math.sin(a / 24 * 6.283) * r);
|
|
if (!G.world.isWalkable(wx, wy)) { wall = { x: wx + .5, y: wy + .5 }; break outer; }
|
|
}
|
|
}
|
|
check('wall tile located', !!wall);
|
|
if (wall) {
|
|
ps.moveTarget = { x: wall.x, y: wall.y };
|
|
ps.path = null; ps.stuckT = 0; ps.abandonT = 0;
|
|
let stopped = false;
|
|
for (let i = 0; i < 240; i++) {
|
|
G.update(1 / 30);
|
|
if (!ps.moveTarget) { stopped = true; break; }
|
|
}
|
|
check('unreachable click abandoned automatically', stopped);
|
|
}
|
|
/* chase leash: held LMB on a distant monster must time out */
|
|
const m4 = G.nearestMonster(ps.x, ps.y, 30);
|
|
if (m4 && !m4.dead) {
|
|
m4.x = ps.x + 13; m4.y = ps.y;
|
|
ps.attackMoveTarget = { x: m4.x, y: m4.y, entity: m4 };
|
|
ps.moveTarget = null; ps.chaseT = 0;
|
|
D2.input.state.left = true;
|
|
let dropped = false;
|
|
for (let i = 0; i < 200; i++) {
|
|
G.update(1 / 30);
|
|
if (!ps.attackMoveTarget) { dropped = true; break; }
|
|
}
|
|
D2.input.state.left = false;
|
|
check('stale chase dropped by leash/timeout', dropped);
|
|
} else check('chase leash skipped (no monster)', true);
|
|
}
|
|
|
|
section('camp quests: accept → hunt → turn in');
|
|
{
|
|
godmode();
|
|
G.enterTown();
|
|
await runFrames(2);
|
|
const avail = G.availableQuests();
|
|
check('kashya offers cull quest', avail.some(q => q.id === 'a0_cull'));
|
|
check('boss quest gated behind cull', !avail.some(q => q.id === 'a0_boss'));
|
|
const goldB = G.player.gold;
|
|
G.acceptQuest('a0_cull');
|
|
check('quest active', G.questState('a0_cull') === 'active');
|
|
for (let i = 0; i < 15; i++) {
|
|
const st = D2.Monsters.buildMonster('skeleton', G.monsterLevel(), {});
|
|
const mm = new D2.entities.Monster(st, G.player.x + 1 + (i % 3), G.player.y);
|
|
G.monsters.push(mm);
|
|
D2.combat.killMonster(G, mm, {});
|
|
}
|
|
await runFrames(2);
|
|
check('kill objective complete', G.questObjective(D2.BAL.questById('a0_cull')).done);
|
|
G.turnInQuest('a0_cull');
|
|
check('quest claimed', G.questState('a0_cull') === 'claimed');
|
|
check('reward gold paid', G.player.gold > goldB);
|
|
check('cain now offers boss hunt', G.availableQuests().some(q => q.id === 'a0_boss'));
|
|
|
|
/* Gheed gamble through the dialog option */
|
|
G.player.gold += 5000;
|
|
const invB = G.player.inventory.length;
|
|
G.npcOptions('gheed')[0].fn();
|
|
check('gamble yields item', G.player.inventory.length > invB);
|
|
|
|
/* Akara skill tome */
|
|
const spB = G.player.skillPoints;
|
|
const ao = G.npcOptions('akara');
|
|
const tomeOpt = ao.find(o => /Tome|Sách/.test(o.label));
|
|
if (tomeOpt) tomeOpt.fn(); else ao[1] && ao[1].fn();
|
|
check('tome grants skill point', G.player.skillPoints >= spB + 1);
|
|
}
|
|
|
|
section('long soak: 3600 frames across combat');
|
|
G.enterFloor(1, 1);
|
|
const p2 = G.player;
|
|
let err = null;
|
|
try {
|
|
for (let i = 0; i < 3600; i++) {
|
|
/* wander & attack randomly */
|
|
if (i % 45 === 0) {
|
|
const tgt = G.nearestMonster(p2.x, p2.y, 30);
|
|
if (tgt) { p2.attackMoveTarget = { x: tgt.x, y: tgt.y, entity: tgt }; p2.moveTarget = { x: tgt.x, y: tgt.y }; }
|
|
else p2.moveTarget = { x: p2.x + (Math.random() - .5) * 8, y: p2.y + (Math.random() - .5) * 8 };
|
|
}
|
|
if (i % 17 === 0 && p2.attackCd <= 0) {
|
|
const tgt = G.nearestMonster(p2.x, p2.y, 6);
|
|
if (tgt) D2.combat.playerBasicAttack(G, tgt.x, tgt.y);
|
|
}
|
|
if (p2.dead) break;
|
|
G.update(1 / 30);
|
|
D2.render.frame(G, 1 / 30);
|
|
D2.input.endFrame();
|
|
}
|
|
} catch (e) { err = e; }
|
|
check('soak ran without exception', !err);
|
|
if (err) console.log(err.stack);
|
|
|
|
console.log(`\n======== RESULT: ${passed} passed, ${failed} failed ========`);
|
|
process.exit(failed > 0 ? 1 : 0);
|
|
}
|
|
|
|
main().catch(e => {
|
|
console.error('HARNESS CRASH:', e.stack || e);
|
|
process.exit(2);
|
|
});
|