WUXIA: 100 Days After — full game

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)
This commit is contained in:
2026-08-23 07:01:52 +00:00
commit 3b1c7a72da
22 changed files with 21119 additions and 0 deletions
+89
View File
@@ -0,0 +1,89 @@
#!/usr/bin/env node
/* Full real-browser flow test: title -> create -> play days via REAL clicks
(true hit-testing), capturing console/404s/pageerrors throughout. */
const { chromium } = require('/app/node_modules/.pnpm/playwright@1.61.1/node_modules/playwright');
(async () => {
const browser = await chromium.launch({ executablePath: '/root/.cache/ms-playwright/chromium-1234/chrome-linux64/chrome', args: ['--no-sandbox'] });
const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
const logs = [];
page.on('console', m => { if (m.type() === 'error' || m.type() === 'warning') logs.push(`[${m.type()}] ${m.text()}`); });
page.on('pageerror', e => logs.push(`[PAGEERROR] ${e.message}`));
page.on('requestfailed', r => logs.push(`[REQFAIL] ${r.url()}`));
page.on('response', r => { if (r.status() >= 400) logs.push(`[HTTP ${r.status()}] ${r.url()}`); });
await page.goto(process.argv[2] || 'http://127.0.0.1:8914/wuxia.html', { waitUntil: 'load' });
await page.waitForTimeout(1000);
const step = async (name, fn) => {
try { await fn(); console.log('✓', name); }
catch (e) { console.log('✗', name, '—', e.message.split('\n')[0]); }
await page.waitForTimeout(150);
};
// title -> create
await step('click New Journey', () => page.click('.title-menu .btn:has-text("New Journey")'));
await step('pick background', () => page.click('.bg-card:has-text("Old Soldier")'));
await step('fill names', async () => {
await page.fill('.inp-name', 'Cloud Tester');
await page.fill('.inp-sect', 'Sky Ridge Sect');
});
await step('pick difficulty', () => page.click('.diff-list .diff-card:has-text("Wanderer")'));
await step('Set Out', () => page.click('.create-actions .btn:has-text("Set Out")'));
// dismiss intro notice
await step('dismiss intro', async () => {
const b = page.locator('.modal .btn', { hasText: 'Continue' }).first();
if (await b.count()) await b.click(); else throw new Error('no intro modal');
});
// canvas should be clickable during play (drag pan)
await step('canvas drag-pan works', async () => {
const cv = page.locator('#cv');
const box = await cv.boundingBox();
await page.mouse.move(box.x + 600, box.y + 400);
await page.mouse.down();
await page.mouse.move(box.x + 500, box.y + 330, { steps: 4 });
await page.mouse.up();
});
// do actions via the action bar buttons
for (const act of ['Train', 'Meditate']) {
await step(`action bar: ${act}`, () => page.click(`.action-bar .btn:has-text("${act}")`));
}
// open each panel by nav button and close
for (const p of ["Map", "Sect", "Disciples", "Arts", "Factions", "Journal", "☰"]) {
await step(`panel ${p}`, async () => {
await page.click(`#navbtns .btn:has-text("${p}")`);
await page.waitForSelector('.panel-layer:not(.hidden)', { timeout: 2000 });
await page.click('.panel-head .closebtn');
});
}
// end several days through the real button
for (let i = 0; i < 4; i++) {
await step(`end day ${i + 1}`, async () => {
// resolve any modal (buttons OR event choices) first
for (let k = 0; k < 5; k++) {
const anyBtn = page.locator('.modal .btn:visible, .modal .choice:visible:not(.disabled)').first();
if (await anyBtn.count()) { await anyBtn.click().catch(() => {}); await page.waitForTimeout(150); } else break;
}
await page.click('.action-bar .btn:has-text("End Day")');
await page.waitForTimeout(350);
for (let k = 0; k < 5; k++) {
const cont = page.locator('.modal .btn:visible, .modal .choice:visible:not(.disabled)').first();
if (await cont.count()) { await cont.click().catch(() => {}); await page.waitForTimeout(150); } else break;
}
});
}
const state = await page.evaluate(() => window.W && window.W.state ? { day: window.W.state.day, ap: window.W.state.ap } : null);
console.log('state after clicks:', JSON.stringify(state));
await page.screenshot({ path: '/tmp/gameplay_after_fix.png' });
console.log('\nErrors seen:');
const interesting = logs.filter(l => !l.includes('favicon.ico'));
interesting.length ? interesting.forEach(l => console.log(' ', l)) : console.log(' (none besides favicon)');
await browser.close();
})().catch(e => { console.error('FLOW ERROR:', e.message); process.exit(1); });
+52
View File
@@ -0,0 +1,52 @@
#!/usr/bin/env node
/* Real-browser repro: loads the game in headless Chromium, captures console
errors, and performs TRUE hit-tested clicks on the title menu buttons. */
const { chromium } = require('/app/node_modules/.pnpm/playwright@1.61.1/node_modules/playwright');
(async () => {
const browser = await chromium.launch({ executablePath: '/root/.cache/ms-playwright/chromium-1234/chrome-linux64/chrome', args: ['--no-sandbox'] });
const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
const logs = [];
page.on('console', m => logs.push(`[console.${m.type()}] ${m.text()}`));
page.on('pageerror', e => logs.push(`[PAGEERROR] ${e.message}`));
await page.goto(process.argv[2] || 'http://127.0.0.1:8914/wuxia.html', { waitUntil: 'load' });
await page.waitForTimeout(1200);
// What's at the center of the New Journey button?
const probe = await page.evaluate(() => {
const btns = [...document.querySelectorAll('.title-menu .btn')];
return btns.map(b => {
const r = b.getBoundingClientRect();
const cx = r.x + r.width / 2, cy = r.y + r.height / 2;
const top = document.elementFromPoint(cx, cy);
return {
label: b.textContent.trim().slice(0, 24),
rect: `${Math.round(r.x)},${Math.round(r.y)} ${Math.round(r.width)}x${Math.round(r.height)}`,
topElement: top ? (top.tagName + '.' + String(top.className).split(' ').join('.')) : 'NULL (offscreen?)',
topIsButtonOrChild: top ? b.contains(top) || top === b : false,
};
});
});
console.log('Title buttons hit-test:');
for (const p of probe) console.log(' •', JSON.stringify(p));
// True click attempt on New Journey
const nj = page.locator('.title-menu .btn', { hasText: 'New Journey' }).first();
try {
await nj.click({ timeout: 3000 });
console.log('CLICK: dispatched');
} catch (e) {
console.log('CLICK FAILED:', e.message.split('\n')[0]);
}
await page.waitForTimeout(400);
const createVisible = await page.evaluate(() => !document.querySelector('.screen-create').classList.contains('hidden'));
console.log('creation screen visible after click:', createVisible);
// Screenshot for visual confirmation
await page.screenshot({ path: '/tmp/title_after_click.png' });
console.log('\nConsole/page errors:');
logs.length ? logs.forEach(l => console.log(' ', l)) : console.log(' (none)');
await browser.close();
})().catch(e => { console.error('REPRO ERROR:', e.message); process.exit(1); });
+222
View File
@@ -0,0 +1,222 @@
#!/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);
+267
View File
@@ -0,0 +1,267 @@
#!/usr/bin/env node
/* UI smoke test: boots wuxia.html in jsdom with a canvas 2D stub,
then drives the real UI through creation -> days -> panels -> combat. */
const { JSDOM } = require('/app/node_modules/.pnpm/jsdom@29.1.1/node_modules/jsdom');
const fs = require('fs');
const html = fs.readFileSync('/root/Wuxia/wuxia.html', 'utf8');
function makeCtxStub() {
const gradient = { addColorStop() { } };
const handler = {
get(target, prop) {
if (prop === 'createLinearGradient' || prop === 'createRadialGradient' || prop === 'createPattern') return () => gradient;
if (prop === 'measureText') return () => ({ width: 10 });
if (prop === 'getImageData') return (x, y, w, h) => ({ data: new Uint8ClampedArray(w * h * 4) });
if (typeof prop === 'string') {
if (!(prop in target)) target[prop] = (...args) => undefined;
return target[prop];
}
return undefined;
},
set(target, prop, v) { target[prop] = v; return true; },
};
return new Proxy({}, handler);
}
const errors = [];
const origErr = console.error;
console.error = (...a) => { errors.push(a.map(x => (x && x.stack) ? x.stack.split('\n')[0] : String(x)).join(' ')); };
const dom = new JSDOM(html.replace('<script>', '<script>window.__JSDOM__=1;'), {
runScripts: 'dangerously',
resources: 'usable',
url: 'http://localhost/',
pretendToBeVisual: true,
beforeParse(window) {
window.HTMLCanvasElement.prototype.getContext = function () { return makeCtxStub(); };
window.HTMLCanvasElement.prototype.toDataURL = function () { return 'data:image/png;base64,x'; };
window.addEventListener('error', e => errors.push('window error: ' + e.message));
},
});
const { window } = dom;
const doc = window.document;
function fail(msg) { console.error('✗ FAIL:', msg); process.exitCode = 1; }
function ok(msg) { console.log('✓', msg); }
function clickByText(sel, text) {
const els = [...doc.querySelectorAll(sel)];
const t = els.find(e => e.textContent.includes(text));
if (!t) { fail(`no ${sel} containing "${text}"`); return null; }
t.dispatchEvent(new window.MouseEvent('click', { bubbles: true }));
return t;
}
const sleep = ms => new Promise(r => setTimeout(r, ms));
(async () => {
await sleep(300);
try {
if (!window.W) { fail('W namespace missing'); process.exit(1); }
ok('game booted, version ' + window.W.VERSION);
if (!doc.querySelector('.title-box')) fail('title screen missing');
else ok('title screen rendered');
// new journey -> creation
clickByText('.btn', 'New Journey');
await sleep(50);
if (!doc.querySelector('.bg-card')) fail('creation backgrounds missing'); else ok('creation screen rendered (' + doc.querySelectorAll('.bg-card').length + ' backgrounds)');
// pick a background & difficulty
clickByText('.bg-card', 'Old Soldier');
doc.querySelector('.inp-name').value = 'Chen Feng';
doc.querySelector('.inp-sect').value = 'Iron Lotus Reborn';
clickByText('.diff-list .diff-card', 'Jianghu');
// set out
clickByText('.create-actions .btn', 'Set Out');
await sleep(100);
if (!doc.querySelector('.hud-top')) fail('HUD missing after start'); else ok('game HUD rendered');
if (!window.W.state) fail('state missing');
else ok('new game: day ' + window.W.state.day + ', party of ' + window.W.state.party.length);
// dismiss intro notice
const contBtn = [...doc.querySelectorAll('.modal .btn')].find(b => b.textContent.includes('Continue'));
if (contBtn) { contBtn.click(); await sleep(30); }
// panels
for (const name of ['Map', 'Sect', 'Disciples', 'Arts', 'Factions', 'Journal']) {
clickByText('#navbtns .btn', name);
await sleep(20);
if (doc.querySelector('.panel-layer.hidden')) fail('panel ' + name + ' did not open');
}
ok('all side panels open/close');
// equip arts panel specifics
clickByText('#navbtns .btn', 'Arts');
await sleep(20);
const eqSlots = doc.querySelectorAll('.eq-slot').length;
if (eqSlots !== 4) fail('expected 4 equip slots, got ' + eqSlots); else ok('arts panel shows 4 equip slots');
window.W.ui.closePanel();
// perform some actions
const app = window.W.app;
for (let i = 0; i < 3; i++) { app.doAction('train'); }
ok('training actions executed, AP left: ' + window.W.state.ap);
app.doAction('meditate');
// end day xN, resolving events/combats automatically
let resolvedCombats = 0;
for (let day = 0; day < 12; day++) {
// resolve any pending event modal
for (let k = 0; k < 6 && doc.querySelector('.event-box'); k++) {
const choices = [...doc.querySelectorAll('.choice')].filter(c => !c.classList.contains('disabled'));
const pick = choices.length ? choices[Math.floor(Math.random() * choices.length)] : null;
if (pick) pick.click();
await sleep(30);
const cont = [...doc.querySelectorAll('.modal .btn')].find(b => b.textContent.includes('Continue') || b.textContent.includes('Yes'));
if (cont && !cont.closest('.confirm-box')) { cont.click(); await sleep(20); }
}
// resolve confirm boxes (travel prompts etc.)
const yes = [...doc.querySelectorAll('.confirm-box .btn')].find(b => b.textContent.includes('Yes'));
if (yes) { yes.click(); await sleep(20); }
// resolve meetings
if (doc.querySelector('.choices') && window.W.state.meeting) {
const btns = [...doc.querySelectorAll('.choices .btn:not(.disabled)')];
if (btns.length) { btns[btns.length - 1].click(); await sleep(20); }
}
// resolve combats quickly via engine
if (window.W.state.combat) {
const st = window.W.state;
let g = 0;
while (st.combat && !st.combat.over && g++ < 120) {
const cur = window.W.combat.current(st.combat);
if (!cur || cur.dead) { window.W.combat.advance(st, st.combat); continue; }
if (cur.side === 'ally') {
const foes = st.combat.units.filter(u => u.side === 'enemy' && !u.dead);
if (!foes.length) break;
const tgt = foes.sort((a, b) => window.W.U.dist(cur.x, cur.y, a.x, a.y) - window.W.U.dist(cur.x, cur.y, b.x, b.y))[0];
if (window.W.U.dist(cur.x, cur.y, tgt.x, tgt.y) <= 1) window.W.combat.attack(st, st.combat, cur, tgt);
else {
const reach = window.W.combat.reachable(st.combat, cur);
let best = null, bd = 1e9;
for (const c of reach) { const d = window.W.U.dist(c.x, c.y, tgt.x, tgt.y); if (d < bd) { bd = d; best = c; } }
if (best) window.W.combat.moveUnit(st, st.combat, cur, best.x, best.y);
else window.W.combat.guard(st, st.combat, cur);
}
window.W.combat.advance(st, st.combat);
} else { window.W.combat.aiAct(st, st.combat, cur); window.W.combat.advance(st, st.combat); }
}
if (st.combat && st.combat.over) {
resolvedCombats++;
// wait for finishCombat timer
await sleep(900);
const res = [...doc.querySelectorAll('.modal .btn')].find(b => b.textContent.includes('Continue'));
if (res) { res.click(); await sleep(40); }
const res2 = [...doc.querySelectorAll('.modal .btn')].find(b => b.textContent.includes('Continue'));
if (res2) { res2.click(); await sleep(40); }
}
}
if (window.W.state.ended) break;
app.endDay();
await sleep(60);
// close event results
const conts = [...doc.querySelectorAll('.modal .btn')].filter(b => b.textContent.includes('Continue'));
if (conts.length) { conts[0].click(); await sleep(20); }
}
ok('survived 12 days in UI, day now ' + window.W.state.day + ', combats fought: ' + resolvedCombats + ', gold: ' + Math.round(window.W.state.res.gold));
// travel via map panel
window.W.ui.openPanel('map');
await sleep(30);
const mapCv = doc.querySelector('.jianghu-map');
if (!mapCv) fail('jianghu map canvas missing'); else ok('jianghu scroll map rendered');
window.W.ui.closePanel();
// save / reload roundtrip through UI storage
app.saveGame('1');
const before = window.W.state.day;
app.loadGame('1');
await sleep(50);
if (window.W.state.day !== before) fail('save/load day mismatch'); else ok('UI save/load roundtrip OK (day ' + before + ')');
// portraits & scene art generation (canvas stubs exercised)
const p = window.W.sim.player(window.W.state);
const pcv = window.W.portrait(p, 'angry');
if (!pcv) fail('portrait generation failed'); else ok('procedural portrait generated');
const scv = window.W.sceneArt('bridge_rain', 400, 200);
if (!scv) fail('scene art failed'); else ok('event scene art generated');
// ---- force an event modal through the real pipeline ----
const st2 = window.W.state;
window.W.sim.rollEvent(st2, 'sect');
if (st2.pendingEvent) {
window.W.ui.showEvent(st2.pendingEvent);
await sleep(30);
if (!doc.querySelector('.event-box')) fail('event modal did not render');
else {
const ch = [...doc.querySelectorAll('.choice')].filter(c => !c.classList.contains('disabled'));
if (ch.length) { ch[0].click(); await sleep(40); }
const conts = [...doc.querySelectorAll('.modal .btn')];
if (conts.length) { conts[conts.length - 1].click(); await sleep(20); }
ok('event modal shown & choice resolved');
}
} else ok('no sect event rolled this day (ok)');
// ---- force a combat through the app flow and click through it ----
const spec = { enemies: window.W.sim.encounterFor(st2, 1), context: 'road' };
st2.pendingCombat = null;
// beginCombat directly (intro modal skipped)
st2.meeting = null;
spec._foes = window.W.sim.buildCombatEnemies(st2, spec);
for (const c of window.W.sim.party(st2)) if (c.alive) c.hp = c.maxHp;
window.W.combat.create(st2, spec);
window.W.ui.showCombat();
await sleep(100);
if (!doc.querySelector('.combat-hud')) fail('combat HUD missing'); else ok('combat HUD rendered');
let guard = 0;
while (st2.combat && !st2.combat.over && guard++ < 60) {
const cur = window.W.combat.current(st2.combat);
if (!cur || cur.dead) { window.W.combat.advance(st2, st2.combat); continue; }
if (cur.side === 'ally') {
// use the real UI command functions
window.W.app.cbtMode('attack');
const foes = st2.combat.units.filter(u => u.side === 'enemy' && !u.dead && window.W.U.dist(cur.x, cur.y, u.x, u.y) <= 1);
if (foes.length) window.W.combat.attack(st2, st2.combat, cur, foes[0]);
else {
const reach = window.W.combat.reachable(st2.combat, cur);
const tgt = st2.combat.units.filter(u => u.side === 'enemy' && !u.dead)[0];
let best = null, bd = 1e9;
for (const c of reach) { const d = window.W.U.dist(c.x, c.y, tgt.x, tgt.y); if (d < bd) { bd = d; best = c; } }
if (best) window.W.combat.moveUnit(st2, st2.combat, cur, best.x, best.y);
else window.W.app.cbtGuard();
}
window.W.combat.advance(st2, st2.combat);
} else { window.W.combat.aiAct(st2, st2.combat, cur); window.W.combat.advance(st2, st2.combat); }
}
await sleep(900);
if (st2.combat && st2.combat.over) {
// drive the app's own finish path
window.W.app.finishCombat();
await sleep(60);
}
if (doc.querySelector('.result-box')) {
ok('combat finished & result screen rendered (' + (st2.combat ? st2.combat.result.outcome : '?') + ')');
const contBtns = [...doc.querySelectorAll('.modal .btn')].filter(b => b.textContent.includes('Continue'));
if (contBtns.length) contBtns[0].click();
await sleep(40);
} else fail('combat result screen missing');
// ---- ending screen ----
st2.day = 100;
window.W.app.finishRun(true);
await sleep(400);
if (!doc.querySelector('.ending-screen')) fail('ending screen missing');
else {
ok('ending screen rendered: ' + (doc.querySelector('.ending-title') || {}).textContent);
const again = [...doc.querySelectorAll('.ending-screen .btn')].find(b => b.textContent.includes('Title'));
if (again) { again.click(); await sleep(60); }
if (!doc.querySelector('.title-box')) fail('return to title failed'); else ok('return to title works');
}
if (errors.length) { fail('page/console errors: ' + errors.slice(0, 5).join(' | ')); }
else ok('no page errors');
console.log('\nUI SMOKE TEST COMPLETE');
process.exit(process.exitCode || 0);
} catch (e) {
console.error('✗ EXCEPTION:', e.stack.split('\n').slice(0, 6).join('\n'));
process.exit(1);
}
})();