/* ============================================================ * 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); });