/** * dev/skills-bar-browser.test.mjs — the SKILL SEATS in a real browser: the eight * seats of the combat bar (data/combat.json → bar.slot, look from data/skills.json * → bar) holding the run's active skills, drawn off assets/images/skills.png, * spent by keys 1-8, reassigned by hand and cleared to make room. * * node dev/server.mjs 8123 & * node dev/skills-bar-browser.test.mjs * * What it pins (the headless mechanics live in dev/skills-combat.test.mjs — this * is the face and the hands): * · a `?skill=max&combat` run boots with the class filed, both skills 5/5 and * the seat Ramming Charge was GIVEN by data filled (nothing to grind); * · the bound seat draws its ICON off the sheet and reads ready; an unbound * seat keeps its reserved empty look; * · pressing 1 dashes: the orb drops by the skill's cost, the seat goes to its * recharge face with the SECONDS printed on it and the sweep unwinds toward * full charge (evidence in dev/shots/); * · a skill the orb cannot pay for draws the "not enough energy" face; * · the ASSIGNMENT hand-off works end to end: the tree hands a skill up (the * scene's beginSkillAssign — what the Skill Tree console's ASSIGN SEAT press * calls), the seats breathe, and the digit-pressed seat takes it. One skill * never holds two seats; PASSIVES refuse a seat outright; * · a RIGHT-press on a seat clears it (making room) and the ledger + the save * registry carry every one of these moves — that record is what a save writes; * · a combat stop/start keeps the bindings (the ledger owns them, not the fight). * * THE RIG: this drives the live demo wave, so it defends itself against the * world — the hull is padded after the passive's numbers are read (a dead ship * restarts combat with full energy mid-assertion), and every key press waits for * a comm/marquee to clear first (a comm owns the keys; see GameScene._skillKeyAction). */ 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; const check = (cond, msg) => { console.log(` ${cond ? '✔' : '✘ FAIL'} ${msg}`); if (!cond) fails++; }; // The numbers under test are the SHIPPED ones (this file owns none of them). const data = (f) => JSON.parse(fs.readFileSync(new URL(`../data/${f}`, import.meta.url), 'utf8')); const skills = data('skills.json'); const combatData = data('combat.json'); const prog = data('progression.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 COST = Number(RAM.energy); const CD = Number(RAM.cooldown); const SEATS = Number(combatData.bar.slot.count); 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)); /** The bar's whole face + the ledger behind it, in one read. */ const snap = () => page.evaluate(() => { const g = window.game.scene.getScene('GameScene'); const b = g.combatBar; return { visible: !!b.visible, awaiting: !!b.awaitingSeat, seats: g.combat.skillSeats(), art: b.seatArt.map((x) => ({ state: x.state, icon: !!x.icon && x.icon.visible, frame: x.icon ? String(x.icon.frame?.name ?? '') : null, secs: x.secs.visible ? x.secs.text : '', })), energy: g.combat.energy, maxEnergy: g.combat.maxEnergy, hull: g.combat.hull, maxHull: g.combat.maxHull, level: g.combat.xp.level, dashing: !!g.combat.dash, down: !!g.combat.playerDown, ship: { x: g.ship.x, y: g.ship.y }, ledgerSeats: [...g.skillState.seats], ranks: Object.fromEntries(g.skillState.ranks), sheetLoaded: g.textures.exists('skill_icons'), // The exact object SaveData.captureState writes as record.playerSkills. recordSeats: JSON.parse(JSON.stringify(g.registry.get('playerSkills').toJSON())).seats, }; }); /** Press through whatever owns the screen (the demo's comms/marquees eat keys). */ const clearScreen = () => page.waitForFunction( () => { const g = window.game.scene.getScene('GameScene'); if (g.comms?.paused) g.comms._ack(); return !g.comms?.paused && !g.comms?.active && !g._screenIsBusy() && !!g.combatBar.visible && !g.combat.playerDown; }, null, { timeout: 90000, polling: 300 }, ); /** * Press a seat and RETURN only once the fight answered. `realKey` drives an actual * DOM Digit keydown through the window (the event Phaser hands to * GameScene._skillKeyAction); otherwise the SAME handler is called with the same * event shape — both are the key's door, the real one just cannot be observed * directly, so it answers by what it moved (energy / the dash). */ const pressSeat = async (digit, { realKey = false } = {}) => { let asked = null; for (let i = 0; i < 8; i++) { await clearScreen(); // Fill the orb first, so what comes off it is EXACTLY the skill's cost. await page.evaluate(() => { const g = window.game.scene.getScene('GameScene'); g.combat.energy = g.combat.maxEnergy; }); const before = await snap(); if (realKey) { await page.keyboard.press(`Digit${digit}`); } else { asked = await page.evaluate((d) => window.game.scene.getScene('GameScene')._skillKeyAction({ key: String(d), code: `Digit${d}` }), digit); } await page.waitForTimeout(120); const after = await snap(); const spent = after.energy < before.energy - 1 || after.dashing; if (realKey ? spent : asked?.ok || spent) return { before, after, asked, spent }; if (!realKey && asked && !asked.ok && asked.reason !== 'dashing') return { before, after, asked, spent }; } return { before: await snap(), after: await snap(), asked, spent: false }; }; /** A pixel clip of seat 1 (game coords → page pixels through the canvas scale). */ const seatClip = () => page.evaluate(() => { const g = window.game.scene.getScene('GameScene'); const bar = g.combatBar; const art = bar.seatArt[0]; const r = document.querySelector('canvas').getBoundingClientRect(); const sx = r.width / g.scale.width; const sy = r.height / g.scale.height; const cx = art.lx + bar.seatBox.size / 2; const cy = art.ly + bar.seatBox.size / 2; const S = (bar.seatBox?.size ?? 44) * 1.2; return { x: Math.max(0, Math.round(r.left + (cx - S / 2) * sx)), y: Math.max(0, Math.round(r.top + (cy - S / 2) * sy)), width: Math.max(8, Math.round(S * sx)), height: Math.max(8, Math.round(S * sy)), }; }); /** Shoot the seat's pixels with named art layers hidden for a beat (then redrawn * from the ledger, so the hide never leaks into later assertions). RENDERED proof * that the layer stack is right — `Text.visible` alone proves nothing. */ const shootSeat = async (hide = []) => { await page.evaluate((keys) => { const art = window.game.scene.getScene('GameScene').combatBar.seatArt[0]; for (const k of keys) if (art[k]) art[k].setVisible(false); }, hide); await page.waitForTimeout(150); const clip = await seatClip(); const buf = await page.screenshot({ clip, timeout: 60000 }); await page.evaluate(() => { const g = window.game.scene.getScene('GameScene'); g.combatBar.setStats(g.combat); // the seat redraws from the ledger's state }); await page.waitForTimeout(150); return buf; }; await page.goto(`${BASE}/index.html?skill=max&combat`, { waitUntil: 'load' }); await page.waitForFunction(() => { const m = window.game?.scene?.getScene?.('MenuScene'); return !!(m && typeof m.seedValue === 'string' && m.seedValue.length > 0); }, null, { timeout: 90000 }); await page.evaluate(() => { const m = window.game.scene.getScene('MenuScene'); if (m && !m.dead) m.startNewGame(); }); await page.waitForFunction(() => { const g = window.game?.scene?.getScene?.('GameScene'); return !!(g && g.ship && g.skillState && g.combat); }, null, { timeout: 90000 }); // The bar is up when the fight is live (the demo wave run makes it so). await page.waitForFunction(() => { const g = window.game.scene.getScene('GameScene'); if (g.comms?.paused) g.comms._ack(); return !!g.combatBar?.visible; }, null, { timeout: 120000, polling: 400 }); // ============================================================ the ready face // The sheet is loaded ON DEMAND by the scene (never in preload), so the load gets // its beat before the assertion that the texture landed. const gotSheet = await page .waitForFunction(() => window.game.scene.getScene('GameScene').textures.exists('skill_icons'), null, { timeout: 30000, polling: 200 }) .then(() => true) .catch(() => false); const a = await snap(); check(gotSheet && a.sheetLoaded === true, 'the seat icons come off assets/images/skills.png (one sheet, loaded on demand)'); check(Object.values(a.ranks).every((n) => n === RAM.maxPoints), `?skill=max ranked the whole tree (${JSON.stringify(a.ranks)})`); check(a.seats.length === SEATS, `the bar carries ${SEATS} seats wearing keys 1-${SEATS}`); const hullWant = Math.round( (Number(combatData.playerShip.hull) + a.level * Number(prog.reward.hullPerLevel)) * (1 + PLATE.effect.hullBonusPerRank * PLATE.maxPoints), ); check(a.maxHull === hullWant, `${PLATE.label} ${PLATE.maxPoints}/${PLATE.maxPoints}: the hull pool is ${a.maxHull} (${combatData.playerShip.hull} + lv${a.level} × ${prog.reward.hullPerLevel}, × 1 + ${PLATE.effect.hullBonusPerRank * 100}% × ${PLATE.maxPoints})`); // THE RIG: the numbers above are read, so pad the hull — a ship killed by the // demo wave restarts combat (full energy, fresh clocks) in mid-assertion. await page.evaluate(() => { const g = window.game.scene.getScene('GameScene'); g.combat.maxHull = 6000; g.combat.hull = 6000; }); const ready = await page .waitForFunction( () => { const g = window.game.scene.getScene('GameScene'); // Under a loaded runner the demo wave can chip the ship and a comm can park // the deck; the wait keeps the range open rather than timing out on scenery. if (g.comms?.paused) g.comms._ack(); if (g.combat) { g.combat.maxHull = 6000; g.combat.hull = 6000; if (g.combat.energy != null && g.combat.maxEnergy != null) g.combat.energy = g.combat.maxEnergy; } const art = g.combatBar.seatArt[0]; return art.icon && art.state === 'ready' ? { ok: true } : null; }, null, { timeout: 60000, polling: 300 }, ) .then(() => snap()); const s0 = ready.seats[0]; check(s0.id === RAM.id && s0.rank === RAM.maxPoints, `seat 1 holds ${RAM.label} ${s0.rank}/${s0.maxPoints} — the DEFAULT binding came from data/skills.json`); check(ready.art[0].icon === true && ready.art[0].frame === String(RAM.frame), `the seat draws icon frame ${RAM.frame} off the sheet (got "${ready.art[0].frame}")`); // RENDERED PROOF: the icon contributes pixels ON TOP of the state layers (an // icon added lazily over the state layers buries them while every property // above still reads correct — that regression is what this catches). await clearScreen(); const seatBase = await shootSeat([]); const noIcon = await shootSeat(['icon']); check(Buffer.compare(seatBase, noIcon) !== 0, 'the icon reaches the pixels too (so it is not buried under the state layers)'); fs.writeFileSync(new URL('./shots/skills-seat.png', import.meta.url), seatBase); await page.screenshot({ path: `${SHOTS}skills-bar-ready.png`, timeout: 60000 }); check(ready.art[0].state === 'ready', `a live, charged, affordable seat reads ready (got ${ready.art[0].state})`); check( ready.seats[1].id === 'inferno' && ready.seats.slice(2).every((s) => s.id === null) && ready.art.slice(2).every((x) => x.state === 'empty' && x.icon === false), 'seat 2 carries the Inferno (its default binding) and the rest keep their reserved EMPTY look', ); await page.screenshot({ path: `${SHOTS}skills-bar-ready.png`, timeout: 60000 }); // =============================================== the key press + the sweep // A real DOM keydown — the event Phaser hands the scene — takes the seat. const cast = await pressSeat(1, { realKey: true }); check(cast.spent === true, `a real Digit-1 keydown casts ${RAM.label} (energy spent / the dash is away)`); check(cast.after.energy <= cast.before.energy - COST + 1, `it spends ${COST} energy (${cast.before.energy.toFixed(1)} → ${cast.after.energy.toFixed(1)})`); check(Math.hypot(cast.after.ship.x - cast.before.ship.x, cast.after.ship.y - cast.before.ship.y) > 40 || cast.after.dashing, 'the hull charged'); // The recharge face: printed seconds + the sweep's fraction climbing to full. await page.waitForFunction(() => window.game.scene.getScene('GameScene').combatBar.seatArt[0].state === 'cooldown', null, { timeout: 40000 }); const mid = await snap(); check(mid.art[0].state === 'cooldown' && /^\d+$/.test(mid.art[0].secs), `the seat recharges with the SECONDS printed ("${mid.art[0].secs}")`); check(mid.seats[0].cooldownLeft > 0 && mid.seats[0].ready === false, `and it is not castable while the clock runs (${mid.seats[0].cooldownLeft.toFixed(1)}s left)`); await page.screenshot({ path: `${SHOTS}skills-bar-recharging.png`, timeout: 60000 }); const climb = await page .waitForFunction( (frac) => { const g = window.game.scene.getScene('GameScene'); const row = g.combat.skillSeats()[0]; return row.cooldownFrac > frac && row.state !== 'ready' ? { ok: true, frac: row.cooldownFrac } : row.ready ? { ok: true, frac: 1 } : null; }, mid.seats[0].cooldownFrac, { timeout: (CD * 1000) | 0, polling: 250 }, ) .then((r) => r.jsonValue()); check(climb.frac > mid.seats[0].cooldownFrac, `the recharge sweep unwinds toward the ${CD}s full charge (${mid.seats[0].cooldownFrac.toFixed(2)} → ${climb.frac.toFixed(2)})`); // RENDERED PROOF of the countdown: hiding the seconds changes the seat's pixels, // so the number is genuinely painted on top of the icon during the recharge. const secsOff = await shootSeat(['secs']); check(Buffer.compare(await shootSeat([]), secsOff) !== 0, 'the recharge seconds reach the pixels too (not buried under the icon)'); // ========================================= the "cannot afford it" face const broke = await page .waitForFunction( ({ cost, id }) => { const g = window.game.scene.getScene('GameScene'); if (g.comms?.paused) g.comms._ack(); // Gate on the REAL state rather than clearing clocks: wait for the charge to // be away and its recharge to expire on its own, THEN take the energy away. // The refusal below is therefore the energy door, never a race with the clock // (a press made mid-charge answers 'cooldown', which is also correct). if (g.combat.dash) return null; if ((g.combat.skillCooldowns?.get(id) ?? 0) > 0) return null; g.combat.energy = Math.max(0, cost - 1); return g.combatBar.seatArt[0].state === 'noEnergy' ? { ok: true } : null; }, { cost: COST, id: RAM.id }, // 120s (not 40s): under a loaded runner the headless frames run slow and the // game clock (frame-based) outruns the wall clock — the ${CD}s recharge can // take far longer to expire than the numbers suggest (same "keep the range // open" rule as the waits above). { timeout: 120000, polling: 250 }, ) .then(() => snap()); check(broke.art[0].state === 'noEnergy' && broke.seats[0].affordable === false, `a seat the ${Math.round(broke.energy)}-point orb cannot pay for draws the dimmed "not enough energy" face`); const refusal = await page.evaluate((cost) => { const g = window.game.scene.getScene('GameScene'); // Same task as the press: a demo-wave restart cannot top the orb back up between. g.combat.energy = Math.max(0, cost - 1); return g._skillKeyAction({ key: '1', code: 'Digit1' }); }, COST); check(refusal?.ok === false && refusal?.reason === 'energy', `pressing it is refused for energy (reason ${refusal?.reason})`); await page.screenshot({ path: `${SHOTS}skills-bar-no-energy.png`, timeout: 60000 }); // ================================================ the assignment hand-off const handoff = await page.evaluate((id) => { const g = window.game.scene.getScene('GameScene'); const res = g.beginSkillAssign(id); return { ok: !!res.ok, awaiting: !!g.combatBar.awaitingSeat, label: g.skillState.defOf(id)?.label ?? null }; }, RAM.id); check(handoff.ok === true && handoff.awaiting === true, `${handoff.label} is in the hand — the eight seats wait for a placement`); const awaiting = await snap(); check(awaiting.awaiting === true && awaiting.art.every((x) => typeof x.state === 'string'), 'the bar sits in ASSIGN mode (every seat outlined by the pulse)'); await page.screenshot({ path: `${SHOTS}skills-bar-assign.png`, timeout: 60000 }); // The digit answers the placement (the same eight addresses as the seats). await clearScreen(); await page.keyboard.press('Digit4'); // the real key answers the placement too let placedOk = await page.evaluate((id) => window.game.scene.getScene('GameScene').skillState.seats[3] === id, RAM.id); if (!placedOk) { // A frame late (or an ack in the way): hand it up and press again. await page.evaluate((id) => window.game.scene.getScene('GameScene').beginSkillAssign(id), RAM.id); await clearScreen(); await page.keyboard.press('Digit4'); } await page.waitForFunction((id) => window.game.scene.getScene('GameScene').skillState.seats[3] === id, RAM.id, { timeout: 40000 }); const moved = await snap(); check(moved.ledgerSeats[3] === RAM.id && moved.recordSeats[3] === RAM.id, `the seat-4 press bound it — ledger + save registry both read ${RAM.id} on seat 4${placedOk ? '' : ' (second press)'}`); check(moved.ledgerSeats[0] === null && moved.art[0].state === 'empty' && moved.art[3].icon === true, 'seat 1 released it (one skill never holds two seats) and seat 4 draws the icon'); // A PASSIVE is refused a seat — always on, so it never sits in the bar. const passive = await page.evaluate((id) => { const g = window.game.scene.getScene('GameScene'); const res = g.beginSkillAssign(id); return { ok: !!res.ok, reason: res.reason ?? null, awaiting: !!g.combatBar.awaitingSeat }; }, PLATE.id); check(passive.ok === false && passive.awaiting === false, `${PLATE.label} (a PASSIVE) cannot take a seat — the hand-off refuses it (${passive.reason})`); // A RIGHT-press on the seat clears it (making room), and the clear is saved. await clearScreen(); // The canvas is SCALED (the game runs 1280×720 inside the 1440×860 viewport, with // the page's top bar offsetting it), so a seat's GAME point is mapped to the // page's PIXEL point before the mouse press. const pt = await page.evaluate(() => { const b = window.game.scene.getScene('GameScene').combatBar.seatBox; const r = document.querySelector('canvas').getBoundingClientRect(); const sx = r.width / window.game.scale.width; const sy = r.height / window.game.scale.height; return { x: r.left + (b.left + 3 * (b.size + b.gap) + b.size / 2) * sx, y: r.top + b.cy * sy }; }); await page.mouse.click(pt.x, pt.y, { button: 'right' }); await page.waitForFunction((id) => window.game.scene.getScene('GameScene').skillState.seats[3] === null, RAM.id, { timeout: 40000 }); const cleared = await snap(); check(cleared.ledgerSeats[3] === null && cleared.recordSeats[3] === null, 'a RIGHT-press on the seat cleared it — and the save registry carries the clear'); check(cleared.ranks[RAM.id] === RAM.maxPoints && cleared.ranks[PLATE.id] === PLATE.maxPoints, 'clearing a seat spends nothing: both ranks stand'); // And it casts from its new home: back to seat 1 through the same hand-off. await page.evaluate((id) => window.game.scene.getScene('GameScene').beginSkillAssign(id), RAM.id); await clearScreen(); await page.evaluate(() => window.game.scene.getScene('GameScene')._skillKeyAction({ key: '1', code: 'Digit1' })); await page.waitForFunction((id) => window.game.scene.getScene('GameScene').skillState.seats[0] === id, RAM.id, { timeout: 40000 }); const rebound = await snap(); check(rebound.art[0].icon === true && rebound.art[0].state !== 'empty', `${RAM.label} is back on seat 1 and drawing its icon again`); const again = await pressSeat(1); check(again.asked?.ok === true && again.after.energy <= again.before.energy - COST + 1, `the reassigned seat casts from key 1 too (${Math.round(again.before.energy)} → ${Math.round(again.after.energy)} energy)`); // A combat stop/start keeps the bindings (they are the run's, not the fight's). const kept = await page.evaluate((id) => { const g = window.game.scene.getScene('GameScene'); g._setCombatBar(false, true); const hidden = [...g.skillState.seats]; g._setCombatBar(true, false); return { hidden, now: [...g.skillState.seats], still: g.combat.skillSeats()[0].id === id, visible: !!g.combatBar.visible }; }, RAM.id); check(kept.hidden.every((s, i) => s === kept.now[i]) && kept.still, 'a combat stop/start keeps the bindings (the ledger owns them, not the fight)'); check(errors.length === 0, `no console errors${errors.length ? ': ' + errors.slice(0, 4).join(' | ') : ''}`); fs.mkdirSync(SHOTS, { recursive: true }); fs.writeFileSync( new URL('./shots/skills-bar.json', import.meta.url), JSON.stringify({ ram: RAM, plate: PLATE, seats: SEATS, ready, recharge: mid, broke, moved, cleared, rebound }, null, 2), ); await browser.close(); console.log(fails ? `\nskills-bar-browser: ${fails} FAILURE(S)` : '\nskills-bar-browser: all checks passed'); process.exit(fails ? 1 : 0);