diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..0c896dc --- /dev/null +++ b/package-lock.json @@ -0,0 +1,62 @@ +{ + "name": "fertig-classic-games", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "fertig-classic-games", + "version": "0.1.0", + "devDependencies": { + "playwright": "^1.62.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", + "dev": true, + "dependencies": { + "playwright-core": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "dev": true, + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + } + } +} diff --git a/package.json b/package.json index 222c5f9..5560fde 100644 --- a/package.json +++ b/package.json @@ -9,5 +9,8 @@ }, "engines": { "node": ">=20" + }, + "devDependencies": { + "playwright": "^1.62.1" } } diff --git a/src/games/jigsaw/JigsawGame.js b/src/games/jigsaw/JigsawGame.js index fda1a40..62acd4f 100644 --- a/src/games/jigsaw/JigsawGame.js +++ b/src/games/jigsaw/JigsawGame.js @@ -514,7 +514,7 @@ export default class JigsawGame extends Phaser.Scene { buttonHit(cx, cy) { for (const b of this.buttons) { - if (!b.visible || !b.parent || !b.parent.visible) continue; + if (!b.visible || (b.parentContainer && !b.parentContainer.visible)) continue; const w = b.options.width, h = b.options.height; if (Math.abs(cx - b.x) <= w / 2 && Math.abs(cy - b.y) <= h / 2) return b; } @@ -1302,7 +1302,7 @@ export default class JigsawGame extends Phaser.Scene { const counter = { v: 0 }; this.winTweens.push(this.tweens.add({ targets: counter, v: this.total, delay: 760, duration: 950, ease: 'Quad.easeOut', - onUpdate: (tw) => { if (this.piecesBig && this.piecesBig.parent) this.piecesBig.setText(String(Math.round(tw.getValue(0)))); }, + onUpdate: (tw) => { if (this.piecesBig) this.piecesBig.setText(String(Math.round(tw.getValue(0)))); }, })); // Camera sweep — the finale fires when it lands. diff --git a/tools/smokeJigsaw.mjs b/tools/smokeJigsaw.mjs new file mode 100644 index 0000000..66fd2ec --- /dev/null +++ b/tools/smokeJigsaw.mjs @@ -0,0 +1,162 @@ +// Browser smoke test for the Jigsaw game (Playwright + headless Chromium). +// +// Usage: +// npx playwright install chromium # one-time +// python3 -m http.server 8000 # serve the repo root (or pass a base URL) +// node tools/smokeJigsaw.mjs # defaults to http://127.0.0.1:8000 +// node tools/smokeJigsaw.mjs http://localhost:9000 +// +// What it checks: +// - game loads, menu → Start Puzzle → playing, pieces spawn on the table +// - forced win (placed = total; onWin()) runs the full showcase: +// * camera sweep lands on WIN_ZOOM framing the board +// * stats counter animates up to the total +// * celebration emitters fire (burst + curtain) and confetti is alive +// * gold frame + shine sweep exist at the right depths +// - "Play Again" restarts a fresh board and tears the win layer down +// - "Menu" returns to the menu and destroys the win layer +// - menu → Start Puzzle works again +// - zero JS console/page errors across the whole flow +// +// NOTE: headless Chromium renders this 1920×1080 WebGL scene at only a few +// FPS (SwiftShader), so the scene clock advances slowly in real time. All +// waits here are condition-based polling (generous timeouts), never fixed +// sleeps. A full run takes roughly 1–3 minutes of wall time. + +import { spawn } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import os from 'node:os'; +import path from 'node:path'; +import { chromium } from 'playwright'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const BASE = process.argv[2] || 'http://127.0.0.1:8000/__jig_test.html'; + +let server = null; +if (!process.argv[2]) { + try { + await fetch(new URL('..', BASE), { method: 'HEAD', signal: AbortSignal.timeout(1500) }); + } catch { + server = spawn('python3', ['-m', 'http.server', '8000'], { cwd: ROOT, stdio: 'ignore', detached: true }); + await new Promise((r) => setTimeout(r, 1200)); + console.log('started local static server on :8000'); + } +} + +const errors = []; +let failed = false; +const ok = (m) => console.log(' ok ' + m); +const bad = (m) => { failed = true; console.log(' FAIL ' + m); }; + +const browser = await chromium.launch({ args: ['--no-sandbox'] }); +const page = await browser.newPage({ viewport: { width: 1920, height: 1080 } }); +page.on('pageerror', (e) => errors.push('pageerror: ' + e.message)); +page.on('console', (m) => { if (m.type() === 'error') errors.push('console: ' + m.text()); }); + +const evalS = (fn, ...a) => page.evaluate(fn, ...a); +const waitFor = async (name, cond, timeoutMs) => { + const t0 = Date.now(); + while (Date.now() - t0 < timeoutMs) { + if (await evalS(cond)) return true; + await new Promise((r) => setTimeout(r, 500)); + } + bad(`timeout waiting for: ${name}`); + return false; +}; +const snap = async (name) => { + const dest = path.join(os.tmpdir(), `jigsaw_smoke_${name}.png`); + await page.screenshot({ path: dest, fullPage: false }); + ok(`screenshot ${dest}`); +}; +const click = (x, y) => evalS((pt) => { window.__mouseDown(pt[0], pt[1]); window.__mouseUp(pt[0], pt[1]); }, [x, y]); +const settleTimeout = Number(process.env.JIG_SMOKE_SETTLE_MS || 180000); + +try { + console.log('— load —'); + await page.goto(BASE, { waitUntil: 'load' }); + await page.waitForFunction(() => document.getElementById('log').textContent.includes('harness ready'), null, { timeout: 30000 }); + ok('harness ready'); + + console.log('— start puzzle —'); + await evalS(() => window.__startPuzzle()); + if (!(await waitFor('state=playing', () => window.__scene().state === 'playing', 60000))) throw new Error('never reached playing'); + ok('state = playing'); + const pieces = await evalS(() => window.__pieces()); + ok(`pieces on table: ${pieces.length} (placed=${pieces.filter((p) => p.placed).length})`); + if (pieces.length === 0) bad('no pieces spawned'); + await snap('1_playing'); + + console.log('— force win —'); + await evalS(() => { const s = window.__scene(); s.placed = s.total; s.onWin(); }); + ok('onWin() called (state=' + (await evalS(() => window.__scene().state)) + ')'); + + console.log('— wait for panel settle (camera at WIN_ZOOM, counter done) —'); + if (await waitFor('camera settled + counter=total', () => { + const s = window.__scene(); + return s.state === 'won' && Math.abs(s.cameras.main.zoom - 1.24) < 0.01 + && s.piecesBig && s.piecesBig.text === String(s.total); + }, settleTimeout)) { + const wi = await evalS(() => { + const s = window.__scene(); + return { zoom: +s.cameras.main.zoom.toFixed(3), sx: Math.round(s.cameras.main.scrollX), sy: Math.round(s.cameras.main.scrollY), counter: s.piecesBig.text }; + }); + ok('settled: ' + JSON.stringify(wi)); + await snap('2_win_panel'); + } + + console.log('— wait for celebration (emitters created + particles alive) —'); + if (await waitFor('emitters alive', () => { + const s = window.__scene(); + return (s.winEmitters || []).length === 2 && s.winEmitters.every((e) => e.getAliveParticleCount() > 0); + }, settleTimeout)) { + const alive = await evalS(() => window.__scene().winEmitters.map((e) => e.getAliveParticleCount())); + ok('particles alive: ' + JSON.stringify(alive)); + await snap('3_confetti'); + const fx = await evalS(() => { + const s = window.__scene(); + return { frame: !!s.winFrame, shine: !!s.winShine, frameDepth: s.winFrame && s.winFrame.depth, shineDepth: s.winShine && s.winShine.depth }; + }); + ok('frame/shine: ' + JSON.stringify(fx)); + if (!fx.frame || !fx.shine) bad('winFrame or winShine missing'); + } + + console.log('— Play Again button —'); + await click(400, 640); // inside "Play Again" (visual 90..476 × 591..653, hit rect 283..669 × 622..684) + if (await waitFor('state=playing after Play Again', () => window.__scene().state === 'playing', 120000)) { + const placed = await evalS(() => window.__scene().pieces.filter((p) => p.placed).length); + const winGone = await evalS(() => !window.__scene().winLayer); + ok(`restarted: placed=${placed}, winLayer destroyed=${winGone}`); + if (placed !== 0) bad('pieces not reset after Play Again'); + if (!winGone) bad('winLayer not destroyed after Play Again'); + await snap('4_restarted'); + } + + console.log('— force win again, then Menu button —'); + await evalS(() => { const s = window.__scene(); s.placed = s.total; s.onWin(); }); + if (await waitFor('second showcase emitters', () => (window.__scene().winEmitters || []).length === 2, settleTimeout)) { + ok('second win showcase running'); + await click(400, 720); // inside "Menu" + if (await waitFor('state=menu after Menu', () => window.__scene().state === 'menu', 120000)) { + const menuVisible = await evalS(() => window.__scene().menu && window.__scene().menu.visible); + const winGone = await evalS(() => !window.__scene().winLayer); + ok(`back to menu: menu.visible=${menuVisible}, winLayer destroyed=${winGone}`); + if (!menuVisible) bad('menu not visible'); + if (!winGone) bad('winLayer not destroyed'); + await snap('5_menu'); + } + } + + console.log('— Start Puzzle from menu works —'); + await evalS(() => { const s = window.__scene(); s.selectedDiff = 'easy'; s.startPuzzle(); }); + if (await waitFor('state=playing from menu', () => window.__scene().state === 'playing', 60000)) ok('menu → playing OK'); +} finally { + await browser.close(); + if (server) { try { process.kill(-server.pid); } catch { /* already gone */ } } +} + +console.log('\n— JS errors (' + errors.length + ') —'); +errors.forEach((e) => console.log(' ' + e)); +if (errors.length) failed = true; + +console.log(failed ? '\nSMOKE TEST: FAILED' : '\nSMOKE TEST: ALL PASS'); +process.exit(failed ? 1 : 0);