#!/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);