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
+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);
}
})();