Initial commit — Jianghu Chronicles: Road of the Wandering Blade

Original open-world wuxia browser RPG:
- Graphical 48px tile world (12 locations) with walkable character,
  NPC interaction, action spots, roaming enemies, travel portals
- Tactical 10x7 grid battles: shapes, statuses, internals, 30+ techniques
  incl. tier-V ultimates & support arts across 6 weapon types
- Full equipment: weapon/head/body/feet/2 accessories, 5 tiers, smithing
- 6-chapter main story + side jobs board + bounty hunts + tournament
- Crafting (smith/alchemy), fishing minigame, gambling, pickpocketing
- Companions with chemistry passives, affection, romance, sects
- Achievements, monster codex, day/night cycle, endings
- 25-test headless smoke suite; no-cache static server included
This commit is contained in:
2026-08-23 07:01:08 +00:00
commit 8680f79102
14 changed files with 6244 additions and 0 deletions
+412
View File
@@ -0,0 +1,412 @@
/* 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();