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:
@@ -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); });
|
||||
@@ -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); });
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user