// Node smoke test for TentsGame.js: drives create → select → game → solve → win // against a minimal Phaser stub (node_modules/phaser, gitignored). import TentsGame from '../src/games/tents/TentsGame.js'; let failures = 0; const check = (name, cond, extra = '') => { if (cond) console.log(' ok ', name); else { failures++; console.log(' FAIL', name, extra); } }; // Generic "does anything" object for Phaser scene services we don't exercise. function stub() { const store = {}; const fn = () => s; const s = new Proxy(fn, { get(_, p) { if (p === 'then') return undefined; if (p === Symbol.toPrimitive) return () => 0; if (p === 'length') return 0; if (p in store) return store[p]; return s; }, set(_, p, v) { store[p] = v; return true; }, apply() { return s; }, construct() { return s; }, has() { return true; }, }); return s; } const sfxPlayed = []; // keys passed to scene.sound.play const sfxAdded = []; // keys passed to scene.sound.add (one-shot jingles) const s = new TentsGame(); // Wire up scene services. s.add = stub(); s.scale = { setZoom() {} }; s.input = { mouse: { disableContextMenu() {} } }; s.sound = { play(key) { sfxPlayed.push(key); }, add(key) { sfxAdded.push(key); return { once() {}, play() {} }; }, }; s.cache = { json: { get: () => null } }; s.scene = { start() {} }; s.events = { on() {}, once() {} }; s.cameras = { main: { setScroll() {} } }; s.tweens = { tweens: [], add(opts) { this.tweens.push(opts); return { kill() {} }; }, killTweensOf() {}, }; const timers = []; s.time = { now: 0, delayedCall(delay, cb) { timers.push({ at: s.time.now + delay, cb }); return { kill() {} }; }, addEvent() { return { off() {} }; }, }; const flushTimers = () => { const pending = timers.splice(0, timers.length).sort((a, b) => a.at - b.at); for (const t of pending) t.cb(); }; s.init({ game: { slug: 'tents', name: 'Tents & Trees' } }); s.create(); check('create() completes', true); check('select screen built', Array.isArray(s.selectLayer) === false && s.selectLayer !== undefined); // ── Start a game ── s.startGame('porch'); check('game state exists', s.g && s.g.puzzle && s.g.tents instanceof Set); check('intro jingle queued on start', sfxAdded.includes('sfx-tents-intro'), `added=${sfxAdded}`); check('board layout computed', typeof s.cell === 'number' && s.cell > 20 && s.cell < 140); check('zones built', s.cellZones.length === s.g.puzzle.size ** 2); check('labels built', s.colLabels.length === s.g.puzzle.size && s.rowLabels.length === s.g.puzzle.size); const p = s.g.puzzle; check('puzzle unique solution stored', Array.isArray(p.solution) && p.solution.length === p.trees.length); // ── Render a frame (day) ── s.time.now = 1000; s.update(1000, 16); check('day frame rendered without throwing', true); // ── Toggle every solution cell on (the correct solution) ── const solKeys = new Set(p.solution.map(([c, r]) => (r * 100 + c))); let placed = 0; for (let r = 0; r < p.size; r++) { for (let c = 0; c < p.size; c++) { if (solKeys.has(r * 100 + c)) { s.time.now += 30; s.onCellClick(c, r); placed++; } } } check('all solution tents placed', s.g.tents.size === p.trees.length && placed === p.trees.length, `tents=${s.g.tents.size} placed=${placed} trees=${p.trees.length}`); check('place cue played', sfxPlayed.includes('sfx-tents-place'), `played=${sfxPlayed}`); check('win triggered', s.won === true); check('win jingle queued', sfxAdded.includes('sfx-tents-win'), `added=${sfxAdded}`); check('sky tween scheduled', s.tweens.tweens.some((t) => t.targets === s.sky)); // ── Nightfall: animate sky to night and render ── for (let t = 1200; t <= 8000; t += 200) { s.time.now = t; s.update(t, 16); } s.sky.t = 1; s.seedFireflies(); s.update(9000, 16); check('night frame with fire/embers/fireflies rendered', true); // ── Win panel ── s.showWinPanel(); check('win panel shown once', s.winPanelShown === true); s.showWinPanel(); check('win panel idempotent', true); // ── Restart after a win (back to day, fresh puzzle) ── s.startGame('clearing'); check('restart resets sky', s.sky.t === 0); check('restart fresh game', s.g.tents.size === 0 && s.won === false); check('restart new size', s.g.puzzle.size === 8); // ── Answer reveal path ── s.revealAnswer(); flushTimers(); check('answer fills the solution', s.g.tents.size === s.g.puzzle.trees.length, `tents=${s.g.tents.size}`); check('answer path wins', s.won === true); // ── Reset path ── s.startGame('meadow'); const half = [...s.g.puzzle.solution.slice(0, 4)]; for (const [c, r] of half) s.onCellClick(c, r); check('partial placement ok', s.g.tents.size === 4, `tents=${s.g.tents.size}`); s.resetTents(); check('reset clears tents', s.g.tents.size === 0); check('remove cue played', sfxPlayed.includes('sfx-tents-remove'), `played=${sfxPlayed}`); // ── All difficulties generate + solve ── for (const key of ['porch', 'clearing', 'meadow', 'deepwoods']) { s.startGame(key); const pp = s.g.puzzle; const sk = new Set(pp.solution.map(([c, r]) => r * 100 + c)); for (let r = 0; r < pp.size; r++) for (let c = 0; c < pp.size; c++) if (sk.has(r * 100 + c)) s.onCellClick(c, r); check(`${key}: solve-by-answer wins`, s.won === true && s.g.tents.size === pp.trees.length); } // ── Hover + violation path ── s.startGame('porch'); s.hover = { c: 0, r: 0 }; s.time.now += 100; s.update(s.time.now, 16); check('hover frame renders', true); // Force an over-count: place tents on solution cells until a row is over. { const pp = s.g.puzzle; // find a row with count 1 let row = -1; for (let r = 0; r < pp.size; r++) if (pp.rowCounts[r] === 1) { row = r; break; } if (row >= 0) { const empties = []; for (let c = 0; c < pp.size; c++) { if (!pp.treeSet?.has?.(row * 100 + c) && !s.g.treeSet.has(row * 100 + c)) empties.push(c); } if (empties.length >= 2) { s.onCellClick(empties[0], row); s.onCellClick(empties[1], row); // now over check('violation flash armed', s.flash.until > 0, `flash.until=${s.flash.until}`); s.update(s.time.now + 50, 16); } } } console.log(failures === 0 ? '\n[smoke] ALL PASSED' : `\n[smoke] ${failures} FAILED`); process.exit(failures === 0 ? 0 : 1);