/* Headless smoke test: loads all game scripts in a VM context with minimal browser stubs, validates data, creates a hero, and runs AI-vs-AI battles to completion. */ import fs from 'fs'; import path from 'path'; import vm from 'vm'; import { fileURLToPath } from 'url'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const ROOT = path.join(__dirname, '..'); const FILES = [ 'js/data.items.js', 'js/data.world.js', 'js/data.dialogues.js', 'js/engine.js', 'js/combat.js', 'js/game.js', 'js/world2d.js', ]; const sandbox = { console, setTimeout, clearTimeout, performance: { now: () => Date.now() }, localStorage: (() => { const m = {}; return { getItem: k => (k in m ? m[k] : null), setItem: (k, v) => { m[k] = String(v); }, removeItem: k => { delete m[k]; } }; })(), window: { addEventListener() {} }, requestAnimationFrame: () => {}, btoa: s => Buffer.from(s, 'binary').toString('base64'), atob: s => Buffer.from(s, 'base64').toString('binary') }; vm.createContext(sandbox); for (const f of FILES) { const code = fs.readFileSync(path.join(ROOT, f), 'utf8'); vm.runInContext(code, sandbox, { filename: f }); } // stub browser-only layer vm.runInContext(` var UI = { showScreen(){}, setTab(){}, refresh(){}, refreshTop(){}, toastSafe(){}, modal(){}, closeModal(){}, openLevelUp(){}, openBattle(b){ if(!b.round) b.startRound(); }, closeBattle(){}, openSmith(){}, openAlchemy(){}, openTrainPick(){}, openBoard(){}, openGamble(){}, renderTab(){}, showEnding(){}, openSystem(){}, renderTitleMenu(){} }; var Toast = { show(){} }; var ACTION_DEFS = { herb:[], mine:[], meditate:[], dummy:[], secttasks:[], board:[], gamble:[], pickpocket:[], camp:[], fish:[] }; UI.runAction = function (act) { if (!ACTION_DEFS[act.id]) throw new Error('unmapped action ' + act.id); }; `, sandbox); const sleep = (ms) => new Promise(r => setTimeout(r, ms)); let failed = 0; const t = (name, fn) => { try { fn(); console.log('✔', name); } catch (e) { failed++; console.error('✘', name, '\n ', String(e.stack).split('\n').slice(0, 4).join('\n ')); } }; function validateData() { const issues = vm.runInContext(`(function(){ var issues = []; for (var nid in NPCS) { var n = NPCS[nid]; if (n.dlg && !DIALOGUES[n.dlg]) issues.push('npc ' + nid + ' dlg ' + n.dlg); if (n.shop) n.shop.stock.forEach(function(i){ if (!ITEMS[i]) issues.push('shop ' + nid + ' item ' + i); }); (n.teach || []).forEach(function(x){ if (x.tech && !TECHNIQUES[x.tech]) issues.push('teach ' + x.tech); if (x.internal && !INTERNALS[x.internal]) issues.push('teach int ' + x.internal); }); } for (var lid in LOCS) LOCS[lid].npcs.forEach(function(nid){ if (!NPCS[nid]) issues.push('loc ' + lid + ' npc ' + nid); }); TRAVEL.forEach(function(e){ if (!LOCS[e[0]] || !LOCS[e[1]]) issues.push('travel loc'); }); Object.keys(ORIGINS).forEach(function(oid){ (ORIGINS[oid].items || []).forEach(function(i){ if (!ITEMS[i]) issues.push('origin ' + oid + ' item ' + i); }); }); Object.keys(ITEMS).forEach(function(iid){ var m = ITEMS[iid]; if (m.type === 'manual' && !TECHNIQUES[m.teach] && !INTERNALS[m.teach] && !LIGHTNESS[m.teach]) issues.push('manual ' + iid); }); Object.keys(SMITH_UPGRADES).forEach(function(k){ var up = SMITH_UPGRADES[k]; if (!ITEMS[k] || !ITEMS[up.to]) issues.push('smith ' + k); Object.keys(up.mats).forEach(function(m){ if (!ITEMS[m]) issues.push('smith mat ' + m); }); }); ALCHEMY_RECIPES.forEach(function(r){ if (!ITEMS[r.out]) issues.push('alch out ' + r.out); Object.keys(r.mats).forEach(function(m){ if (!ITEMS[m]) issues.push('alch mat ' + m); }); }); return issues; })()`, sandbox); if (issues.length) throw new Error(issues.join('; ')); } async function main() { t('data cross-references resolve', validateData); t('newGame creates a hero with derived stats', () => { vm.runInContext(`Game.newGame({ name:'Smoke Tester', gender:'m', origin:'hunter', attrs:{str:2,agi:2} });`, sandbox); const info = vm.runInContext(`({name:G.player.name, hp:G.player.d.maxHp, atk:G.player.d.atk, blade:!!G.player.techs['t_blade_1']})`, sandbox); if (info.name !== 'Smoke Tester') throw new Error('no player'); if (!info.hp || !info.atk || !info.blade) throw new Error('derived/tech missing'); }); t('equipment bonuses apply', () => { vm.runInContext(`G.player.equip.acc1='ac_jade'; Stats.recalc(G.player); G.player.equip.acc1=null; Stats.recalc(G.player);`, sandbox); }); t('headless battle runs to completion', async () => { vm.runInContext(` G.party = ['n_tiehu']; Game.startBattle({ ids:['e_wolf','e_wolf'], scale:0.8, instant:true, meta:{kind:'wild'} }); var _b = G.battle; if (!_b) throw new Error('battle missing'); _b.allies.find(u=>u.isPlayer).ai = 'brute'; `, sandbox); const deadline = Date.now() + 10000; while (!vm.runInContext('!G.battle || G.battle.over', sandbox)) { if (Date.now() > deadline) throw new Error('battle stalled'); await sleep(40); } const res = vm.runInContext(`({win:_b.win, lvl:G.player.lvl, silver:G.player.silver, inBattle:G.inBattle})`, sandbox); if (!res.win) throw new Error('expected AI win vs wolves'); if (res.inBattle) throw new Error('inBattle stuck true'); }); t('player-driven battle accepts input & casts', async () => { vm.runInContext(` G.player.hp = G.player.d.maxHp; G.player.mp = G.player.d.maxMp; Game.startBattle({ ids:['e_bandit'], scale:0.7, instant:true, meta:{kind:'wild'} }); var _b2 = G.battle; if (!_b2) throw new Error('no battle'); _b2.queue = [_b2.allies.find(u=>u.isPlayer)]; _b2.turnIdx = -1; _b2.startRound(); var pu = _b2.current; if (!pu || !pu.isPlayer) throw new Error('player not first: ' + (pu&&pu.name)); var foe = _b2.enemies[0]; // teleport foe adjacent foe.x = pu.x + 1; foe.y = pu.y; _b2.beginCast(pu, 't_univ_1', 2); if (!_b2.selectedMove) throw new Error('cast not selected'); _b2.clickTile(foe.x, foe.y); if (_b2.selectedMove) throw new Error('cast not cleared'); if (!_b2.current.acted) throw new Error('acted flag not set'); `, sandbox); await sleep(120); }); t('travel, time and daily reset work', () => { vm.runInContext(` G.flags.unlockedCity = true; G.flags.hongVisible = false; Game.travelTo('l_city'); if (G.loc !== 'l_city') throw new Error('travel failed'); var d0 = G.time.day; G.time.hour = 23; TimeSys.advance(2); if (G.time.day !== d0 + 1) throw new Error('day rollover failed'); `, sandbox); }); t('save/load roundtrip preserves state', () => { vm.runInContext(` var s0 = G.player.silver; SaveSys.save('1'); G.player.silver += 777; SaveSys.load('1'); if (G.player.silver !== s0) throw new Error('mismatch ' + G.player.silver + ' vs ' + s0); `, sandbox); }); t('quests: start, stage, complete, rewards', () => { vm.runInContext(` var lvl0 = G.player.lvl; Game.startQuest('q_main_1'); G.flags.wolfKills = 3; Game.setStage('q_main_1', 2); Game.completeQuest('q_main_1', { exp: 500, silver: 60, fame: 5, items:[['c_bun',2]] }); if (!G.quests['q_main_1'].done) throw new Error('not done'); if (G.player.lvl <= lvl0) throw new Error('no level gain'); if (!Game.hasItem('c_bun', 2)) throw new Error('reward items missing'); if (Game.questStageText('q_main_1') !== QUESTS['q_main_1'].doneText) throw new Error('stage text'); `, sandbox); }); t('economy: buy/sell/smith/brew', () => { vm.runInContext(` G.player.silver = 5000; var n0 = Game.countItem('c_herb_red'); Game.buyItem('n_merchant_su', 'c_herb_red'); if (Game.countItem('c_herb_red') !== n0 + 1) throw new Error('buy failed'); Game.sellItem('c_herb_red'); G.player.equip.weapon = 'w_sword_1'; G.player.inv.push({id:'mat_ore_1', q:5}); Game.smithUpgrade('w_sword_1'); if (G.player.equip.weapon !== 'w_sword_2' && !Game.hasItem('w_sword_2')) throw new Error('upgrade failed'); G.player.inv.push({id:'c_herb_red', q:5}); Game.brew('r_hp'); if (!Game.hasItem('c_pill_hp', 2)) throw new Error('brew failed'); `, sandbox); }); t('fishing rewards & daily cap counter', () => { vm.runInContext(` G.loc = 'l_dock'; G.daily = defaultDaily(); var f0 = Game.countItem('c_fish'); Game.fishResult(true); if (Game.countItem('c_fish') < f0 + 1) throw new Error('no fish caught'); Game.fishResult(false); if ((G.daily.fished || 0) !== 2) throw new Error('cast counter broken'); if (!Game.canFish() || !LOCS['l_dock'].actions.some(a=>a.id==='fish')) throw new Error('cap/facility missing'); if (!ITEMS['c_fish'] || !ITEMS['j_boot']) throw new Error('fishing items missing'); `, sandbox); }); t('hard mode scales enemy stats', async () => { vm.runInContext(` G.flags.hardMode = true; G.party = []; G.player.hp = G.player.d.maxHp; G.player.mp = G.player.d.maxMp; Game.startBattle({ ids:['e_boar'], instant:true, meta:{kind:'wild'} }); var _hb = G.battle; var expect = Math.round(ENEMIES['e_boar'].hp * 1.28); if (_hb.enemies[0].hpMax !== expect) throw new Error('expected ' + expect + ', got ' + _hb.enemies[0].hpMax); _hb.allies.find(u=>u.isPlayer).ai = 'brute'; `, sandbox); const deadline = Date.now() + 8000; while (!vm.runInContext('!G.battle || G.battle.over', sandbox)) { if (Date.now() > deadline) throw new Error('hard-mode battle stalled'); await sleep(40); } vm.runInContext(`delete G.flags.hardMode;`, sandbox); }); t('camp action exists in wilderness', () => { const ok = vm.runInContext(`['l_mountain','l_bamboo','l_valley'].every(id => LOCS[id].actions.some(a => a.id === 'camp'))`, sandbox); if (!ok) throw new Error('camp action missing somewhere'); }); t('achievements unlock idempotently', () => { vm.runInContext(` G.flags.ach = {}; Game.unlockAch('first_blood'); Game.unlockAch('first_blood'); if (Object.keys(G.flags.ach).length !== 1) throw new Error('not idempotent'); if (!ACHIEVEMENTS['water_patience'] || !ACHIEVEMENTS['umbrellas_fall']) throw new Error('meta missing'); `, sandbox); }); t('bestiary records encountered foes', async () => { vm.runInContext(` delete G.flags.seen; G.party = []; G.player.hp = G.player.d.maxHp; G.player.mp = G.player.d.maxMp; Game.startBattle({ ids:['e_wolf','e_boar'], instant:true, meta:{kind:'wild'} }); var _bb = G.battle; _bb.allies.find(u=>u.isPlayer).ai = 'brute'; `, sandbox); const deadline = Date.now() + 8000; while (!vm.runInContext('!G.battle || G.battle.over', sandbox)) { if (Date.now() > deadline) throw new Error('bestiary battle stalled'); await sleep(40); } const ok = vm.runInContext(`!!G.flags.seen && G.flags.seen['e_wolf'] && G.flags.seen['e_boar'] && !!BEAST_LORE['e_wolf']`, sandbox); if (!ok) throw new Error('seen flags or lore missing'); vm.runInContext(`Game.startQuest('q_main_1');`, sandbox); }); t('temple trial: meditate, spar, join', () => { vm.runInContext(` Game.startQuest('q_trial_fl'); Game.meditate(); if (!G.flags.trialFlMed) throw new Error('meditate not tracked'); Game.checkTrialFl(); if (G.quests['q_trial_fl'].stage !== 0) throw new Error('advanced without spar'); G.flags.trialFlSpar = true; Game.checkTrialFl(); if (G.quests['q_trial_fl'].stage !== 1) throw new Error('trial not advanced'); Game.completeQuest('q_trial_fl', {}); G.flags.templeMember = true; Game.checkQuestAuto(); if (QUESTS['q_trial_fl'].stages.length !== 2) throw new Error('bad stages'); `, sandbox); }); t('companion chemistry passives apply', () => { vm.runInContext(` G.party = ['n_tiehu']; var u1 = makePlayerUnit(G.player); var baseHp = u1.hpMax / 1.08; G.party = []; var u0 = makePlayerUnit(G.player); if (Math.round(u1.hpMax - u0.hpMax) < 1) throw new Error('no HP passive'); G.party = ['n_lin']; var u2 = makePlayerUnit(G.player); if (u2.crit <= u0.crit) throw new Error('no crit passive'); G.party = []; `, sandbox); }); t('world2d: deterministic maps with valid layout', () => { vm.runInContext(` var ids = Object.keys(LOCS); ids.forEach(function (lid) { var a = W2D.genGrid(lid), b = W2D.genGrid(lid); if (!a || a.w < 20 || a.h < 20) throw new Error(lid + ' bad size'); // determinism for (var y = 0; y < a.h; y++) for (var x = 0; x < a.w; x++) if (a.g[y][x] !== b.g[y][x]) throw new Error(lid + ' nondeterministic'); // border fully blocked EXCEPT carved exit gaps var gaps = new Set(); a.exits.forEach(function (ex) { for (var k = -1; k <= 1; k++) { if (ex.tag === 'N' || ex.tag === 'S') { gaps.add((ex.x + k) + ',' + ex.y); } else { gaps.add(ex.x + ',' + (ex.y + k)); } } }); var okTile = function (x, y) { return BLOCKED.has(a.g[y][x]) || gaps.has(x + ',' + y); }; for (var x2 = 0; x2 < a.w; x2++) { if (!okTile(x2, 0)) throw new Error(lid + ' open north'); if (!okTile(x2, a.h - 1)) throw new Error(lid + ' open south'); } for (var y2 = 0; y2 < a.h; y2++) { if (!okTile(0, y2)) throw new Error(lid + ' open west'); if (!okTile(a.w - 1, y2)) throw new Error(lid + ' open east'); } // exit count matches travel edges; gap tiles walkable if (a.exits.length !== TRAVEL.filter(function(e){return e.includes(lid);}).length) throw new Error(lid + ' exit count'); a.exits.forEach(function (ex) { if (BLOCKED.has(a.g[ex.y][ex.x])) throw new Error(lid + ' exit gap blocked at ' + ex.x + ',' + ex.y); }); // spawn plaza walkable if (BLOCKED.has(a.g[a.cy][a.cx])) throw new Error(lid + ' spawn blocked'); // npc/roam spots inside bounds a.npcSpots.concat(a.roamSpots).forEach(function (p) { if (p.x<0||p.y<0||p.x>=a.w||p.y>=a.h) throw new Error(lid + ' spot oob'); }); }); if (W2D.styleFor('l_dock') !== 'dock') throw new Error('styleFor'); `, sandbox); }); t('world2d: action defs cover every location action', () => { const ok = vm.runInContext(` var missing = []; Object.keys(LOCS).forEach(function (lid) { (LOCS[lid].actions || []).forEach(function (act) { try { UI.runAction(act); } catch (e) { missing.push(lid + ':' + act.id); } }); }); missing.length === 0; `, sandbox); if (!ok) throw new Error('unmapped action ids'); }); t('armor slots (head/feet/body) affect derived stats', () => { vm.runInContext(` var d0 = Stats.recalc(G.player).def, s0 = Stats.recalc(G.player).spd; G.player.equip.head = 'ar_head_2'; G.player.equip.feet = 'ar_foot_2'; G.player.equip.body = 'ar_body_2'; var d1 = Stats.recalc(G.player).def, s1 = Stats.recalc(G.player).spd; G.player.equip.head = G.player.equip.feet = G.player.equip.body = null; Stats.recalc(G.player); if (!(d1 > d0)) throw new Error('no def from armor'); if (!(s1 > s0)) throw new Error('no spd from boots'); `, sandbox); }); t('tier-V arts exist and are taught by masters', () => { const ok = vm.runInContext(` ['t_sword_5','t_blade_5','t_spear_5','t_staff_5','t_fist_5','t_hidden_5','t_univ_2','t_univ_3'].every(function(t){ return TECHNIQUES[t] && TECHNIQUES[t].moves.length >= 2; }) && NPCS.n_master_yun.teach.some(function(x){return x.tech==='t_sword_5';}) && NPCS.n_drill_capt.teach.some(function(x){return x.tech==='t_spear_5';}) && ITEMS['m_univ_2'] && ITEMS['m_t_fist_5'] && ITEMS['ar_body_5']; `, sandbox); if (!ok) throw new Error('tier-V content incomplete'); }); t('side job lifecycle: accept, progress, deliver', () => { vm.runInContext(` Game.sideStart('q_night_watch'); if (Game.sideState('q_night_watch') !== 'active') throw new Error('not active'); Game.registerKill({ enemyId: 'e_bandit' }); Game.registerKill({ enemyId: 'e_bandit_vet' }); Game.registerKill({ enemyId: 'e_bandit' }); if (Game.sideState('q_night_watch') !== 'ready') throw new Error('not ready after 3 kills: ' + Game.sideState('q_night_watch')); var sil0 = G.player.silver; Game.sideDeliver('q_night_watch'); if (!G.quests['q_night_watch'].done) throw new Error('not done'); if (G.player.silver <= sil0) throw new Error('no wage paid'); Game.removeItem('c_herb_red', Game.countItem('c_herb_red')); Game.addItem('c_herb_red', 5); Game.sideStart('q_herb_bai'); if (Game.sideState('q_herb_bai') !== 'ready') throw new Error('herb not ready'); Game.sideDeliver('q_herb_bai'); if (Game.hasItem('c_herb_red', 1)) throw new Error('herbs not consumed'); `, sandbox); }); t('spar restores vitals & grants affection', async () => { vm.runInContext(` G.loc='l_dock'; G.time.day=2; Game.dailyReset(); var hp0 = G.player.hp = G.player.d.maxHp; Game.spar('n_tiehu'); if (!G.battle) throw new Error('spar battle missing'); G.battle.allies.find(u=>u.isPlayer).ai='brute'; `, sandbox); const deadline = Date.now() + 10000; while (!vm.runInContext('!G.battle || G.battle.over', sandbox)) { if (Date.now() > deadline) throw new Error('spar stalled'); await sleep(40); } const st = vm.runInContext(`({hp:G.player.hp, aff:G.aff['n_tiehu']||0})`, sandbox); if (st.hp < vm.runInContext('G.player.d.maxHp', sandbox) - 1) throw new Error('hp not restored: ' + st.hp); }); t('main quest chain reaches the tomb stage', () => { vm.runInContext(` Game.startQuest('q_main_4'); // normally started by Du Kang's onWin G.flags.mainChapter = 3; Game.recruitCompanion('n_tiehu'); Game.recruitCompanion('n_lin'); if (G.quests['q_main_4'].stage === 0) throw new Error('allies stage not auto-advanced'); G.flags.azureMember = true; Game.checkQuestAuto(); if (G.quests['q_main_4'].stage !== 2) throw new Error('sect stage not advanced: ' + G.quests['q_main_4'].stage); Game.completeQuest('q_main_4', {}); Game.startQuest('q_main_5'); if (FVsafe('mainChapter') < 3) throw new Error('chapter flag'); `, sandbox); }); t('ending computes without crash', () => { vm.runInContext(` G.flags.mainChapter = 6; G.rep.fame = 300; Game.showEnding(); `, sandbox); }); console.log(failed ? `\n${failed} test(s) FAILED` : '\nAll smoke tests passed ✔'); process.exit(failed ? 1 : 0); } main();