orbit/dev/skills-window-browser.test.mjs

220 lines
13 KiB
JavaScript

/**
* dev/skills-window-browser.test.mjs — the SKILL TREE CONSOLE in a real browser:
* the ship console's door, the tree, spending points with RANK UP, handing a skill
* to a bar seat and getting back to the console.
*
* node dev/server.mjs 8123 &
* node dev/skills-window-browser.test.mjs
*
* Presses are REAL mouse clicks on the button rects the window publishes
* (SkillWindow.actionRects()) mapped through the canvas's scale, and a REAL Digit
* press places the handed skill — the flow a player takes.
*/
import fs from 'node:fs';
import { pathToFileURL } from 'node:url';
const { chromium } = await import(
pathToFileURL('/home/brianfertig/.npm/_npx/e41f203b7505f1fb/node_modules/playwright/index.mjs').href
);
const BASE = process.env.ORBIT_URL || 'http://127.0.0.1:8123';
const SHOTS = new URL('./shots/', import.meta.url).pathname;
let fails = 0;
fs.mkdirSync(SHOTS, { recursive: true });
const check = (c, m) => { console.log(` ${c ? '✔' : '✘ FAIL'} ${m}`); if (!c) fails++; };
const data = (f) => JSON.parse(fs.readFileSync(new URL(`../data/${f}`, import.meta.url), 'utf8'));
const skills = data('skills.json');
const RAM = skills.classes.vanguard.skills.find((s) => s.effect?.kind === 'dashBlast');
const PLATE = skills.classes.vanguard.skills.find((s) => s.effect?.kind === 'hullPool');
const browser = await chromium.launch({
headless: true,
executablePath: '/home/brianfertig/.cache/ms-playwright/chromium-1208/chrome-linux64/chrome',
args: ['--no-sandbox', '--use-gl=swiftshader', '--enable-unsafe-swiftshader', '--autoplay-policy=no-user-gesture-required'],
});
const page = await browser.newPage({ viewport: { width: 1440, height: 860 } });
const errors = [];
page.on('console', (m) => { if (m.type() === 'error' && !/Failed to load resource/i.test(m.text())) errors.push(m.text()); });
page.on('pageerror', (e) => errors.push('[pageerror] ' + e.message));
const view = () => page.evaluate(() => {
const g = window.game.scene.getScene('GameScene');
const w = g.skillWindow;
return {
open: !!w?.isOpen,
shipsOpen: !!g.shipWindow?.isOpen,
door: g.shipScreenSnapshot().skillDoor,
rows: (w?._hits ? [] : []).length,
snap: w?.getSnapshot ? w.getSnapshot() : null,
hits: w?.actionRects?.() ?? [],
seats: [...g.skillState.seats],
ranks: Object.fromEntries(g.skillState.ranks),
points: g.classState.skillPoints,
};
});
/** A real click on one of the console's buttons (game point → page pixel). */
const clickButton = async (kind, id) => {
const pt = await page.evaluate(({ kind, id }) => {
const g = window.game.scene.getScene('GameScene');
const h = (g.skillWindow.actionRects() ?? []).find((r) => r.kind === kind && r.id === id);
if (!h) return null;
const r = document.querySelector('canvas').getBoundingClientRect();
return { x: r.left + h.cx * (r.width / window.game.scale.width), y: r.top + h.cy * (r.height / window.game.scale.height) };
}, { kind, id });
if (!pt) return false;
await page.mouse.click(pt.x, pt.y);
await page.waitForTimeout(150);
return true;
};
await page.goto(`${BASE}/index.html?skill&combat`, { waitUntil: 'load' });
await page.waitForFunction(() => window.game?.scene?.getScene?.('MenuScene')?.seedValue, null, { timeout: 90000 });
await page.evaluate(() => { const m = window.game.scene.getScene('MenuScene'); if (!m.dead) m.startNewGame(); });
await page.waitForFunction(() => {
const g = window.game.scene.getScene('GameScene');
if (g.comms?.paused) g.comms._ack();
return !!(g.skillState && g.classState?.hasClass && !g.comms?.paused);
}, null, { timeout: 120000, polling: 400 });
// The ship console's SKILL TREE door is LIVE for a class that owns a tree.
await page.evaluate(() => window.game.scene.getScene('GameScene').shipWindow.open());
await page.waitForFunction(() => window.game.scene.getScene('GameScene').shipWindow.isOpen, null, { timeout: 40000 });
// The tabs paint from the console's own refresh beat — wait for the door to light.
await page.waitForFunction(() => window.game.scene.getScene('GameScene').shipWindow._tabs?.find((t) => t.id === 'skills' && !t.isStandby()), null, { timeout: 40000 });
let v = await view();
check(v.door === true, 'the ship console reports the SKILL TREE door live for a class with a tree');
const tabPressed = await page.evaluate(() => {
const sw = window.game.scene.getScene('GameScene').shipWindow;
const tab = (sw._tabs ?? []).find((t) => t.id === 'skills');
if (!tab || tab.isStandby()) return { ok: false, standby: tab ? tab.isStandby() : null };
tab.g.emit('pointerdown'); // the tab's own press handler
return { ok: true };
});
check(tabPressed.ok === true, 'the SKILL TREE tab is lit (not the OFFLINE socket)');
await page.waitForFunction(() => window.game.scene.getScene('GameScene').skillWindow.isOpen, null, { timeout: 40000 });
await page.waitForFunction(() => (window.game.scene.getScene('GameScene').skillWindow.actionRects() ?? []).length > 0, null, { timeout: 40000 });
// The ship console signs off on its own beat (a close is an exit, not a swap).
await page.waitForFunction(() => !window.game.scene.getScene('GameScene').shipWindow.isOpen, null, { timeout: 40000 });
v = await view();
check(v.open === true && v.shipsOpen === false, 'pressing it opens the Skill Tree console and steps the ship console aside');
check(v.snap.rows.length === 2 && v.snap.rows.every((r) => r.def?.label), `the tree lists the class's ${v.snap.rows.length} skills (${v.snap.rows.map((r) => r.def.label).join(', ')})`);
// No template ever reaches the screen half-filled.
const leaks = await page.evaluate(() => {
const w = window.game.scene.getScene('GameScene').skillWindow;
const out = [];
w.list.forEach((go) => { if (go.type === 'Text' && /\{\w+\}/.test(go.text ?? '')) out.push(go.text); });
return out;
});
check(leaks.length === 0, `every template is filled on the face${leaks.length ? ': ' + leaks.join(' / ') : ''}`);
// The console's own blocks are LIVE: the feed tag (no clip authored → NO SIGNAL) and
// window.fx's sweep/glitch, which must paint at least once while the console is up.
const live = await page.evaluate(async () => {
const g = window.game.scene.getScene('GameScene');
const w = g.skillWindow;
const texts = [];
w.list.forEach((go) => { if (go.type === 'Text' && go.text) texts.push(String(go.text)); });
const t0 = { ...(w.fxPainted?.() ?? {}) };
await new Promise((r) => setTimeout(r, 9000)); // long enough to cross the data's own windows
return { texts, before: t0, after: w.fxPainted() };
});
check(live.texts.some((t) => t.includes(live.after.feedTag)), `the plate prints the feed's own tag (${live.after.feedTag}) — window.video is read, not decorative`);
check(live.after.sweeps > live.before.sweeps || live.after.glitches > live.before.glitches, `window.fx is live off its data timings (sweeps ${live.before.sweeps} → ${live.after.sweeps}, glitches ${live.before.glitches} → ${live.after.glitches})`);
// Nothing hangs off the panel: every string stays inside its console.
const spill = await page.evaluate(() => {
const g = window.game.scene.getScene('GameScene');
const w = g.skillWindow;
const pad = Math.max(16, Math.round(Math.min(g.scale.width, g.scale.height) * 0.05));
const right = (g.scale.width - Math.min(1080, g.scale.width - pad * 2)) / 2 + Math.min(1080, g.scale.width - pad * 2);
const bottom = (g.scale.height - Math.min(620, g.scale.height - pad * 2)) / 2 + Math.min(620, g.scale.height - pad * 2);
const out = [];
w.list.forEach((go) => {
if (go.type !== 'Text') return;
const r = go.getBounds();
if (r.right > right + 1 || r.bottom > bottom + 1) out.push(`${String(go.text).slice(0, 22)}@${Math.round(r.right)},${Math.round(r.bottom)}`);
});
return { out, right: Math.round(right), bottom: Math.round(bottom) };
});
check(spill.out.length === 0, `every line stays inside the panel (${spill.out.join(' | ')})`);
const rankBtn = v.hits.filter((h) => h.kind === 'rankUp').map((h) => h.id);
check(rankBtn.includes(RAM.id) && rankBtn.includes(PLATE.id), 'every skill wears a RANK UP action');
// SPEND: two real clicks on Ramming Charge's RANK UP = 2/5 and the bank pays.
const pts0 = v.points;
await clickButton('rankUp', RAM.id);
await page.waitForFunction((p) => window.game.scene.getScene('GameScene').classState.skillPoints === p, pts0 - 1, { timeout: 40000 });
await clickButton('rankUp', RAM.id);
await page.waitForFunction((p) => window.game.scene.getScene('GameScene').classState.skillPoints === p, pts0 - 2, { timeout: 40000 });
v = await view();
check(v.ranks[RAM.id] === 2, `two RANK UP clicks ranked ${RAM.label} to ${v.ranks[RAM.id]}/5`);
check(v.points === pts0 - 2, `the bank paid one point per click (${pts0} → ${v.points})`);
// BOTH skills take points from the same bank — and the PASSIVE is live the moment
// its first rank lands (the hull pool grows; nothing else changed it).
const hullBefore = await page.evaluate(() => window.game.scene.getScene('GameScene').combat?.maxHull ?? 0);
await clickButton('rankUp', PLATE.id);
await page.waitForFunction((p) => window.game.scene.getScene('GameScene').classState.skillPoints === p, pts0 - 3, { timeout: 40000 });
await page.waitForTimeout(250);
v = await view();
const hullAfter = await page.evaluate(() => window.game.scene.getScene('GameScene').combat?.maxHull ?? 0);
check(v.ranks[PLATE.id] === 1, `${PLATE.label} ranked to 1/5 from the console (bank ${pts0 - 3})`);
check(hullAfter > hullBefore, `the PASSIVE is live at rank 1 — the hull pool went ${hullBefore} → ${hullAfter}`);
await page.screenshot({ path: `${SHOTS}skills-window-ranked.png`, timeout: 60000 });
await page.screenshot({ path: `${SHOTS}skills-window.png`, timeout: 60000 });
// THE HAND-OFF: CLEAR SEAT frees the default binding, ASSIGN SEAT raises the
// hand (the console steps aside) and Digit 3 places it.
await clickButton('clearSeat', RAM.id);
await page.waitForFunction((id) => !window.game.scene.getScene('GameScene').skillState.seats.includes(id), RAM.id, { timeout: 40000 });
const handed = await clickButton('assign', RAM.id);
await page.waitForFunction(() => window.game.scene.getScene('GameScene').combatBar?.awaitingSeat === true, null, { timeout: 40000 });
check(handed, 'ASSIGN SEAT put the skill in the hand (the bar is waiting for a seat)');
const closedForAssign = await view();
check(closedForAssign.open === false, 'the console stepped aside so the bar could be seen');
await page.keyboard.press('Digit3');
await page.waitForFunction((id) => window.game.scene.getScene('GameScene').skillState.seats[2] === id, RAM.id, { timeout: 40000 });
v = await view();
check(v.seats[2] === RAM.id && v.seats[0] === null, `${RAM.label} bound to seat 3 and released its default seat 1`);
// Back in the console: the row now reads SEAT 3 with a CLEAR SEAT action.
await page.evaluate(() => window.game.scene.getScene('GameScene').openSkillWindow({ source: 'test' }));
await page.waitForFunction(() => window.game.scene.getScene('GameScene').skillWindow.isOpen, null, { timeout: 40000 });
v = await view();
const clear = v.hits.find((h) => h.kind === 'clearSeat' && h.id === RAM.id);
check(!!clear, 'the bound row offers CLEAR SEAT (and no ASSIGN while it holds a seat)');
await clickButton('clearSeat', RAM.id);
await page.waitForFunction((id) => !window.game.scene.getScene('GameScene').skillState.seats.includes(id), RAM.id, { timeout: 40000 });
check(true, `CLEAR SEAT cleared it (ranks keep standing: ${JSON.stringify((await view()).ranks)})`);
// A PASSIVE never gets the seat action at all.
const passiveRow = await page.evaluate((id) => {
const g = window.game.scene.getScene('GameScene');
return (g.skillWindow.actionRects() ?? []).filter((h) => h.id === id).map((h) => h.kind);
}, PLATE.id);
check(passiveRow.length === 1 && passiveRow[0] === 'rankUp', `${PLATE.label} (PASSIVE) has no seat action — only RANK UP`);
// ESC closes the console and gives the world back (comms first: a comm owns the
// input, so the escape never reaches the window while one is up).
const clearComms = () => page.evaluate(() => {
const g = window.game.scene.getScene('GameScene');
if (g.comms?.paused) g.comms._ack();
});
await clearComms();
await page.waitForTimeout(250);
await page.keyboard.press('Escape');
await clearComms();
await page.waitForFunction(() => !window.game.scene.getScene('GameScene').skillWindow.isOpen, null, { timeout: 40000 });
const after = await view();
// What still owns the screen? (A comm or the level marquee can legitimately be up
// after a combat wave — the console must not be one of them.)
const busy = await page.evaluate(() => {
const g = window.game.scene.getScene('GameScene');
return { busy: g._screenIsBusy(), by: { comms: !!g.comms?.paused, marquee: !!g.levelMarquee?.active, ship: !!g.shipWindow?.isOpen, skill: !!g.skillWindow?.isOpen } };
});
check(after.open === false && busy.by.skill === false, `ESC closes the console and it stops owning the screen (still up: ${JSON.stringify(busy)})`);
check(errors.length === 0, `no console errors${errors.length ? ': ' + errors.slice(0, 3).join(' | ') : ''}`);
fs.writeFileSync(new URL('./shots/skills-window.json', import.meta.url), JSON.stringify({ ram: RAM, plate: PLATE, final: after }, null, 2));
await browser.close();
console.log(fails ? `\nskills-window-browser: ${fails} FAILURE(S)` : '\nskills-window-browser: all checks passed');
process.exit(fails ? 1 : 0);