220 lines
13 KiB
JavaScript
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);
|