242 lines
12 KiB
JavaScript
242 lines
12 KiB
JavaScript
/**
|
|
* dev/classes-browser.test.mjs — the CLASS LAYER's trigger in a real browser,
|
|
* with no dev param in the URL.
|
|
*
|
|
* node dev/server.mjs 8123 & # the static dev server
|
|
* node dev/classes-browser.test.mjs
|
|
*
|
|
* What it pins (the freeze contract's other half — js/ui/ClassWindow.js's
|
|
* screenshots live in dev/class-shot.mjs):
|
|
* · a plain boot asks for NOTHING (level 0, no class);
|
|
* · crossing data/shipScreen.json → level.classAt in a quiet sky opens the
|
|
* console by itself, once, through GameScene._classBeat (the same path a
|
|
* real run takes — no ?level5), and the world parks through _frozen;
|
|
* · dismissing it DEFERS: no class is filed, the world runs on, and the
|
|
* console never re-fires on its own;
|
|
* · the ship console's CHOOSE YOUR CLASS door is the way back in (its snapshot
|
|
* still reports mode 'choose' until something is filed);
|
|
* · locking a class in flips that snapshot to the class plate.
|
|
*/
|
|
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';
|
|
|
|
let fails = 0;
|
|
const check = (cond, msg) => {
|
|
console.log(` ${cond ? '✔' : '✘ FAIL'} ${msg}`);
|
|
if (!cond) fails++;
|
|
};
|
|
|
|
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));
|
|
|
|
// ?noclass closes BOTH dev doors (MenuScene's walk-in and GameScene's trigger),
|
|
// so the run is entered — and the level is earned — exactly like a player's.
|
|
await page.goto(`${BASE}/index.html?noclass`, { waitUntil: 'load' });
|
|
// The menu must be BUILT before its NEW GAME action runs (startNewGame reads the
|
|
// seed line the menu's create() sets up; a fixed sleep is what made this test
|
|
// flake when the suite ran it under load — wait for the state, not the clock).
|
|
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.classState);
|
|
}, null, { timeout: 90000 });
|
|
|
|
// The intro comm owns the screen until ACKNOWLEDGED; press it (CommsHub._ack is
|
|
// the button's own callback).
|
|
await page.waitForFunction(
|
|
() => {
|
|
const g = window.game.scene.getScene('GameScene');
|
|
if (g.comms?.paused) g.comms._ack();
|
|
return !g.comms?.paused && !g.comms?.active;
|
|
},
|
|
null,
|
|
{ timeout: 120000, polling: 500 }
|
|
);
|
|
|
|
const cold = await page.evaluate(() => {
|
|
const g = window.game.scene.getScene('GameScene');
|
|
return { cw: !!g.classWindow, prompted: g._classPrompted, level: g.combat?.xp?.level ?? 0, hasClass: g.classState.hasClass };
|
|
});
|
|
check(cold.cw === false && cold.prompted === false && cold.hasClass === false, `a level-${cold.level} boot asks for nothing`);
|
|
|
|
// Earn the level. The grant also fires the level-up marquee — the console waits
|
|
// for that beat to clear (it never stacks two full-screen moments).
|
|
await page.evaluate(() => {
|
|
const g = window.game.scene.getScene('GameScene');
|
|
for (let i = 0; i < 8; i++) g.combat.xp.grant(500, 'test');
|
|
});
|
|
await page.waitForFunction(
|
|
() => {
|
|
const g = window.game.scene.getScene('GameScene');
|
|
if (g.comms?.paused) g.comms._ack(); // a brief that lands mid-flight, the player presses through
|
|
return g.classWindow?.isOpen === true;
|
|
},
|
|
null,
|
|
{ timeout: 150000, polling: 500 }
|
|
);
|
|
check(true, 'crossing level 5 in a quiet sky opened the class console by itself');
|
|
|
|
const armed = await page.evaluate(() => {
|
|
const g = window.game.scene.getScene('GameScene');
|
|
const v = g.ship.body.velocity;
|
|
return {
|
|
level: g.combat.xp.level,
|
|
prompted: g._classPrompted,
|
|
marqueeCleared: g.levelMarquee?.active !== true,
|
|
parked: Math.abs(v.x) < 0.01 && Math.abs(v.y) < 0.01,
|
|
standby: g.actionBar?.enabled === false,
|
|
combatFrozen: (g.combat?.enemies ?? []).every((e) => !e.body || (e.body.velocity.x === 0 && e.body.velocity.y === 0)),
|
|
};
|
|
});
|
|
check(armed.level >= 5 && armed.prompted === true, `the prompt is spent at level ${armed.level}`);
|
|
check(armed.marqueeCleared, 'the level-up marquee got its beat first');
|
|
check(armed.parked && armed.standby && armed.combatFrozen, 'the world is parked while the console is up (_frozen/_parkForFreeze)');
|
|
|
|
// DEFER: ESC closes without filing anything, and nothing re-fires.
|
|
await page.keyboard.press('Escape');
|
|
await page.waitForFunction(() => window.game.scene.getScene('GameScene').classWindow.openState === 'closed', null, { timeout: 40000 });
|
|
const deferred = await page.evaluate(() => {
|
|
const g = window.game.scene.getScene('GameScene');
|
|
return { hasClass: g.classState.hasClass, plate: g.classPlateSnapshot(), standby: g.actionBar?.enabled === false };
|
|
});
|
|
check(deferred.hasClass === false && deferred.plate === null, 'closing without picking files no class');
|
|
check(deferred.standby === false, 'the world runs again on the deferral');
|
|
await page.waitForTimeout(6000);
|
|
check((await page.evaluate(() => window.game.scene.getScene('GameScene').classWindow.isOpen)) === false, 'a deferred pick is not nagged');
|
|
|
|
// The ship console's door is the second way in (no dead click).
|
|
const viaDoor = await page.evaluate(() => {
|
|
const g = window.game.scene.getScene('GameScene');
|
|
const snap = g.shipScreenSnapshot();
|
|
const mode = snap.playerClass ? 'class' : snap.level?.level >= 5 ? 'choose' : 'timeline';
|
|
g.deckAction?.('ship') ?? g.shipWindow?.open?.();
|
|
g.openClassWindow({ source: 'shipConsole' });
|
|
return { mode };
|
|
});
|
|
await page.waitForFunction(() => window.game.scene.getScene('GameScene').classWindow.isOpen, null, { timeout: 60000 });
|
|
check(viaDoor.mode === 'choose', 'the ship console still shows CHOOSE YOUR CLASS while nothing is filed');
|
|
check(true, 'that door re-opens the class console');
|
|
|
|
// Lock one in and the console's snapshot becomes a class plate.
|
|
const locked = await page.evaluate(() => {
|
|
const g = window.game.scene.getScene('GameScene');
|
|
const r = g.applyClassPick('vanguard');
|
|
const snap = g.shipScreenSnapshot();
|
|
return { r, plate: snap.playerClass };
|
|
});
|
|
check(locked.r.ok === true && locked.plate?.name === 'VANGUARD' && locked.plate?.insignia === 0, `the class plate replaces the door (${JSON.stringify(locked.plate)})`);
|
|
|
|
// THE HAND-OFF: a CONFIRMED lock-in walks the player to the SKILL TREE the
|
|
// moment the class console finishes closing (GameScene's onClose seam).
|
|
await page.evaluate(() => window.game.scene.getScene('GameScene').classWindow.close({ picked: 'vanguard' }));
|
|
const handed = await page.waitForFunction(
|
|
() => {
|
|
const g = window.game.scene.getScene('GameScene');
|
|
return g.classWindow.openState === 'closed' && g.skillWindow?.isOpen === true;
|
|
},
|
|
null,
|
|
{ timeout: 40000 }
|
|
).then(() =>
|
|
page.evaluate(() => {
|
|
const g = window.game.scene.getScene('GameScene');
|
|
const res = { classClosed: g.classWindow.openState === 'closed', skillOpen: g.skillWindow.isOpen, points: g.classState.skillPoints };
|
|
g.skillWindow.close?.(); // the test does not keep the console open
|
|
return res;
|
|
})
|
|
);
|
|
check(handed.classClosed && handed.skillOpen, 'a confirmed pick hands the player to the skill tree console');
|
|
check(handed.points > 0, `the banked points (${handed.points}) are spendable on arrival`)
|
|
|
|
// ── THE VOLLEYS through the REAL firing code (the classState seam in CombatSystem).
|
|
// Same capture-stub trick, run against the live prototype method: Phaser exists
|
|
// in the page, so this is the half dev/class-combat.test.mjs cannot do in node.
|
|
const volleys = await page.evaluate(() => {
|
|
const g = window.game.scene.getScene('GameScene');
|
|
const fire = Object.getPrototypeOf(g.combat).firePlayerAt;
|
|
const run = (perkOwner) => {
|
|
const shots = [];
|
|
const stub = {
|
|
classState: perkOwner,
|
|
nextFireAt: 0,
|
|
scene: g,
|
|
fx: { muzzle() {} },
|
|
spawnBullet(x, y, angle, w, owner) {
|
|
shots.push({ x, y, angle, damage: Number(w.damage), speed: Number(w.bulletSpeed) || 600, owner });
|
|
return {};
|
|
},
|
|
};
|
|
const player = { x: 0, y: 0, radius: 12 };
|
|
// a hostile crossing the nose at 240 px/s — the case a lead solve exists for
|
|
const target = { x: 300, y: 0, body: { velocity: { x: 0, y: 240 } } };
|
|
fire.call(stub, player, target, 1000);
|
|
return { shots, cooldown: stub.nextFireAt - 1000 };
|
|
};
|
|
const mk = (id) => {
|
|
const st = new (Object.getPrototypeOf(g.classState).constructor)();
|
|
if (id) st.choose(id);
|
|
return st;
|
|
};
|
|
return { none: run(null), plainLedger: run(mk(null)), vanguard: run(mk('vanguard')), wraith: run(mk('wraith')) };
|
|
});
|
|
const baseDamage = volleys.none.shots[0].damage; // read off the unclassed bolt — no second source
|
|
const sep = Math.hypot(volleys.vanguard.shots[0].x - volleys.vanguard.shots[1].x, volleys.vanguard.shots[0].y - volleys.vanguard.shots[1].y);
|
|
check(volleys.none.shots.length === 1 && volleys.plainLedger.shots.length === 1, `no class = one bolt (${volleys.none.shots.length}/${volleys.plainLedger.shots.length})`);
|
|
check(volleys.vanguard.shots.length === 2, `Vanguard fires a two-barrel volley (${volleys.vanguard.shots.length})`);
|
|
check(volleys.vanguard.shots.every((s) => s.damage === baseDamage), `both bolts carry the full ${baseDamage} damage`);
|
|
check(sep > 8 && sep < 40, `the barrels are offset (${sep.toFixed(1)}px apart)`);
|
|
check(volleys.vanguard.cooldown === volleys.none.cooldown, `the cooldown is untouched (${volleys.none.cooldown}ms)`);
|
|
const leadOff = Math.abs(((volleys.wraith.shots[0].angle - Math.atan2(0, 300) + Math.PI * 3) % (Math.PI * 2)) - Math.PI);
|
|
check(volleys.wraith.shots.length === 1 && volleys.wraith.shots[0].angle > 0.12, `Wraith's bolt leads the crossing hostile (${volleys.wraith.shots[0].angle.toFixed(3)} rad, ${leadOff.toFixed(3)} off raw aim)`);
|
|
check(volleys.vanguard.shots.every((s) => s.owner === 'player'), 'the bolts stay the player\u2019s (friendly fire rules unchanged)');
|
|
|
|
// THE RANGE keeps its base feel: a run with nothing filed (?combat&noclass —
|
|
// hostiles up, class layer off) still fires the single cannon it always did.
|
|
await page.goto(`${BASE}/index.html?combat&noclass`, { waitUntil: 'load' });
|
|
// The menu needs its seed line before a start is meaningful (a fixed sleep races
|
|
// the boot under a loaded runner).
|
|
await page.waitForFunction(() => window.game?.scene?.getScene?.('MenuScene')?.seedValue, 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');
|
|
if (g?.comms?.paused) g.comms._ack();
|
|
return !!(g && g.combat && (g.combat.enemies?.length ?? 0) > 0);
|
|
},
|
|
null,
|
|
{ timeout: 150000, polling: 500 }
|
|
);
|
|
const range = await page.evaluate(() => {
|
|
const g = window.game.scene.getScene('GameScene');
|
|
return { hasClass: g.classState?.hasClass === true, perks: g.classState?.perks?.size ?? -1, hostiles: g.combat.enemies.length };
|
|
});
|
|
check(range.hasClass === false && range.perks === 0 && range.hostiles > 0, `the range runs unclassed (${range.hostiles} hostiles, 0 perks) — so its volley is the base shot asserted above`);
|
|
|
|
await browser.close();
|
|
const real = errors.filter((e) => !/AudioContext|autoplay|WebGL|GPU lockout|swiftshader|SoundManager|Unlock/i.test(e));
|
|
check(real.length === 0, `no console errors${real.length ? ': ' + real.slice(0, 3).join(' | ') : ''}`);
|
|
console.log(fails ? `\n${fails} FAILURE(S)` : '\nall checks passed');
|
|
process.exit(fails ? 1 : 0);
|