fertig-classic-games/tools/smokeTents.js

178 lines
6.2 KiB
JavaScript

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