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