410 lines
22 KiB
JavaScript
410 lines
22 KiB
JavaScript
/**
|
||
* 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);
|