Single-file ink-painting wuxia sect-survival RPG. Lead a sect through 100 days: martial arts combos, cultivation, tactical combat, faction war, chronicle endings. - src/ 13 modules (data, sim, combat, render, UI, app shell) - wuxia.html self-contained build (no dependencies) - tools/build.js bundler - tests: headless 100-day sims + jsdom UI smoke + real-browser Chromium click-through (playwright)
223 lines
9.8 KiB
JavaScript
223 lines
9.8 KiB
JavaScript
#!/usr/bin/env node
|
|
/* Headless simulation test: loads logic modules, runs full 100-day games
|
|
with randomized decisions to catch crashes and dead ends. */
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
const SRC = '/root/Wuxia/src';
|
|
const LOGIC = ['00_boot.js', '05_audio.js', '10_data_arts.js', '12_data_world.js', '14_data_events.js', '20_state.js', '22_sim.js', '24_combat.js'];
|
|
|
|
global.window = global;
|
|
for (const f of LOGIC) {
|
|
eval(fs.readFileSync(path.join(SRC, f), 'utf8'));
|
|
}
|
|
|
|
function pickChoice(st) {
|
|
const pe = st.pendingEvent;
|
|
const okIdx = pe.choices.map((c, i) => c.ok ? i : -1).filter(i => i >= 0);
|
|
return okIdx.length ? W.pick(okIdx) : null;
|
|
}
|
|
|
|
function autoCombat(st) {
|
|
let guard = 0;
|
|
while (st.combat && !st.combat.over && guard++ < 200) {
|
|
const cbt = st.combat;
|
|
const cur = W.combat.current(cbt);
|
|
if (!cur || cur.dead) { W.combat.advance(st, cbt); continue; }
|
|
if (cur.side === 'ally') {
|
|
const foes = cbt.units.filter(u => u.side === 'enemy' && !u.dead);
|
|
if (!foes.length) break;
|
|
let tgt = foes.sort((a, b) => W.U.dist(cur.x, cur.y, a.x, a.y) - W.U.dist(cur.x, cur.y, b.x, b.y))[0];
|
|
const techs = cur.arts.map(id => W.artById(id)).filter(a => a && a.cmb && !(cur.cds[a.id] > 0) && cur.qi >= a.cmb.qi);
|
|
const inRange = techs.filter(a => W.combat.targetsFor(cbt, cur, a).length);
|
|
if (inRange.length && W.chance(0.5)) {
|
|
W.combat.useTechnique(st, cbt, cur, inRange[0].id);
|
|
} else if (W.U.dist(cur.x, cur.y, tgt.x, tgt.y) <= 1) {
|
|
W.combat.attack(st, cbt, cur, tgt);
|
|
} else {
|
|
const reach = W.combat.reachable(cbt, cur);
|
|
let best = null, bd = 1e9;
|
|
for (const c of reach) { const d = W.U.dist(c.x, c.y, tgt.x, tgt.y); if (d < bd) { bd = d; best = c; } }
|
|
if (best) W.combat.moveUnit(st, cbt, cur, best.x, best.y);
|
|
else if (W.U.dist(cur.x, cur.y, tgt.x, tgt.y) <= 2) { /* approach more next round */ }
|
|
else W.combat.guard(st, cbt, cur);
|
|
}
|
|
W.combat.advance(st, cbt);
|
|
} else {
|
|
W.combat.aiAct(st, cbt, cur);
|
|
W.combat.advance(st, cbt);
|
|
}
|
|
}
|
|
if (st.combat && st.combat.result) {
|
|
W.combat.finish(st, st.combat);
|
|
if (!W.sim.player(st).alive) return false;
|
|
} else if (st.combat) { st.combat = null; }
|
|
return true;
|
|
}
|
|
|
|
function runCombatSpec(st, spec) {
|
|
spec._foes = W.sim.buildCombatEnemies(st, spec);
|
|
// simulate competent play: party enters fights in good shape
|
|
for (const c of W.sim.party(st)) { if (c.alive) c.hp = Math.max(c.hp, Math.round(c.maxHp * 0.95)); }
|
|
st._lastCtx = spec.context;
|
|
W.combat.create(st, spec);
|
|
// estimate: retreat if badly outmatched (like a careful player would)
|
|
let foePow = 0, allyPow = 0;
|
|
for (const u of st.combat.units) {
|
|
if (u.side === 'enemy') foePow += (u.hp / 40) * (u.atk / 8);
|
|
else allyPow += (u.hp / 40) * (u.atk / 8) * (u.side === 'ally' ? 1 : 0);
|
|
}
|
|
const outmatched = allyPow < foePow * 0.62;
|
|
let guard = 0;
|
|
while (st.combat && !st.combat.over && guard++ < 260) {
|
|
const cbt = st.combat;
|
|
const cur = W.combat.current(cbt);
|
|
if (!cur || cur.dead) { W.combat.advance(st, cbt); continue; }
|
|
if (outmatched && cur.side === 'ally' && guard % 3 === 1) { if (W.combat.flee(st, cbt, cur)) break; W.combat.advance(st, cbt); continue; }
|
|
if (cur.side === 'ally') {
|
|
const foes = cbt.units.filter(u => u.side === 'enemy' && !u.dead);
|
|
if (!foes.length) break;
|
|
// heal self/allies via techniques if available and hurt
|
|
const hurtFriend = cbt.units.filter(x => x.side === 'ally' && !x.dead && x.hp < x.maxHp * 0.55)[0];
|
|
let acted = false;
|
|
for (const aid of cur.arts) {
|
|
const a = W.artById(aid);
|
|
if (a && a.cmb && !(cur.cds[aid] > 0) && cur.qi >= a.cmb.qi && a.cmb.kind === 'heal' && hurtFriend) {
|
|
W.combat.useTechnique(st, cbt, cur, aid, hurtFriend); acted = true; break;
|
|
}
|
|
}
|
|
if (!acted) {
|
|
let tgt = foes.sort((a, b) => (a.hp / a.maxHp + W.U.dist(cur.x, cur.y, a.x, a.y) * 0.05) - (b.hp / b.maxHp + W.U.dist(cur.x, cur.y, b.x, b.y) * 0.05))[0];
|
|
const techs = cur.arts.map(id => W.artById(id)).filter(a => a && a.cmb && !(cur.cds[a.id] > 0) && cur.qi >= a.cmb.qi && a.cmb.kind !== 'heal');
|
|
const usable = techs.filter(a => W.combat.targetsFor(cbt, cur, a).length);
|
|
if (usable.length && W.chance(0.6)) {
|
|
W.combat.useTechnique(st, cbt, cur, usable[0].id);
|
|
} else if (W.U.dist(cur.x, cur.y, tgt.x, tgt.y) <= 1) {
|
|
W.combat.attack(st, cbt, cur, tgt);
|
|
} else {
|
|
const reach = W.combat.reachable(cbt, cur);
|
|
let best = null, bd = 1e9;
|
|
for (const c of reach) { const d = W.U.dist(c.x, c.y, tgt.x, tgt.y); if (d < bd) { bd = d; best = c; } }
|
|
if (best) W.combat.moveUnit(st, cbt, cur, best.x, best.y);
|
|
else W.combat.guard(st, cbt, cur);
|
|
}
|
|
}
|
|
W.combat.advance(st, cbt);
|
|
} else {
|
|
W.combat.aiAct(st, cbt, cur);
|
|
W.combat.advance(st, cbt);
|
|
}
|
|
}
|
|
if (st.combat && st.combat.result) {
|
|
W.combat.finish(st, st.combat);
|
|
if (!W.sim.player(st).alive) return false;
|
|
} else if (st.combat) { st.combat = null; }
|
|
return true;
|
|
}
|
|
|
|
function resolvePendingEvents(st) {
|
|
let g = 0;
|
|
while (st.pendingEvent && g++ < 6) {
|
|
const ci = pickChoice(st);
|
|
if (ci == null) { st.pendingEvent = null; st._pendingDef = null; break; }
|
|
W.sim.chooseEvent(st, ci);
|
|
if (st.pendingCombat) {
|
|
const spec = st.pendingCombat; st.pendingCombat = null;
|
|
spec.winReward = spec.winReward || null;
|
|
if (!runCombatSpec(st, spec)) return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function runGame(seed, opts, verbose) {
|
|
W.seedRng(seed);
|
|
const st = W.newGameState(Object.assign({ background: W.pick(W.BACKGROUNDS).id, playerName: 'Test', sectName: 'Test Sect', difficulty: 'jianghu' }, opts));
|
|
st.assign = {};
|
|
let daysPlayed = 0;
|
|
while (!st.ended && daysPlayed < 130) {
|
|
// competent play: backfill travel party from healthy disciples at home
|
|
const liveParty = st.party.filter(id => st.chars[id] && st.chars[id].alive);
|
|
if (liveParty.length < 4) {
|
|
for (const c of W.sim.disciples(st)) {
|
|
if (liveParty.length >= 4) break;
|
|
if (!liveParty.includes(c.id)) { liveParty.push(c.id); }
|
|
}
|
|
st.party = liveParty.slice(0, 4);
|
|
}
|
|
let acts = 0;
|
|
while (st.ap > 0 && acts++ < 8) {
|
|
const options = [];
|
|
if (st.locId !== 'home') {
|
|
options.push('explore', 'explore', 'explore', 'gather', 'rest', 'rest');
|
|
if (['town', 'city', 'temple', 'camp'].includes(W.locById(st.locId).type)) options.push('recruit', 'trade', 'spy');
|
|
if (['wild', 'village'].includes(W.locById(st.locId).type)) options.push('hunt');
|
|
} else {
|
|
options.push('train', 'meditate', 'rest');
|
|
}
|
|
const r = W.sim.doAction(st, W.pick(options));
|
|
if (r.event && st.pendingEvent) { if (!resolvePendingEvents(st)) return { seed, day: st.day, end: 'death-event-combat' }; }
|
|
if (r.meeting && st.meeting) {
|
|
const choice = W.pick(['gift', 'persuade', 'spar', 'leave', 'duel', 'request']);
|
|
const rr = W.sim.resolveMeeting(st, choice);
|
|
if (rr.combat) {
|
|
rr.combat.sparChar = rr.combat.sparVs ? st.chars[rr.combat.sparVs] : undefined;
|
|
delete rr.combat.sparVs;
|
|
if (!runCombatSpec(st, rr.combat)) return { seed, day: st.day, end: 'death-meeting' };
|
|
}
|
|
}
|
|
if (r.combat) {
|
|
if (!runCombatSpec(st, r.combat)) return { seed, day: st.day, end: 'player-death', ctx: r.combat.context };
|
|
}
|
|
}
|
|
// travel sometimes
|
|
if (!st.travel && W.chance(0.4)) {
|
|
const dests = Object.keys(st.world.locs).filter(id => st.world.locs[id].discovered && id !== st.locId);
|
|
if (dests.length) W.sim.startTravel(st, W.pick(dests));
|
|
}
|
|
const logs = W.sim.endDay(st);
|
|
daysPlayed++;
|
|
for (const lg of logs) {
|
|
if (lg.kind === 'encounter') {
|
|
const ev = W.sim.rollEvent(st, 'travel');
|
|
if (ev) { if (!resolvePendingEvents(st)) return { seed, end: 'death-travel-event' }; }
|
|
else {
|
|
const dest = W.locById(st.travel ? st.travel.to : st.locId);
|
|
const spec = { enemies: W.sim.encounterFor(st, Math.max(1, dest.danger)), context: 'road' };
|
|
if (!runCombatSpec(st, spec)) return { seed, end: 'death-road' };
|
|
}
|
|
} else if (lg.kind === 'event' || lg.kind === 'war') {
|
|
if (!resolvePendingEvents(st)) return { seed, end: 'death-day-event' };
|
|
} else if (lg.kind === 'raid_incoming') {
|
|
const spec = W.sim.defenseBattle(st, lg.rival);
|
|
if (!runCombatSpec(st, spec)) return { seed, end: 'defense-loss' };
|
|
} else if (lg.kind === 'end') break;
|
|
}
|
|
if (st.day >= 92 && !st.war.finalDone) {
|
|
const fin = W.sim.finalInvasion(st);
|
|
if (!runCombatSpec(st, fin.spec)) return { seed, day: st.day, end: 'final-loss' };
|
|
}
|
|
if (!W.sim.player(st).alive) return { seed, day: st.day, end: 'martyr' };
|
|
}
|
|
const ending = st.endingId || W.sim.computeEnding(st);
|
|
if (verbose) console.log(`seed ${seed}: ended day ${Math.min(st.day, 100)} — ${ending} | fame ${Math.round(st.rep.fame)} | arts ${W.sim.knownArtCount(st)} | disciples ${W.sim.roster(st).length} | kills ${st.stats.kills} | combos seen`);
|
|
return { seed, day: Math.min(st.day, 100), ending };
|
|
}
|
|
|
|
// ---- run batch ----
|
|
const N = parseInt(process.argv[2] || '30', 10);
|
|
let fails = 0, endings = {};
|
|
for (let i = 0; i < N; i++) {
|
|
try {
|
|
const r = runGame(1000 + i * 7919, {}, i < 8);
|
|
endings[r.end || r.ending] = (endings[r.end || r.ending] || 0) + 1; if(r.end) console.log(' died:', JSON.stringify(r));
|
|
} catch (e) {
|
|
fails++;
|
|
console.error('\n=== CRASH seed', 1000 + i * 7919, '===');
|
|
console.error(e.stack.split('\n').slice(0, 8).join('\n'));
|
|
if (fails > 4) break;
|
|
}
|
|
}
|
|
console.log(`\nSimulated ${N} runs. Crashes: ${fails}. Outcomes:`, endings);
|
|
process.exit(fails ? 1 : 0);
|