/** * 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);