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

410 lines
22 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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