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)
268 lines
12 KiB
JavaScript
268 lines
12 KiB
JavaScript
#!/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);
|
|
}
|
|
})();
|