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

538 lines
30 KiB
JavaScript

/**
* dev/skills-window-browser.test.mjs — the SKILL TREE CONSOLE in a real browser.
*
* The layout the player is promised (data/skills.json → window.tree): the class
* PLATE upper-left, the TREE of icon tiles upper-right (two-thirds of the body),
* the DETAIL DRAWER along the bottom third, and the three actions — RESET,
* ASSIGN/CLEAR SEAT, + ADD A SKILL POINT — in that drawer's lower-right corner.
* Presses are REAL mouse clicks on the rects the console publishes
* (SkillWindow.actionRects()) mapped through the canvas's scale; a REAL Digit press
* places the handed skill. The session rule is walked too: rank up, RESET gives it
* back while the console is open, closing commits what is left.
*
* node dev/server.mjs 8123 &
* node dev/skills-window-browser.test.mjs
*/
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 VANGUARD = skills.classes.vanguard;
const RAM = VANGUARD.skills.find((s) => s.effect?.kind === 'dashBlast');
const PLATE = VANGUARD.skills.find((s) => s.effect?.kind === 'hullPool');
const RAM_CELL = RAM.node ?? { col: 1, row: 0 };
const PLATE_CELL = PLATE.node ?? { col: 3, row: 0 };
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));
/** Everything the console will say about itself, in one round-trip. */
const view = () => page.evaluate(() => {
const g = window.game.scene.getScene('GameScene');
const w = g.skillWindow;
const texts = [];
const walk = (go) => {
if (go.type === 'Text' && go.text) texts.push({ text: String(go.text), bounds: go.getBounds() });
for (const child of go.list ?? []) walk(child);
};
if (w) walk(w);
return {
open: !!w?.isOpen,
shipsOpen: !!g.shipWindow?.isOpen,
door: g.shipScreenSnapshot().skillDoor,
selected: w?.selected ?? null,
regions: w?.regions?.() ?? null,
scroll: w?.scrollState?.() ?? null,
snap: w?.getSnapshot ? w.getSnapshot() : null,
hits: (w?.actionRects?.() ?? []).map((h) => ({ kind: h.kind, id: h.id, cx: Math.round(h.cx), cy: Math.round(h.cy) })),
tiles: (w?._tree?.nodes ?? []).map((n) => ({
id: n.id,
visible: !!n.visible,
lx: n.lx,
ly: n.ly,
size: n.size,
x: Math.round(n.rect.x),
y: Math.round(n.rect.y),
})),
texts: texts.map((t) => ({ text: t.text, r: Math.round(t.bounds.right), b: Math.round(t.bounds.bottom), l: Math.round(t.bounds.left), t: Math.round(t.bounds.top) })),
seats: [...g.skillState.seats],
ranks: Object.fromEntries(g.skillState.ranks),
pending: Object.fromEntries([...g.skillState.ranks].map(([id]) => [id, g.skillState.pendingOf(id)])),
points: g.classState.skillPoints,
session: g.skillState.inSession,
};
});
/** A real click at one of the console's published rects (game point → page pixel). */
const clickAt = async (cx, cy) => {
const r = await page.evaluate(() => {
const c = document.querySelector('canvas').getBoundingClientRect();
return { left: c.left, top: c.top, w: c.width, h: c.height, gw: window.game.scale.width, gh: window.game.scale.height };
});
await page.mouse.click(r.left + cx * (r.w / r.gw), r.top + cy * (r.h / r.gh));
await page.waitForTimeout(150);
};
const clickKind = async (kind, id) => {
const pt = await page.evaluate(({ kind, id }) => {
const w = window.game.scene.getScene('GameScene').skillWindow;
const h = (w.actionRects() ?? []).find((r) => r.kind === kind && (id === undefined || r.id === id));
return h ? { cx: h.cx, cy: h.cy } : null;
}, { kind, id });
if (!pt) return false;
await clickAt(pt.cx, pt.cy);
return true;
};
/** Click a tile where it actually is (its rect moves with the scroll). */
const clickTile = async (id) => {
const t = await page.evaluate((id) => {
const w = window.game.scene.getScene('GameScene').skillWindow;
const n = (w._tree?.nodes ?? []).find((n) => n.id === id);
return n && n.visible ? { cx: n.rect.x + n.size / 2, cy: n.rect.y + n.size / 2 } : null;
}, id);
if (!t) return false;
await clickAt(t.cx, t.cy);
return true;
};
const selectTile = async (id) => {
await page.evaluate((id) => window.game.scene.getScene('GameScene').skillWindow.select(id), id);
await page.waitForTimeout(150);
};
/** ESC, with the comms politely stepped around: a comm owns the keyboard, so the
* escape is re-issued once the screen gives it back. */
const closeComms = () => page.evaluate(() => {
const g = window.game.scene.getScene('GameScene');
if (g.comms?.paused) g.comms._ack();
});
const pressEscape = async () => {
for (let i = 0; i < 4; i++) {
await closeComms();
await page.waitForTimeout(200);
await page.keyboard.press('Escape');
await page.waitForTimeout(250);
if (!(await page.evaluate(() => window.game.scene.getScene('GameScene').skillWindow.isOpen))) return true;
}
return false;
};
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 DOOR
// 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 === 4 && v.snap.rows.every((r) => r.def?.label), `the tree carries the class's ${v.snap.rows.length} skills (${v.snap.rows.map((r) => r.def.label).join(', ')})`);
check(v.session === true, 'opening the console opens a SESSION (SkillState.beginSession) — the snapshot RESET unwinds to');
// --------------------------------------------------------------- THE LAYOUT
const reg = v.regions;
check(reg.tree.x > reg.plate.x + reg.plate.w && reg.tree.y === reg.plate.y, `the tree sits to the RIGHT of the class plate, top-aligned with it (tree x ${reg.tree.x}, plate ${reg.plate.x}+${reg.plate.w})`);
check(
reg.tree.w > reg.plate.w * 2 && Math.abs(reg.tree.x + reg.tree.w - (reg.panel.x + reg.panel.w) + 17) < 4,
`the tree is the upper-right two-thirds (${reg.tree.w}px across against the plate's ${reg.plate.w}px, flush with the panel's inner edge)`,
);
check(
reg.drawer.h / (reg.panel.h - 40 - 32) >= 0.3 && reg.drawer.y > reg.tree.y + reg.tree.h,
`the detail drawer is the bottom third (${reg.drawer.h}px of ${reg.panel.h - 40 - 32}px, below the tree at y ${reg.tree.y + reg.tree.h})`,
);
check(v.tiles.length === 4 && v.tiles.every((t) => t.visible), `all four tiles are on the grid (${v.tiles.map((t) => `${t.id}@${t.lx},${t.ly}`).join(' ')})`);
const GRID = skills.window.tree.gap; // the tile pitch is the data's, not a guess
const cellOf = (t) => ({
col: Math.round((t.lx - v.tiles[0].lx) / (t.size + GRID.colPx)),
row: Math.round((t.ly - v.tiles[0].ly) / (t.size + GRID.rowPx)),
});
const ramTile = v.tiles.find((t) => t.id === RAM.id);
const plateTile = v.tiles.find((t) => t.id === PLATE.id);
check(
cellOf(ramTile).col === 0 && cellOf(plateTile).col - cellOf(ramTile).col === PLATE_CELL.col - RAM_CELL.col && cellOf(plateTile).row === cellOf(ramTile).row,
`the tiles sit on the grid the data authored (${RAM.id} col ${RAM_CELL.col}, ${PLATE.id} col ${PLATE_CELL.col} → ${(cellOf(ramTile).col, cellOf(plateTile).col)})`,
);
check(v.tiles.every((t) => t.x >= reg.tree.x - 1 && t.x + t.size <= reg.tree.x + reg.tree.w + 1), 'no tile crosses out of the tree pane');
// No template ever reaches the screen half-filled.
const leaks = v.texts.filter((t) => /\{\w+\}/.test(t.text)).map((t) => t.text);
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 w = window.game.scene.getScene('GameScene').skillWindow;
const t0 = { ...(w.fxPainted?.() ?? {}) };
await new Promise((r) => setTimeout(r, 9000)); // long enough to cross the data's own windows
return { before: t0, after: w.fxPainted() };
});
check(v.texts.some((t) => t.text.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 the cabinet.
const spill = { out: [], right: reg.panel.x + reg.panel.w, bottom: reg.panel.y + reg.panel.h };
for (const t of v.texts) if (t.r > spill.right + 1 || t.b > spill.bottom + 1 || t.l < reg.panel.x - 1 || t.t < reg.panel.y - 1) spill.out.push(`${t.text.slice(0, 22)}@${t.r},${t.b}`);
check(spill.out.length === 0, `every line stays inside the panel (${spill.out.join(' | ')})`);
// ------------------------------------------------------- THE DRAWER (SELECT)
await clickTile(RAM.id);
v = await view();
check(v.selected === RAM.id, 'a tile click makes that skill the drawer\u2019s subject');
const drawerKinds = v.hits.filter((h) => h.cy > reg.drawer.y).map((h) => h.kind);
check(
drawerKinds.includes('rankUp') && drawerKinds.includes('reset') && (drawerKinds.includes('clearSeat') || drawerKinds.includes('assign')),
`the drawer carries the three actions lower-right (${[...new Set(drawerKinds)].join(', ')})`,
);
const rankBtn = v.hits.find((h) => h.kind === 'rankUp' && h.id === RAM.id);
check(!!rankBtn && rankBtn.cy > reg.drawer.y, 'the ADD A SKILL POINT button belongs to the selected skill');
await page.screenshot({ path: `${SHOTS}skills-window-selected.png`, timeout: 60000 });
// -------------------------------------------------- SPEND · RESET · COMMIT
const pts0 = v.points;
await clickKind('rankUp', RAM.id);
await page.waitForFunction((p) => window.game.scene.getScene('GameScene').classState.skillPoints === p, pts0 - 1, { timeout: 40000 });
await clickKind('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 ADD A SKILL POINT clicks ranked ${RAM.label} to ${v.ranks[RAM.id]}/5`);
check(v.points === pts0 - 2 && v.pending[RAM.id] === 2, `the bank paid and the session is holding both points (pending ${JSON.stringify(v.pending)})`);
// Re-clicking the SELECTED tile spends too (one control, two hands).
await clickTile(RAM.id);
await page.waitForFunction((p) => window.game.scene.getScene('GameScene').classState.skillPoints === p, pts0 - 3, { timeout: 40000 });
v = await view();
check(v.ranks[RAM.id] === 3, `re-clicking the selected tile spent a third point (${v.ranks[RAM.id]}/5)`);
// RESET hands back ONLY what this session ranked.
await clickKind('reset', RAM.id);
await page.waitForTimeout(200);
v = await view();
check((v.ranks[RAM.id] ?? 0) === 0 && v.points === pts0, `RESET gave the session's ${RAM.label} points straight back (rank ${v.ranks[RAM.id] ?? 0}, bank ${v.points}/${pts0})`);
// The PASSIVE is live the moment its first rank lands (the hull pool grows).
const hullBefore = await page.evaluate(() => window.game.scene.getScene('GameScene').combat?.maxHull ?? 0);
await selectTile(PLATE.id);
v = await view();
check(v.selected === PLATE.id && !v.hits.some((h) => h.id === PLATE.id && (h.kind === 'assign' || h.kind === 'clearSeat')), `${PLATE.label} (PASSIVE) gets no seat action in the drawer`);
await clickKind('rankUp', PLATE.id);
await page.waitForFunction((p) => window.game.scene.getScene('GameScene').classState.skillPoints === p, pts0 - 1, { timeout: 40000 });
await page.waitForTimeout(250);
const hullAfter = await page.evaluate(() => window.game.scene.getScene('GameScene').combat?.maxHull ?? 0);
v = await view();
check(v.ranks[PLATE.id] === 1, `${PLATE.label} ranked to ${v.ranks[PLATE.id]}/5 from the drawer`);
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 });
// And RESET takes the hull back down again — the seam runs both ways.
await clickKind('reset', PLATE.id);
await page.waitForTimeout(300);
const hullReset = await page.evaluate(() => window.game.scene.getScene('GameScene').combat?.maxHull ?? 0);
check(hullReset <= hullBefore, `RESET re-rolled the passives (${hullAfter} → ${hullReset})`);
// Closing COMMITS: rank one skill up, close the console, and the point is spent.
await selectTile(RAM.id);
await clickKind('rankUp', RAM.id);
await page.waitForFunction((p) => window.game.scene.getScene('GameScene').classState.skillPoints === p, pts0 - 1, { timeout: 40000 });
const closedByEscape = await pressEscape();
check(closedByEscape, 'ESC closes the console');
v = await view();
check(v.session === false && v.points === pts0 - 1 && v.ranks[RAM.id] === 1, `closing the console committed it (rank ${v.ranks[RAM.id]}, bank ${pts0 - 1}, session ${v.session})`);
// Re-opening files a NEW session: the committed rank is not refundable.
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();
check(v.session === true && Object.values(v.pending).every((n) => n === 0), 'a fresh session has nothing pending — the committed rank is outside RESET');
await clickKind('reset', RAM.id);
await page.waitForTimeout(200);
v = await view();
check(v.ranks[RAM.id] === 1 && v.points === pts0 - 1, 'RESET on a committed rank says no and moves nothing');
// --------------------------------------------------------------- THE HAND-OFF
// CLEAR SEAT frees the default binding, ASSIGN SEAT raises the hand (the console
// steps aside — and does NOT commit) and Digit 3 places it.
await selectTile(RAM.id);
v = await view();
if (v.seats.includes(RAM.id)) {
await clickKind('clearSeat', RAM.id);
await page.waitForFunction((id) => !window.game.scene.getScene('GameScene').skillState.seats.includes(id), RAM.id, { timeout: 40000 });
}
const pendingBefore = (await view()).points;
await selectTile(RAM.id);
const handed = await clickKind('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 });
await page.waitForFunction(() => window.game.scene.getScene('GameScene').skillWindow.isOpen, null, { 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`);
check(v.points === pendingBefore && v.session === true, 'the hand-off did NOT commit the session (still undoable after the bar took its pick)');
// Back in the console: the tile that holds a seat offers CLEAR SEAT again.
await selectTile(RAM.id);
v = await view();
check(!!v.hits.find((h) => h.kind === 'clearSeat' && h.id === RAM.id), 'the bound skill offers CLEAR SEAT (and no ASSIGN while it holds a seat)');
// ------------------------------------------------------------- THE SCROLLER
// The tree pane is a SCROLLER: content taller than the pane must scroll (wheel,
// drag, thumb), cull what cannot fit whole and move its thumb. Two Vanguard skills
// do not exercise that, so for the duration of these checks the console is shown a
// long tree — a view-only substitution (getSnapshot is swapped and given back; the
// ledger is never touched).
const injectLongTree = () => page.evaluate((count) => {
const w = window.game.scene.getScene('GameScene').skillWindow;
window.__realSnapshot = window.__realSnapshot ?? w.getSnapshot;
const defs = Array.from({ length: count }, (_, i) => ({
id: `probe_${i}`, label: `PROBE ${i}`, frame: i % 8, maxPoints: 5,
type: { key: 'offense', label: 'OFFENSE', accentCss: '#ff8a3d' },
energy: 40 + i, cooldown: 3, description: 'A probe skill, for the scroller.',
node: { col: i % 5, row: Math.floor(i / 5) }, requires: [],
}));
w.getSnapshot = () => ({
hasClass: true, classId: 'vanguard', className: 'VANGUARD', points: 4, iconReady: true,
frame: 0, seatCount: 8, pending: 0,
rows: defs.map((def) => ({ id: def.id, def, rank: 1, max: 5, state: 'upgradable', seat: null, pending: 0, locked: null })),
});
w._sig = '';
w.refresh();
const out = (w._tree?.nodes ?? []).map((n) => ({ id: n.id, visible: !!n.visible, y: Math.round(n.rect.y), bottom: Math.round(n.rect.y + n.size) }));
return { regions: w.regions(), scroll: w.scrollState(), tiles: out };
}, 22);
const restoreSnapshot = () => page.evaluate(() => {
const w = window.game.scene.getScene('GameScene').skillWindow;
if (window.__realSnapshot) w.getSnapshot = window.__realSnapshot;
w._sig = '';
w.refresh();
});
const long = await injectLongTree();
check(long.scroll.maxScroll > 0, `a tree taller than the pane reports ${long.scroll.maxScroll}px of overflow (pane ${long.regions.tree.h}px tall)`);
const shownTop = long.tiles.filter((t) => t.visible);
check(shownTop.length > 0 && shownTop.every((t) => t.y >= long.regions.tree.y - 1 && t.bottom <= long.regions.tree.y + long.regions.tree.h + 1), `${shownTop.length} tiles fit the pane and every one of them is inside it`);
check(long.tiles.length - shownTop.length > 0, `${long.tiles.length - shownTop.length} tiles are off-pane and drawn away (this build has no clip mask)`);
await page.screenshot({ path: `${SHOTS}skills-window-long-tree.png`, timeout: 60000 });
// A REAL wheel over the pane scrolls it (the pointer is put inside the tree region).
const before = (await view()).scroll.scroll;
await page.mouse.move(long.regions.tree.x + long.regions.tree.w / 2, long.regions.tree.y + long.regions.tree.h / 2);
await page.mouse.wheel(0, 240);
await page.waitForTimeout(200);
const afterWheel = (await view()).scroll;
check(afterWheel.scroll > before, `a wheel notch over the pane scrolled it (${before} → ${afterWheel.scroll} of ${afterWheel.maxScroll})`);
// The thumb and the scroll surface exist for an overflowing pane…
const thumb = await page.evaluate(() => {
const w = window.game.scene.getScene('GameScene').skillWindow;
return (w.actionRects() ?? []).filter((h) => h.kind === 'scroll').map((h) => ({ cx: Math.round(h.cx), cy: Math.round(h.cy) }));
});
check(thumb.length === 1, `an overflowing pane grows a thumb the player can hit (${JSON.stringify(thumb)})`);
if (thumb.length === 1) {
await clickAt(thumb[0].cx, long.regions.tree.y + long.regions.tree.h - 6); // bottom of the track
const atBottom = await view();
check(atBottom.scroll.scroll > afterWheel.scroll, `clicking the bottom of the track takes the pane there (${afterWheel.scroll} → ${atBottom.scroll.scroll})`);
}
// …and dragging the grid itself drags the content (the press starts on bare pane,
// 120px down means 120px of content, and nothing gets selected on the way out).
const gapY = await page.evaluate((pane) => {
const w = window.game.scene.getScene('GameScene').skillWindow;
const shown = (w._tree?.nodes ?? []).filter((n) => n.visible).map((n) => n.rect.y);
const sizes = (w._tree?.nodes ?? []).map((n) => n.size);
const size = sizes[0] ?? 60;
for (const y of shown) {
const after = y + size + 4; // just below a row: pane, not tile
if (after > pane.y + 8 && after < pane.y + pane.h - 8) return Math.round(after);
}
return Math.round(pane.y + pane.h / 2);
}, long.regions.tree);
const beforeDrag = (await view()).scroll;
const treeCX = long.regions.tree.x + long.regions.tree.w / 2;
const selectedBefore = (await view()).selected;
// Page pixels are not game pixels (SCALE_FIT): the drag is stated in page pixels and
// checked in the game space the pane works in.
const zoom = await page.evaluate(() => {
const c = document.querySelector('canvas').getBoundingClientRect();
return window.game.scale.height / c.height;
});
const DRAG_PX = 120;
await page.mouse.move(treeCX, gapY);
await page.mouse.down();
await page.mouse.move(treeCX, gapY + DRAG_PX, { steps: 8 });
await page.mouse.up();
await page.waitForTimeout(200);
const dragged = await view();
check(
Math.abs(dragged.scroll.scroll - Math.max(0, beforeDrag.scroll - DRAG_PX * zoom)) <= 4,
`dragging the grid pulls the content with it (${beforeDrag.scroll} → ${dragged.scroll.scroll}, ${Math.round(DRAG_PX * zoom)} game px of drag)`,
);
check(dragged.selected === selectedBefore, 'a drag is a drag: nothing was selected on the way out');
// Selecting a tile that is off-pane brings it into view.
const reveal = await page.evaluate(() => {
const w = window.game.scene.getScene('GameScene').skillWindow;
const far = w._tree.nodes[w._tree.nodes.length - 1];
const hidden = !far.visible;
w.select(far.id);
return { id: far.id, hidden, before: w.scrollState() };
});
await page.waitForTimeout(200);
const revealed = await view();
const nowShown = revealed.tiles.find((t) => t.id === reveal.id);
check(reveal.hidden && nowShown?.visible === true, `selecting an off-pane tile scrolls it into view (${reveal.id}, scroll ${reveal.before.scroll} → ${revealed.scroll.scroll})`);
// -------------------------------------------------------------- THE JOINS
// PIXEL PROOF that the elbow line between two skills is actually ON screen in
// the data's spent colour. Graphics leaves no display-list trace a test can
// read, and this build has no clip mask, so the presented frame is copied into
// a 2D canvas (the raw WebGL backbuffer reads blank once presented — the same
// hook as encounter-hud-check) and the pixels along the joins are counted.
const injectChain = () => page.evaluate(() => {
const w = window.game.scene.getScene('GameScene').skillWindow;
window.__realSnapshot = window.__realSnapshot ?? w.getSnapshot;
const defs = [0, 1, 2].map((i) => ({
id: `chain_${i}`, label: `CHAIN ${i + 1}`, frame: i, maxPoints: 5,
type: { key: 'offense', label: 'OFFENSE', accentCss: '#ff8a3d' },
energy: 40, cooldown: 3, description: 'A skill chained below another.',
node: { col: 0, row: i }, requires: i === 0 ? [] : [{ skill: `chain_${i - 1}`, rank: 1 }],
}));
const ranks = [2, 1, 0];
w.getSnapshot = () => ({
hasClass: true, classId: 'vanguard', className: 'VANGUARD', points: 4, iconReady: true,
frame: 0, seatCount: 8, pending: 0,
rows: defs.map((def, i) => ({ id: def.id, def, rank: ranks[i], max: 5, state: 'upgradable', seat: null, pending: 0, locked: null })),
});
w._sig = '';
w.refresh();
const T = w._tree;
return {
nodes: T.nodes.map((n) => ({ id: n.id, x: Math.round(n.rect.x), y: Math.round(n.rect.y), size: Math.round(n.size), visible: !!n.visible })),
edges: T.layout.edges.map((e) => `${e.from}\u2192${e.to}`),
};
});
const chain = await injectChain();
check(
chain.edges.length === 2 && chain.edges[0].startsWith('chain_0\u2192chain_1'),
`the layout joins the chain: ${chain.edges.join(', ')}`,
);
const gold = await page.evaluate(() => {
const ren = window.game.renderer;
const cv = ren.canvas;
const off = document.createElement('canvas');
off.width = cv.width; off.height = cv.height;
const octx = off.getContext('2d');
const orig = ren.render;
return new Promise((done) => {
// A copy is taken after every present, so the frame read is a REAL frame.
ren.render = function (...a) {
const ret = orig.apply(this, a);
try { octx.drawImage(cv, 0, 0); } catch { /* one bad frame — ignored */ }
return ret;
};
setTimeout(() => {
ren.render = orig;
const T = window.game.scene.getScene('GameScene').skillWindow._tree;
const sx = cv.width / window.game.scale.width, sy = cv.height / window.game.scale.height;
const count = (x, y, w2, h2, test) => {
const X = Math.max(0, Math.round(x * sx)), Y = Math.max(0, Math.round(y * sy));
const W = Math.max(1, Math.round(w2 * sx)), H = Math.max(1, Math.round(h2 * sy));
const d = octx.getImageData(X, Y, W, H).data;
let n = 0;
for (let i = 0; i < d.length; i += 4) if (test(d[i], d[i + 1], d[i + 2])) n++;
return n;
};
const GOLD = (r, g, b) => r > 185 && g > 145 && b < 150 && r - b > 70;
let onJoin = 0, offJoin = 0;
for (const a of T.nodes) {
const child = T.nodes.find((c) => (c.row?.def?.requires ?? []).some((q) => q.skill === a.id));
if (!child || !a.visible || !child.visible) continue;
// The gap BETWEEN the two tiles: the elbow has to cross it. Tile frames are
// stroked inside the tile, so nothing lit of its own lives in this band.
const x0 = Math.min(a.rect.x, child.rect.x) - 4;
const x1 = Math.max(a.rect.x + a.size, child.rect.x + child.size) + 4;
const y0 = a.rect.y + a.size, y1 = child.rect.y;
onJoin += count(x0, y0, x1 - x0, Math.max(1, y1 - y0), GOLD);
// The same-sized band one column to the LEFT: the control that proves the
// gold belongs to the join and not to whatever else glitters on screen.
offJoin += count(x0 - (x1 - x0) - 8, y0, x1 - x0, Math.max(1, y1 - y0), GOLD);
}
done({ onJoin, offJoin });
}, 700);
});
});
check(
gold.onJoin >= 8 && gold.offJoin === 0,
`a completed join is PAINTED across the gap in the spent colour (${gold.onJoin} gold px between the tiles, ${gold.offJoin} in the same band beside it)`,
);
await page.screenshot({ path: `${SHOTS}skills-window-chain.png`, timeout: 60000 });
await restoreSnapshot();
v = await view();
check(v.tiles.length === 4 && v.scroll.maxScroll === 0, `the real tree comes back whole (${v.tiles.length} tiles, no overflow)`);
// ---------------------------------------------------------------- ESC CLOSE
const escaped = await pressEscape();
const after = await view();
check(escaped, 'ESC closes the console from the drawer too');
// 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(' | ') : ''}`);
await page.screenshot({ path: `${SHOTS}skills-window-closed.png`, timeout: 60000 });
fs.writeFileSync(new URL('./shots/skills-window.json', import.meta.url), JSON.stringify({ ram: RAM, plate: PLATE, regions: reg, 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);