538 lines
30 KiB
JavaScript
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);
|