Diablo2D — Shadows of Tristram: complete browser ARPG

- 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
This commit is contained in:
2026-08-23 06:59:36 +00:00
commit fc1fa2d51e
42 changed files with 11784 additions and 0 deletions
+477
View File
@@ -0,0 +1,477 @@
/* ============================================================
* 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);
});