178 lines
6.2 KiB
JavaScript
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);
|