diff --git a/assets/images/game-icons.png b/assets/images/game-icons.png index e2c6f9c..f1d5f99 100644 Binary files a/assets/images/game-icons.png and b/assets/images/game-icons.png differ diff --git a/src/data/gamesRegistry.js b/src/data/gamesRegistry.js index 25a206c..f580b09 100644 --- a/src/data/gamesRegistry.js +++ b/src/data/gamesRegistry.js @@ -122,3 +122,4 @@ registerGame({ slug: 'excitebike', name: 'Excitebike', category: 'arcade-console registerGame({ slug: 'mastervega', name: 'Master of Vega', category: 'arcade-console-pc', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 92 }); registerGame({ slug: 'wolfenstein', name: 'Wolfenstein 3D', category: 'arcade-console-pc', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 93 }); registerGame({ slug: 'pipepuzzle', name: 'Pipe Puzzle', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, hasTutorial: true, iconFrame: 94 }); +registerGame({ slug: 'tents', name: 'Tents & Trees', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 95 }); diff --git a/src/games/tents/TentsGame.js b/src/games/tents/TentsGame.js new file mode 100644 index 0000000..e0ed8b0 --- /dev/null +++ b/src/games/tents/TentsGame.js @@ -0,0 +1,937 @@ +import * as Phaser from 'phaser'; +import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js'; +import { Button } from '../../ui/Button.js'; +import { MusicPlayer } from '../../ui/MusicPlayer.js'; +import { getGameSoundtrack } from '../../services/soundtrack.js'; +import { SFX, playSoundEx } from '../../ui/Sounds.js'; +import { api } from '../../services/api.js'; +import { + DIFFICULTIES, DIFFICULTY_ORDER, + keyOf, newGame, toggleTent, clearTents, tentCount, + isSolved, solutionTents, generatePuzzle, +} from './TentsLogic.js'; + +const FONT_D = 'Righteous'; +const FONT_B = '"Julius Sans One"'; + +// ── Color helpers (day → night lerps drive the whole scene) ───────────────── + +const lerp = (a, b, t) => a + (b - a) * t; +const clamp = (v, a, b) => Math.max(a, Math.min(b, v)); + +function mix(c1, c2, t) { + const r = Math.round(lerp(c1 >> 16, c2 >> 16, t)); + const g = Math.round(lerp((c1 >> 8) & 0xff, (c2 >> 8) & 0xff, t)); + const b = Math.round(lerp(c1 & 0xff, c2 & 0xff, t)); + return (r << 16) | (g << 8) | b; +} + +const PALETTE = { + skyTop: [0x5fa8dc, 0x241a52], + skyBottom: [0xbfe3f4, 0x74479c], + sun: [0xfff0b0, 0xff8a4a], + hillFar: [0x3e7d55, 0x14102c], + hillNear: [0x2f6544, 0x0e0c22], + meadow: [0x69b54f, 0x223356], + tileA: [0xd8e0c0, 0x33406b], + tileB: [0xc3cdb0, 0x2a3558], + tileLine: [0x9aa884, 0x1d2743], + trunk: [0x7a4a28, 0x3a2448], + treeA: [0x2c6b3a, 0x1a2f5e], + treeB: [0x3d8a4c, 0x22386b], + treeC: [0x54a862, 0x2c4a80], + tentL: [0xf2e2b8, 0x8a92d8], + tentR: [0xd9c393, 0x6a72b4], + tentDoor: [0x8a5a34, 0x3a3f78], +}; + +const col = (name, t) => mix(PALETTE[name][0], PALETTE[name][1], t); + +function easeOutBack(p) { + const c1 = 1.70158, c3 = c1 + 1; + return 1 + c3 * Math.pow(p - 1, 3) + c1 * Math.pow(p - 1, 2); +} + +function tri(g, x1, y1, x2, y2, x3, y3) { + g.beginPath(); + g.moveTo(x1, y1); + g.lineTo(x2, y2); + g.lineTo(x3, y3); + g.closePath(); + g.fillPath(); +} + +// ── Scene ───────────────────────────────────────────────────────────────────── + +export default class TentsGame extends Phaser.Scene { + constructor() { + super('TentsGame'); + } + + init(data) { + this.gameDef = data?.game ?? data?.gameDef ?? { slug: 'tents' }; + this.view = 'select'; + this.dirty = true; + this.sky = { t: 0 }; // 0 = midday, 1 = starry night + this.won = false; + this.winPanelShown = false; + this.difficulty = 'porch'; + this.hover = null; // {c, r} + this.embers = []; + this.lastEmberAt = 0; + this.pop = new Map(); // tent key → time placed (pop animation) + this.wiggle = new Map(); // tree key → time wiggled + this.flash = { until: 0, keys: new Set() }; + this.fireflies = []; + } + + create() { + try { + const { tracks, volume } = getGameSoundtrack(this); + if (tracks.length) new MusicPlayer(this, tracks, volume); + } catch (_) { /* no soundtrack available */ } + + this.input.mouse?.disableContextMenu?.(); + + this.horizonY = 560; + this.seedScenery(); + + // Draw layers (depth: background < board < glow fx < HUD). + this.bgGfx = this.add.graphics().setDepth(0); + this.boardGfx = this.add.graphics().setDepth(10); + this.fxGfx = this.add.graphics().setDepth(20).setBlendMode(Phaser.BlendModes.ADD); + + this.colLabels = []; + this.rowLabels = []; + this.cellZones = []; + + this.buildHud(); + this.hudLayer.setVisible(false); + this.buildSelectScreen(); + + this.events.on('shutdown', () => { + this.embers.length = 0; + this.pop.clear(); + this.wiggle.clear(); + this.fireflies.length = 0; + }); + } + + // ── Scenery (seeded once per scene instance) ────────────────────────────── + + seedScenery() { + const W = GAME_WIDTH; + + this.stars = []; + for (let i = 0; i < 110; i++) { + this.stars.push({ + x: Math.random() * W, + y: Math.random() * (this.horizonY - 40), + s: Math.random() < 0.12 ? 2 + Math.random() * 1.5 : 0.8 + Math.random() * 1.1, + sp: 0.6 + Math.random() * 1.6, + ph: Math.random() * Math.PI * 2, + }); + } + + this.clouds = []; + for (let i = 0; i < 5; i++) { + this.clouds.push({ + x: Math.random() * W, + y: 60 + Math.random() * 220, + w: 140 + Math.random() * 160, + v: 4 + Math.random() * 7, + puffs: Array.from({ length: 4 }, () => ({ + dx: (Math.random() - 0.5) * 0.9, + dy: (Math.random() - 0.5) * 0.5, + r: 0.28 + Math.random() * 0.22, + })), + }); + } + + this.hillFar = []; + for (let x = -60; x < W + 60; x += 40 + Math.random() * 30) { + this.hillFar.push({ x, w: 44 + Math.random() * 34, h: 24 + Math.random() * 34 }); + } + this.hillNear = []; + for (let x = -80; x < W + 80; x += 80 + Math.random() * 60) { + this.hillNear.push({ x, w: 80 + Math.random() * 50, h: 46 + Math.random() * 46 }); + } + + const FLOWER_COLORS = [0xffd166, 0xef6f6c, 0xffffff, 0xb795ff]; + this.flowers = []; + for (let i = 0; i < 70; i++) { + this.flowers.push({ + x: Math.random() * W, + y: this.horizonY + 24 + Math.random() * (GAME_HEIGHT - this.horizonY - 40), + r: 1.6 + Math.random() * 2.2, + c: FLOWER_COLORS[(Math.random() * FLOWER_COLORS.length) | 0], + }); + } + } + + // ── Layout ───────────────────────────────────────────────────────────────── + + computeLayout() { + const s = this.g.puzzle.size; + const left = 330, right = GAME_WIDTH - 130; + const top = this.horizonY - 84, bottom = GAME_HEIGHT - 58; + const cell = Math.min((right - left) / s, (bottom - top) / s, 128); + const bw = cell * s, bh = cell * s; + this.cell = cell; + this.bx = left + (right - left - bw) / 2; + this.by = top + (bottom - top - bh) / 2; + } + + cellCenter(c, r) { + return [this.bx + c * this.cell + this.cell / 2, this.by + r * this.cell + this.cell / 2]; + } + + // ── HUD (play screen) ────────────────────────────────────────────────────── + + buildHud() { + this.hudLayer = this.add.container(0, 0).setDepth(30); + + const title = this.makeText('TENTS & TREES', 42, 84, FONT_D, 46, COLORS.goldHex); + const tag = this.makeText('pitch every tent, then watch the stars come out', 42, 126, FONT_B, 20, '#cfe3f2'); + this.diffText = this.add.text(42, 168, '', { + fontFamily: FONT_B, fontSize: '24px', color: '#ffd76a', + }).setDepth(30); + + this.statTents = this.makeText('tents 0 / 0', 42, 430, FONT_B, 22, '#fff3d9'); + this.statTimer = this.makeText('time 0:00', 42, 464, FONT_B, 22, '#fff3d9'); + + this.hudLayer.add([title, tag, this.diffText, this.statTents, this.statTimer]); + + const mk = (label, y, cb, variant) => + new Button(this, 150, y, label, cb, { width: 230, height: 58, variant }).setDepth(31); + this.btnNew = mk('New Game', 540, () => this.startGame(this.difficulty), 'solid'); + this.btnAnswer = mk('Answer', 620, () => this.revealAnswer(), 'ghost'); + this.btnReset = mk('Reset Tents', 700, () => this.resetTents(), 'ghost'); + this.btnMenu = mk('Menu', 780, () => this.scene.start('GameMenu'), 'ghost'); + + this.hudLayer.add([this.btnNew, this.btnAnswer, this.btnReset, this.btnMenu]); + } + + makeText(str, x, y, font, size, color) { + return this.add.text(x, y, str, { + fontFamily: font, fontSize: `${size}px`, color, + stroke: '#3a2a14', strokeThickness: 2, + }).setOrigin(0, 0).setDepth(30); + } + + // ── Select screen ────────────────────────────────────────────────────────── + + buildSelectScreen() { + this.selectLayer = this.add.container(0, 0).setDepth(30); + + const title = this.add.text(GAME_WIDTH / 2, 150, 'TENTS & TREES', { + fontFamily: FONT_D, fontSize: '84px', color: '#ffd76a', + stroke: '#4a3416', strokeThickness: 10, + }).setOrigin(0.5).setDepth(31); + + const sub = this.add.text(GAME_WIDTH / 2, 224, 'A cozy logic puzzle — one tent for every tree, none may touch.', { + fontFamily: FONT_B, fontSize: '26px', color: '#fdf6e3', + }).setOrigin(0.5).setDepth(31); + + const rules = this.add.text(GAME_WIDTH / 2, 268, 'The numbers along the top and side are exact tent counts for that column and row.', { + fontFamily: FONT_B, fontSize: '22px', color: '#cfe3f2', + }).setOrigin(0.5).setDepth(31); + + this.selectLayer.add([title, sub, rules]); + + const cardW = 330, cardH = 250, gap = 36; + const totalW = cardW * 4 + gap * 3; + let cx = (GAME_WIDTH - totalW) / 2; + for (const key of DIFFICULTY_ORDER) { + const def = DIFFICULTIES[key]; + const card = this.add.rectangle(cx + cardW / 2, 460, cardW, cardH, 0x0d0b1a, 0.55) + .setStrokeStyle(2, 0xd9a441, 0.7).setDepth(31); + const name = this.add.text(cx + cardW / 2, 402, def.label, { + fontFamily: FONT_D, fontSize: '34px', color: '#ffd76a', + }).setOrigin(0.5).setDepth(32); + const meta = this.add.text(cx + cardW / 2, 452, `${def.size}×${def.size} · ${def.trees} trees`, { + fontFamily: FONT_B, fontSize: '24px', color: '#f2ead8', + }).setOrigin(0.5).setDepth(32); + const flavor = this.add.text(cx + cardW / 2, 508, def.blurb, { + fontFamily: FONT_B, fontSize: '20px', color: '#b9cfe0', + wordWrap: { width: cardW - 60 }, align: 'center', + }).setOrigin(0.5).setDepth(32); + const go = this.add.text(cx + cardW / 2, 568, '▸ play', { + fontFamily: FONT_D, fontSize: '26px', color: '#9fd8a4', + }).setOrigin(0.5).setDepth(32); + + const hit = this.add.zone(cx + cardW / 2, 460, cardW, cardH).setInteractive(); + hit.on('pointerover', () => { + card.setFillStyle(0x1a1530, 0.75); + this.tweens.add({ targets: [name, meta, flavor, go], scale: 1.04, duration: 130, ease: 'Quad.out' }); + playSoundEx(this, SFX.UI_PICK, { volume: 0.5 }); + }); + hit.on('pointerout', () => { + card.setFillStyle(0x0d0b1a, 0.55); + this.tweens.add({ targets: [name, meta, flavor, go], scale: 1, duration: 130 }); + }); + hit.on('pointerup', () => { + playSoundEx(this, SFX.UI_ACTIVATE, { volume: 0.7 }); + this.startGame(key); + }); + + this.selectLayer.add([card, name, meta, flavor, go, hit]); + cx += cardW + gap; + } + + const back = new Button(this, GAME_WIDTH / 2, 760, 'Back', () => this.scene.start('GameMenu'), + { width: 220, height: 56, variant: 'ghost' }).setDepth(31); + this.selectLayer.add(back); + + // A little campfire decor on the menu meadow. + this.menuCamp = { x: GAME_WIDTH / 2, y: this.horizonY + 120 }; + } + + // ── Game flow ────────────────────────────────────────────────────────────── + + startGame(difficultyKey) { + let puzzle = null; + try { + puzzle = generatePuzzle(difficultyKey); + } catch (err) { + console.error('Tents generation failed', err); + } + if (!puzzle) { + this.toast('Hmm, the forest is in a mood. Try again.', 940); + return; + } + + this.difficulty = difficultyKey; + this.g = newGame(puzzle); + this.g.startedAt = null; + this.won = false; + this.winPanelShown = false; + this.embers.length = 0; + this.pop.clear(); + this.wiggle.clear(); + this.fireflies.length = 0; + this.flash.until = 0; + this.flash.keys.clear(); + this.hover = null; + + // Back to day (in case we're restarting after a solved night). + this.tweens.killTweensOf(this.sky); + this.sky.t = 0; + + const def = DIFFICULTIES[difficultyKey] ?? DIFFICULTIES.porch; + this.view = 'play'; + this.selectLayer.setVisible(false); + this.winPanel?.setVisible(false); + this.hudLayer.setVisible(true); + this.btnNew.setEnabled(true); + this.btnAnswer.setEnabled(true); + this.btnReset.setEnabled(true); + this.diffText.setText(def.label); + + this.computeLayout(); + this.buildBoard(); + this.updateHud(); + this.dirty = true; + this.toast(`${def.label} — ${def.size}×${def.size}, ${puzzle.trees.length} trees. Pitch each tent beside a tree.`, 950); + } + + buildBoard() { + for (const z of this.cellZones) z.destroy(); + this.cellZones = []; + for (const t of this.colLabels) t.destroy(); + for (const t of this.rowLabels) t.destroy(); + this.colLabels = []; + this.rowLabels = []; + + const p = this.g.puzzle; + const cell = this.cell; + const s = p.size; + const fs = Math.max(18, Math.round(cell * 0.46)); + + // Edge number labels (top = columns, right = rows). + for (let c = 0; c < s; c++) { + this.colLabels.push(this.add.text(this.bx + c * cell + cell / 2, this.by - 44, String(p.colCounts[c]), { + fontFamily: FONT_D, fontSize: `${fs}px`, color: '#fff3d9', + stroke: '#2a1f10', strokeThickness: 4, + }).setOrigin(0.5).setDepth(30)); + } + for (let r = 0; r < s; r++) { + this.rowLabels.push(this.add.text(this.bx + s * cell + 46, this.by + r * cell + cell / 2, String(p.rowCounts[r]), { + fontFamily: FONT_D, fontSize: `${fs}px`, color: '#fff3d9', + stroke: '#2a1f10', strokeThickness: 4, + }).setOrigin(0.5).setDepth(30)); + } + + // Interactive cell zones. + for (let r = 0; r < s; r++) { + for (let c = 0; c < s; c++) { + const [x, y] = this.cellCenter(c, r); + const zone = this.add.zone(x, y, cell, cell).setInteractive().setDepth(25); + zone.on('pointerover', () => { this.hover = { c, r }; this.dirty = true; }); + zone.on('pointerout', () => { + if (this.hover && this.hover.c === c && this.hover.r === r) this.hover = null; + this.dirty = true; + }); + zone.on('pointerup', () => this.onCellClick(c, r)); + this.cellZones.push(zone); + } + } + } + + onCellClick(c, r) { + if (this.won) return; + const res = toggleTent(this.g, c, r); + this.dirty = true; + + if (!res.changed) { + this.wiggle.set(keyOf(c, r), this.time.now); + playSoundEx(this, SFX.UI_FLIP, { volume: 0.5 }); + return; + } + + if (!this.g.startedAt) this.g.startedAt = Date.now(); + + if (res.placed) { + this.pop.set(keyOf(c, r), this.time.now); + playSoundEx(this, SFX.UI_ACTIVATE, { volume: 0.8 }); + this.checkViolationFeedback(); + } else { + this.pop.delete(keyOf(c, r)); + playSoundEx(this, SFX.UI_PICK, { volume: 0.7 }); + } + + this.updateHud(); + if (isSolved(this.g)) this.startWin(); + } + + diagnoseLite() { + const p = this.g.puzzle; + const badRows = new Set(), badCols = new Set(); + const rowUsed = new Array(p.size).fill(0), colUsed = new Array(p.size).fill(0); + for (const k of this.g.tents) { rowUsed[(k / 100) | 0]++; colUsed[k % 100]++; } + for (let r = 0; r < p.size; r++) if (rowUsed[r] > p.rowCounts[r]) badRows.add(r); + for (let c = 0; c < p.size; c++) if (colUsed[c] > p.colCounts[c]) badCols.add(c); + return { badRows, badCols, rowUsed, colUsed }; + } + + checkViolationFeedback() { + const d = this.diagnoseLite(); + if (d.badRows.size === 0 && d.badCols.size === 0) return; + this.flash.until = this.time.now + 380; + this.flash.keys.clear(); + for (const k of this.g.tents) { + if (d.badRows.has((k / 100) | 0) || d.badCols.has(k % 100)) this.flash.keys.add(k); + } + playSoundEx(this, SFX.MASTERMIND_DENIED, { volume: 0.5 }); + const cam = this.cameras.main; + this.tweens.add({ + targets: cam, x: 5, duration: 55, yoyo: true, repeat: 2, + onComplete: () => cam.setScroll(0, 0), + }); + } + + resetTents() { + if (this.won) return; + clearTents(this.g); + this.pop.clear(); + this.flash.until = 0; + this.dirty = true; + this.updateHud(); + playSoundEx(this, SFX.UI_PICK, { volume: 0.6 }); + } + + revealAnswer() { + if (this.won) return; + const sol = new Set(solutionTents(this.g).map(([c, r]) => keyOf(c, r))); + for (const k of [...this.g.tents]) if (!sol.has(k)) { this.g.tents.delete(k); this.pop.delete(k); } + this.dirty = true; + const missing = [...sol].filter((k) => !this.g.tents.has(k)); + missing.forEach((k, i) => this.time.delayedCall(75 * i, () => { + if (this.g.tents.has(k) || this.won) return; + this.g.tents.add(k); + this.pop.set(k, this.time.now); + this.dirty = true; + playSoundEx(this, SFX.UI_ACTIVATE, { volume: 0.55 }); + })); + this.time.delayedCall(75 * missing.length + 180, () => { + this.updateHud(); + if (isSolved(this.g)) this.startWin(); + }); + this.btnAnswer.setEnabled(false); + } + + startWin() { + if (this.won) return; + this.won = true; + playSoundEx(this, SFX.VICTORY_SHORT, { volume: 0.9 }); + this.toast('Every tent is pitched… the sun is going down.', 950); + + // Nightfall: ~6.5s, then the cozy panel. + this.tweens.add({ targets: this.sky, t: 1, duration: 6500, ease: 'Sine.inOut' }); + this.time.delayedCall(4700, () => this.seedFireflies()); + this.time.delayedCall(6700, () => this.showWinPanel()); + + this.btnNew.setEnabled(true); + this.btnAnswer.setEnabled(false); + this.btnReset.setEnabled(false); + + const seconds = this.g.startedAt + ? Math.max(1, Math.round((Date.now() - this.g.startedAt) / 1000)) + : null; + api.post('/history/single-player', { + slug: 'tents', + score: seconds ?? 0, + opponentScores: [], + result: 'win', + }).catch((err) => console.warn('Tents: history post failed', err)); + } + + seedFireflies() { + if (!this.g) return; + const s = this.g.puzzle.size; + const minX = this.bx - 70, maxX = this.bx + s * this.cell + 70; + const minY = this.by - 70, maxY = this.by + s * this.cell + 90; + for (let i = 0; i < 26; i++) { + this.fireflies.push({ + x: minX + Math.random() * (maxX - minX), + y: minY + Math.random() * (maxY - minY), + ax: 12 + Math.random() * 26, + ay: 8 + Math.random() * 18, + sp: 0.3 + Math.random() * 0.7, + ph: Math.random() * Math.PI * 2, + blink: 1.5 + Math.random() * 2.5, + r: 1.6 + Math.random() * 1.4, + }); + } + } + + showWinPanel() { + if (this.winPanelShown) return; + this.winPanelShown = true; + + const cx = GAME_WIDTH / 2, cy = 245; + const w = 680, h = 320; + const layer = this.add.container(0, 0).setDepth(40); + + const panel = this.add.rectangle(cx, cy, w, h, 0x101024, 0.92) + .setStrokeStyle(3, 0xffc46a, 0.9); + const stars = this.add.text(cx, cy - h / 2 + 62, '✶ ✶ ✶', { + fontFamily: FONT_B, fontSize: '30px', color: '#ffd76a', + }).setOrigin(0.5); + const title = this.add.text(cx, cy - 40, 'Camp Complete!', { + fontFamily: FONT_D, fontSize: '58px', color: '#ffd76a', + stroke: '#3a2a14', strokeThickness: 8, + }).setOrigin(0.5); + const sub = this.add.text(cx, cy + 24, 'The fire crackles, the stars are out — a perfect night.', { + fontFamily: FONT_B, fontSize: '22px', color: '#e8f0ff', + }).setOrigin(0.5); + + const newBtn = new Button(this, cx - 130, cy + 104, 'New Game', + () => this.startGame(this.difficulty), { width: 220, height: 58, variant: 'solid' }); + const menuBtn = new Button(this, cx + 130, cy + 104, 'Menu', + () => this.scene.start('GameMenu'), { width: 160, height: 58, variant: 'ghost' }); + + layer.add([panel, stars, title, sub, newBtn, menuBtn]); + layer.setAlpha(0); + this.winPanel = layer; + this.tweens.add({ targets: layer, alpha: 1, duration: 420, ease: 'Quad.out' }); + } + + updateHud() { + if (!this.g) return; + const p = this.g.puzzle; + const { rowUsed, colUsed } = this.diagnoseLite(); + this.statTents.setText(`tents ${tentCount(this.g)} / ${p.trees.length}`); + if (this.g.startedAt) { + const s = Math.max(0, Math.floor((Date.now() - this.g.startedAt) / 1000)); + this.statTimer.setText(`time ${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`); + } + for (let c = 0; c < p.size; c++) { + const done = colUsed[c] === p.colCounts[c] && p.colCounts[c] > 0; + const over = colUsed[c] > p.colCounts[c]; + this.colLabels[c].setColor(over ? '#ff6b6b' : done ? '#ffd76a' : '#fff3d9'); + } + for (let r = 0; r < p.size; r++) { + const done = rowUsed[r] === p.rowCounts[r] && p.rowCounts[r] > 0; + const over = rowUsed[r] > p.rowCounts[r]; + this.rowLabels[r].setColor(over ? '#ff6b6b' : done ? '#ffd76a' : '#fff3d9'); + } + } + + toast(msg, y) { + const t = this.add.text(GAME_WIDTH / 2, y, msg, { + fontFamily: FONT_B, fontSize: '24px', color: '#fff6dd', + stroke: '#241a10', strokeThickness: 5, align: 'center', + }).setOrigin(0.5).setDepth(45).setAlpha(0); + this.tweens.add({ targets: t, alpha: 1, duration: 260 }); + this.tweens.add({ + targets: t, alpha: 0, y: y - 26, delay: 3200, duration: 700, + onComplete: () => t.destroy(), + }); + } + + // ── Render ───────────────────────────────────────────────────────────────── + + update(time, delta) { + if (this.view !== 'play' && this.view !== 'select') return; + const animating = this.sky.t > 0.001 || this.view === 'select'; + if (!this.dirty && !animating) return; + + this.drawSky(time, delta); + if (this.view === 'play' && this.g) this.drawBoard(time); + this.drawFx(time, delta); + this.dirty = false; + } + + drawSky(now, delta) { + const g = this.bgGfx; + g.clear(); + const W = GAME_WIDTH, H = GAME_HEIGHT, t = this.sky.t; + + // Sky: vertical gradient via strips (base color), blended toward the + // horizon glow color in the lower half. + const steps = 24; + const stripH = H / steps + 1; + for (let i = 0; i < steps; i++) { + const y0 = (H * i) / steps; + g.fillStyle(col('skyTop', t), 1); + g.fillRect(0, y0, W, stripH); + const weight = clamp(1 - (this.horizonY - y0) / 340, 0, 1); + if (weight > 0) { + g.fillStyle(col('skyBottom', t), weight * 0.85); + g.fillRect(0, y0, W, stripH); + } + } + + // Stars (night). + for (const st of this.stars) { + if (st.y > this.horizonY - 6) continue; + const tw = 0.5 + 0.5 * Math.sin((now / 1000) * st.sp + st.ph); + const a = t * (0.25 + 0.75 * tw); + if (a < 0.02) continue; + g.fillStyle(0xdfe6ff, a); + g.fillCircle(st.x, st.y, st.s); + if (st.s > 2) { + g.lineStyle(1, 0xdfe6ff, a * 0.8); + g.lineBetween(st.x - st.s * 2.4, st.y, st.x + st.s * 2.4, st.y); + g.lineBetween(st.x, st.y - st.s * 2.4, st.x, st.y + st.s * 2.4); + } + } + + // Sun (sinks to the horizon and fades in the last stretch). + const sunA = t > 0.72 ? Math.max(0, 1 - (t - 0.72) / 0.28) : 1; + if (sunA > 0.01) { + const sx = lerp(W * 0.76, W * 0.9, t); + const sy = lerp(170, this.horizonY - 4, t); + g.fillStyle(col('sun', t), 0.16 * sunA); g.fillCircle(sx, sy, 78); + g.fillStyle(col('sun', t), 0.28 * sunA); g.fillCircle(sx, sy, 52); + g.fillStyle(col('sun', t), sunA); g.fillCircle(sx, sy, 34); + } + + // Moon (rises as night falls). + const mA = clamp((t - 0.35) / 0.65, 0, 1); + if (mA > 0.01) { + const mx = W * 0.15, my = lerp(this.horizonY + 60, 168, mA); + g.fillStyle(0xeef1ff, mA * 0.35); g.fillCircle(mx, my, 48); + g.fillStyle(0xeef1ff, mA); g.fillCircle(mx, my, 30); + g.fillStyle(col('skyTop', t), mA); g.fillCircle(mx + 13, my - 9, 26); // crescent bite + } + + // Clouds (day; fade out at night). + const cloudA = (1 - t) * 0.8; + if (cloudA > 0.02) { + for (const cl of this.clouds) { + cl.x -= cl.v * (delta / 1000); + if (cl.x < -cl.w) cl.x = W + cl.w; + for (const puff of cl.puffs) { + g.fillStyle(0xffffff, cloudA * (0.45 + puff.r)); + g.fillEllipse(cl.x + puff.dx * cl.w, cl.y + puff.dy * cl.w * 0.5, + cl.w * puff.r * 1.6, cl.w * puff.r * 0.8); + } + } + } + + // Pine silhouettes on the horizon. + for (const [arr, keyName] of [[this.hillFar, 'hillFar'], [this.hillNear, 'hillNear']]) { + g.fillStyle(col(keyName, t), 1); + g.beginPath(); + g.moveTo(-10, this.horizonY + 2); + for (const s of arr) { + g.lineTo(s.x, this.horizonY + 2); + g.lineTo(s.x + s.w / 2, this.horizonY + 2 - s.h); + g.lineTo(s.x + s.w, this.horizonY + 2); + } + g.lineTo(W + 10, this.horizonY + 2); + g.closePath(); + g.fillPath(); + } + + // Meadow. + g.fillStyle(col('meadow', t), 1); + g.fillRect(0, this.horizonY, W, H - this.horizonY); + for (let x = 0; x < W; x += 200) { + g.fillStyle(0xffffff, 0.03 * (1 - t * 0.6)); + g.fillRect(x, this.horizonY, 100, H - this.horizonY); + } + for (const f of this.flowers) { + g.fillStyle(f.c, (1 - t) * 0.85 + 0.06); + g.fillCircle(f.x, f.y, f.r); + } + } + + drawBoard(now) { + const g = this.boardGfx; + g.clear(); + const p = this.g.puzzle; + const cell = this.cell; + const s = p.size; + const t = this.sky.t; + + const treeSet = this.g.treeSet; + const tentSet = this.g.tents; + const d = this.diagnoseLite(); + const flashing = now < this.flash.until; + + // Soft drop shadow under the board. + g.fillStyle(0x000000, 0.25); + g.fillEllipse(this.bx + (s * cell) / 2, this.by + s * cell + 14, s * cell * 1.04, 34); + + // Edge label chips (top = columns, right = rows). + const chip = (x, y) => { + g.fillStyle(0x141126, 0.78); + g.fillRoundedRect(x - 27, y - 24, 54, 48, 10); + g.lineStyle(2, 0xd9a441, 0.8); + g.strokeRoundedRect(x - 27, y - 24, 54, 48, 10); + }; + for (let c = 0; c < s; c++) chip(this.bx + c * cell + cell / 2, this.by - 44); + for (let r = 0; r < s; r++) chip(this.bx + s * cell + 46, this.by + r * cell + cell / 2); + + // Cells. + for (let r = 0; r < s; r++) { + for (let c = 0; c < s; c++) { + const x0 = this.bx + c * cell, y0 = this.by + r * cell; + const bad = d.badRows.has(r) || d.badCols.has(c); + g.fillStyle((r + c) % 2 === 0 ? col('tileA', t) : col('tileB', t), 1); + g.fillRoundedRect(x0 + 2, y0 + 2, cell - 4, cell - 4, 9); + g.lineStyle(1.5, col('tileLine', t), 0.85); + g.strokeRoundedRect(x0 + 2, y0 + 2, cell - 4, cell - 4, 9); + if (bad && flashing) { + g.fillStyle(0xff5a5a, 0.2); + g.fillRoundedRect(x0 + 2, y0 + 2, cell - 4, cell - 4, 9); + } + } + } + + // Hover hint (empty cells only). + if (this.hover && !this.won) { + const { c, r } = this.hover; + if (!treeSet.has(keyOf(c, r))) { + const x0 = this.bx + c * cell, y0 = this.by + r * cell; + g.fillStyle(0xffffff, 0.16); + g.fillRoundedRect(x0 + 2, y0 + 2, cell - 4, cell - 4, 9); + if (!tentSet.has(keyOf(c, r))) { + const [cx, cy] = this.cellCenter(c, r); + g.globalAlpha = 0.35; + this.drawTent(g, cx, cy, cell, t, 1); + g.globalAlpha = 1; + } + } + } + + // Trees. + for (const [c, r] of p.trees) { + const [x, y] = this.cellCenter(c, r); + let w = 0; + const t0 = this.wiggle.get(keyOf(c, r)); + if (t0 !== undefined) { + const pr = clamp((now - t0) / 450, 0, 1); + if (pr < 1) w = Math.sin(pr * Math.PI * 3) * (1 - pr) * 0.16; + else this.wiggle.delete(keyOf(c, r)); + } + this.drawTree(g, x, y, cell, t, w); + } + + // Tents. + for (const k of tentSet) { + const c = k % 100, r = (k / 100) | 0; + let [x, y] = this.cellCenter(c, r); + let scale = 1; + const t0 = this.pop.get(k); + if (t0 !== undefined) { + const pr = clamp((now - t0) / 220, 0, 1); + if (pr < 1) scale = 0.4 + 0.6 * easeOutBack(pr); + else this.pop.delete(k); + } + if (flashing && this.flash.keys.has(k)) x += Math.sin(now / 28) * 2.6; + this.drawTent(g, x, y, cell, t, scale); + } + } + + drawTree(g, x, y, s, t, wiggle) { + const u = s * 0.86; + const sw = 1 + wiggle; + g.fillStyle(col('trunk', t), 1); + g.fillRect(x - u * 0.07, y + u * 0.12, u * 0.14, u * 0.3); + const tiers = [ + { yOff: 0.16, w: 0.52, h: 0.44, key: 'treeA' }, + { yOff: -0.1, w: 0.42, h: 0.4, key: 'treeB' }, + { yOff: -0.32, w: 0.32, h: 0.36, key: 'treeC' }, + ]; + for (const L of tiers) { + const w = u * L.w * sw, h = u * L.h * (1 + wiggle * 0.6); + const baseY = y + u * L.yOff; + g.fillStyle(col(L.key, t), 1); + tri(g, x, baseY - h, x + w / 2, baseY, x - w / 2, baseY); + // A touch of light on the sun side. + g.fillStyle(0xffffff, 0.08 + 0.1 * (1 - t)); + g.beginPath(); + g.moveTo(x, baseY - h); + g.lineTo(x + w * 0.18, baseY - h * 0.25); + g.lineTo(x - w * 0.06, baseY - h * 0.2); + g.closePath(); + g.fillPath(); + } + g.fillStyle(0xffffff, 0.12 * (1 - t)); + g.fillCircle(x, y - u * 0.62, u * 0.05); + } + + drawTent(g, x, y, s, t, popScale) { + const u = s * 0.78 * popScale; + const baseY = y + u * 0.42, apexY = y - u * 0.5; + const halfW = u * 0.5; + g.fillStyle(0x000000, 0.18); + g.fillEllipse(x, baseY + 2, u * 1.05, u * 0.2); + g.fillStyle(col('tentL', t), 1); + tri(g, x, apexY, x - halfW, baseY, x, baseY); + g.fillStyle(col('tentR', t), 1); + tri(g, x, apexY, x, baseY, x + halfW, baseY); + g.fillStyle(col('tentDoor', t), 1); + tri(g, x, y - u * 0.16, x - u * 0.17, baseY, x + u * 0.17, baseY); + g.lineStyle(Math.max(1, u * 0.025), 0x2a1f10, 0.5); + g.lineBetween(x, apexY, x, baseY); + g.fillStyle(0x2a1f10, 0.8); + g.fillCircle(x, apexY, Math.max(1.6, u * 0.045)); + } + + // ── Night FX: campfires, embers, fireflies ──────────────────────────────── + + drawFx(now, delta) { + const g = this.fxGfx; + g.clear(); + const t = this.sky.t; + + // Menu campfire (always lit — a cozy landing spot). + if (this.view === 'select') { + this.drawCampfire(g, this.menuCamp.x, this.menuCamp.y, now, 0, 1); + this.spawnEmbers(this.menuCamp.x, this.menuCamp.y - 10, now); + this.updateAndDrawEmbers(g, now, delta, 1); + return; + } + + // Campfire intensity ramps in over the last half of dusk. + const a = clamp((t - 0.5) / 0.5, 0, 1); + if (a <= 0.01) { + this.updateAndDrawEmbers(g, now, delta, 0); + return; + } + + if (this.g) { + const p = this.g.puzzle; + const cell = this.cell; + const fires = []; + for (let i = 0; i < p.solution.length; i++) { + const [c, r] = p.solution[i]; + const [cx, cy] = this.cellCenter(c, r); + const fx = cx + cell * 0.62; + const fy = cy + cell * 0.34; + fires.push([fx, fy]); + this.drawCampfire(g, fx, fy, now, i, a); + } + // Embers rise from a random fire each tick (shared rate limiter). + if (fires.length) { + const [ex, ey] = fires[(Math.random() * fires.length) | 0]; + this.spawnEmbers(ex, ey - 10, now); + } + + // Warm light pooling around each tent door. + for (const [c, r] of p.solution) { + const [cx, cy] = this.cellCenter(c, r); + g.fillStyle(0xff9e42, 0.1 * a); + g.fillCircle(cx, cy + cell * 0.3, cell * 1.15); + g.fillStyle(0xffc46a, 0.16 * a); + g.fillCircle(cx, cy + cell * 0.3, cell * 0.6); + g.fillStyle(0xffe2a0, 0.3 * a); + g.fillCircle(cx, cy + cell * 0.32, cell * 0.28); + } + } + + this.updateAndDrawEmbers(g, now, delta, a); + + // Fireflies. + if (a > 0.15) { + for (const f of this.fireflies) { + const bx = f.x + Math.cos((now / 1000) * f.sp + f.ph) * f.ax; + const by = f.y + Math.sin((now / 1000) * f.sp * 1.3 + f.ph) * f.ay; + const blink = 0.5 + 0.5 * Math.sin((now / 1000) * f.blink + f.ph * 2); + const al = a * (0.2 + 0.8 * blink); + g.fillStyle(0xb8ff7a, al * 0.25); + g.fillCircle(bx, by, f.r * 3.4); + g.fillStyle(0xd6ffb0, al); + g.fillCircle(bx, by, f.r); + } + } + } + + drawCampfire(g, x, y, now, seed, a) { + const f = 1 + 0.13 * Math.sin(now / 95 + seed * 2.3) + 0.09 * Math.sin(now / 41 + seed * 5.1); + const fl = f * a; + g.lineStyle(5, 0x3a2412, 1); + g.lineBetween(x - 10, y + 4, x + 10, y - 3); + g.lineBetween(x - 10, y - 3, x + 10, y + 4); + if (a > 0.05) { + g.fillStyle(0xff6b3d, 0.75 * a); + tri(g, x - 10 * fl, y, x + 10 * fl, y, x, y - 26 * fl); + g.fillStyle(0xff9e42, 0.85 * a); + tri(g, x - 6.5 * fl, y, x + 6.5 * fl, y, x, y - 17 * fl); + g.fillStyle(0xffd76a, 0.95 * a); + tri(g, x - 3.4 * fl, y, x + 3.4 * fl, y, x, y - 9.5 * fl); + // Glow (additive layer). + g.fillStyle(0xff9e42, 0.12 * a); g.fillCircle(x, y - 8, 34 * fl); + g.fillStyle(0xffc46a, 0.2 * a); g.fillCircle(x, y - 8, 20 * fl); + g.fillStyle(0xffe9b8, 0.3 * a); g.fillCircle(x, y - 8, 10 * fl); + } + } + + spawnEmbers(x, y, now) { + if (now - this.lastEmberAt < 130 + Math.random() * 120) return; + if (this.embers.length > 90) return; + this.lastEmberAt = now; + this.embers.push({ + x: x + (Math.random() - 0.5) * 10, + y: y + (Math.random() - 0.5) * 6, + vx: (Math.random() - 0.5) * 22, + vy: -26 - Math.random() * 26, + r: 1 + Math.random() * 1.6, + life: 0, + max: 900 + Math.random() * 900, + hot: Math.random(), + }); + } + + updateAndDrawEmbers(g, now, delta, a) { + const dt = delta / 1000; + for (let i = this.embers.length - 1; i >= 0; i--) { + const e = this.embers[i]; + e.life += delta; + if (e.life >= e.max) { this.embers.splice(i, 1); continue; } + e.x += e.vx * dt; + e.y += e.vy * dt; + e.vy -= 7 * dt; + e.vx += Math.sin(now / 300 + e.hot * 9) * 8 * dt; + const k = 1 - e.life / e.max; + const al = k * (a > 0 ? a : 1) * 0.9; + g.fillStyle(e.hot > 0.5 ? 0xffd76a : 0xff9e42, al); + g.fillCircle(e.x, e.y, e.r * (0.5 + 0.5 * k)); + } + } +} diff --git a/src/games/tents/TentsLogic.js b/src/games/tents/TentsLogic.js new file mode 100644 index 0000000..7861b90 --- /dev/null +++ b/src/games/tents/TentsLogic.js @@ -0,0 +1,274 @@ +// Tents & Trees — pure board model for the classic "Tents" logic puzzle. +// No Phaser, no DOM — unit-testable in Node (see tools/verifyTents.js). +// +// Rules: +// • Trees are given; tents are placed on empty cells. +// • Every tent must be orthogonally adjacent to a tree, and every tree +// must be paired with exactly one tent (one-to-one). +// • No two tents may touch — not even corner to corner. +// • The row/column counts on the edges must be met exactly. +// +// Generation scatters trees, then requires that the pairing + no-touch rules +// admit exactly ONE solution (checked by an exhaustive solver capped at two +// solutions). The edge counts are derived from that unique placement, which +// can only ever shrink the solution set — so uniqueness is guaranteed. + +export const DIFFICULTIES = { + porch: { key: 'porch', label: 'Porch', size: 6, trees: 6, blurb: 'A gentle stroll' }, + clearing: { key: 'clearing', label: 'Clearing', size: 8, trees: 10, blurb: 'A proper camp' }, + meadow: { key: 'meadow', label: 'Meadow', size: 9, trees: 12, blurb: 'Real bushcraft' }, + deepwoods: { key: 'deepwoods', label: 'Deep Woods', size: 10, trees: 14, blurb: 'For seasoned scouts' }, +}; + +export const DIFFICULTY_ORDER = ['porch', 'clearing', 'meadow', 'deepwoods']; + +const DIRS4 = [[1, 0], [-1, 0], [0, 1], [0, -1]]; +const DIRS8 = DIRS4.concat([[1, 1], [1, -1], [-1, 1], [-1, -1]]); + +// Cell keys pack row/col into one integer (boards never exceed 99 cells wide). +export const keyOf = (c, r) => r * 100 + c; +const colOf = (k) => k % 100; +const rowOf = (k) => (k / 100) | 0; + +export function inBounds(c, r, size) { + return c >= 0 && c < size && r >= 0 && r < size; +} + +// ── Solver ──────────────────────────────────────────────────────────────────── +// Backtracking over trees (ordered by fewest candidate cells). A "solution" is +// a full assignment where every tree has exactly one tent in an empty cell, +// and no two tents are adjacent in any of the 8 directions. Row/column +// counts (when given) are enforced as first-class constraints — in practice +// they are what makes a random tree layout unique. Returns up to `limit` +// solutions, each a sorted array of [col, row] tent positions. +export function countSolutions(trees, size, { limit = 2, rowCounts = null, colCounts = null } = {}) { + const treeSet = new Set(trees.map(([c, r]) => keyOf(c, r))); + // A candidate cell must be orthogonally adjacent to EXACTLY one tree — a + // tent touching two trees would leave one of them double-claimed (or + // strand its partner), so such cells are illegal for every tree. + function adjTrees(c, r) { + let n = 0; + for (const [dc, dr] of DIRS4) { + const nc = c + dc, nr = r + dr; + if (inBounds(nc, nr, size) && treeSet.has(keyOf(nc, nr))) n++; + } + return n; + } + const cands = trees.map(([tc, tr]) => { + const out = []; + for (const [dc, dr] of DIRS4) { + const c = tc + dc, r = tr + dr; + if (inBounds(c, r, size) && !treeSet.has(keyOf(c, r)) && adjTrees(c, r) === 1) out.push([c, r]); + } + return out; + }); + if (cands.some((cs) => cs.length === 0)) return []; // a tree with no usable neighbour can never get a tent + + const order = cands.map((cs, i) => i).sort((a, b) => cands[a].length - cands[b].length); + const solutions = []; + const used = []; // tent keys, in assignment order + const usedSet = new Set(); + const rowUsed = new Array(size).fill(0); + const colUsed = new Array(size).fill(0); + + // True once we should stop searching entirely. + function stop() { return solutions.length >= limit; } + + function backtrack(i) { + if (stop()) return true; + if (i === order.length) { + solutions.push( + used + .map((k) => [colOf(k), rowOf(k)]) + .sort((a, b) => a[1] - b[1] || a[0] - b[0]), + ); + return stop(); // true only if the search must halt (limit reached) + } + + const treeIdx = order[i]; + // A new tent must not sit on, or touch (8-way), any tent already placed. + const blocked = new Set(usedSet); + for (const k of usedSet) { + const c = colOf(k), r = rowOf(k); + for (const [dc, dr] of DIRS8) { + const nc = c + dc, nr = r + dr; + if (inBounds(nc, nr, size)) blocked.add(keyOf(nc, nr)); + } + } + + for (const [c, r] of cands[treeIdx]) { + const k = keyOf(c, r); + if (blocked.has(k)) continue; + if (rowCounts && rowUsed[r] >= rowCounts[r]) continue; + if (colCounts && colUsed[c] >= colCounts[c]) continue; + usedSet.add(k); + used.push(k); + rowUsed[r]++; colUsed[c]++; + const halt = backtrack(i + 1); + rowUsed[r]--; colUsed[c]--; + usedSet.delete(k); + used.pop(); + if (halt) return true; + } + return false; + } + + backtrack(0); + return solutions; +} + +// ── Generation ─────────────────────────────────────────────────────────────── + +function placeTrees(size, count, rng) { + const cells = []; + for (let r = 0; r < size; r++) for (let c = 0; c < size; c++) cells.push([c, r]); + // Partial Fisher–Yates: pull `count` distinct random cells. + for (let i = 0; i < count; i++) { + const j = i + Math.floor(rng() * (cells.length - i)); + const t = cells[i]; cells[i] = cells[j]; cells[j] = t; + } + const trees = cells.slice(0, count); + const treeSet = new Set(trees.map(([c, r]) => keyOf(c, r))); + // Every tree needs at least one empty orthogonal neighbour to host a tent. + for (const [c, r] of trees) { + let free = false; + for (const [dc, dr] of DIRS4) { + const nc = c + dc, nr = r + dr; + if (inBounds(nc, nr, size) && !treeSet.has(keyOf(nc, nr))) { free = true; break; } + } + if (!free) return null; + } + return trees; +} + +// Pipeline: scatter trees → find ANY solution → derive its edge counts → +// verify that (trees + counts) admits exactly one solution. The counts do the +// heavy lifting for uniqueness (a layout that looks ambiguous on pairing +// rules alone is usually pinned down by its row/column sums), so this +// converges in a handful of tries. +export function generatePuzzle(difficultyKey, rng = Math.random) { + const def = DIFFICULTIES[difficultyKey] ?? DIFFICULTIES.porch; + for (let attempt = 0; attempt < 1500; attempt++) { + const trees = placeTrees(def.size, def.trees, rng); + if (!trees) continue; + + const base = countSolutions(trees, def.size, { limit: 1 }); + if (base.length !== 1) continue; // unsolvable layout + const solution = base[0]; + + const rowCounts = new Array(def.size).fill(0); + const colCounts = new Array(def.size).fill(0); + for (const [c, r] of solution) { rowCounts[r] += 1; colCounts[c] += 1; } + + const withCounts = countSolutions(trees, def.size, { limit: 2, rowCounts, colCounts }); + if (withCounts.length !== 1) continue; // still ambiguous once the counts are given + + return { + difficulty: def.key, + size: def.size, + trees, // array of [col, row] + rowCounts, // tents per row + colCounts, // tents per column + solution, // the unique tent placement, [col, row] pairs + }; + } + throw new Error('Tents: could not find a uniquely-solvable tree layout'); +} + +// ── Play state ─────────────────────────────────────────────────────────────── + +export function newGame(puzzle) { + return { + puzzle, + treeSet: new Set(puzzle.trees.map(([c, r]) => keyOf(c, r))), + tents: new Set(), // player-placed tent keys + firstMoveDone: false, + }; +} + +export function isTree(g, c, r) { + return g.treeSet.has(keyOf(c, r)); +} + +// Toggle a tent on an empty cell. Returns { changed, placed } (placed=false +// on removal, changed=false on no-op such as clicking a tree). +export function toggleTent(g, c, r) { + if (g.treeSet.has(keyOf(c, r))) return { changed: false, placed: false }; + const k = keyOf(c, r); + if (g.tents.has(k)) { + g.tents.delete(k); + return { changed: true, placed: false }; + } + g.tents.add(k); + g.firstMoveDone = true; + return { changed: true, placed: true }; +} + +export function clearTents(g) { + const had = g.tents.size > 0; + g.tents.clear(); + return had; +} + +export function tentCount(g) { + return g.tents.size; +} + +// Highlight-worthy problems in the current placement: +// badTents — tent with no tree beside it, or touching another tent +// badTrees — tree claimed by two or more tents +// badRows / badCols — row/column already over its count +export function diagnose(g) { + const size = g.puzzle.size; + const badTents = new Set(); + const badTrees = new Set(); + const badRows = new Set(); + const badCols = new Set(); + + for (const k of g.tents) { + const c = colOf(k), r = rowOf(k); + let besideTree = false; + for (const [dc, dr] of DIRS4) { + const nc = c + dc, nr = r + dr; + if (inBounds(nc, nr, size) && g.treeSet.has(keyOf(nc, nr))) { besideTree = true; break; } + } + if (!besideTree) badTents.add(k); + + let touching = false; + for (const [dc, dr] of DIRS8) { + const nc = c + dc, nr = r + dr; + if (inBounds(nc, nr, size) && g.tents.has(keyOf(nc, nr))) { touching = true; break; } + } + if (touching) badTents.add(k); + } + + for (const [tc, tr] of g.puzzle.trees) { + let claimed = 0; + for (const [dc, dr] of DIRS4) { + const nc = tc + dc, nr = tr + dr; + if (inBounds(nc, nr, size) && g.tents.has(keyOf(nc, nr))) claimed++; + } + if (claimed >= 2) badTrees.add(keyOf(tc, tr)); + } + + const rowUsed = new Array(size).fill(0); + const colUsed = new Array(size).fill(0); + for (const k of g.tents) { rowUsed[rowOf(k)]++; colUsed[colOf(k)]++; } + for (let r = 0; r < size; r++) if (rowUsed[r] > g.puzzle.rowCounts[r]) badRows.add(r); + for (let c = 0; c < size; c++) if (colUsed[c] > g.puzzle.colCounts[c]) badCols.add(c); + + return { badTents, badTrees, badRows, badCols }; +} + +// Solved: every tent placed, perfectly paired, nothing touching, counts met. +export function isSolved(g) { + if (g.tents.size !== g.puzzle.trees.length) return false; + const d = diagnose(g); + return d.badTents.size === 0 && d.badTrees.size === 0 + && d.badRows.size === 0 && d.badCols.size === 0; +} + +// The unique solution (for the Answer button). Array of [col, row]. +export function solutionTents(g) { + return g.puzzle.solution; +} diff --git a/src/main.js b/src/main.js index 2d6ab4f..9f4872d 100644 --- a/src/main.js +++ b/src/main.js @@ -108,6 +108,7 @@ import VegaCombatSim from './games/mastervega/VegaCombatSim.js'; import WolfensteinGame from './games/wolfenstein/WolfensteinGame.js'; import WolfensteinEditor from './games/wolfenstein/WolfensteinEditor.js'; import PipePuzzleGame from './games/pipepuzzle/PipePuzzleGame.js'; +import TentsGame from './games/tents/TentsGame.js'; const config = { type: Phaser.AUTO, @@ -229,6 +230,7 @@ const config = { WolfensteinGame, WolfensteinEditor, PipePuzzleGame, + TentsGame, ], }; diff --git a/src/scenes/GameRoomScene.js b/src/scenes/GameRoomScene.js index f154daa..c9deddf 100644 --- a/src/scenes/GameRoomScene.js +++ b/src/scenes/GameRoomScene.js @@ -23,7 +23,7 @@ export default class GameRoomScene extends Phaser.Scene { } create() { - const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame', monopoly: 'MonopolyGame', triominoes: 'TriominoesGame', freecell: 'FreecellGame', rushhour: 'RushHourGame', hexsweeper: 'HexsweeperGame', puddingmonsters: 'PuddingMonstersGame', shift: 'ShiftGame', blockfighter: 'BlockFighterGame', mahjongmatch: 'MahjongMatchGame', mahjong: 'MahjongGame', jewelquest: 'JewelQuestGame', zuma: 'ZumaGame', bejeweled: 'BejeweledGame', minimotorways: 'MiniMotorwaysGame', slots: 'SlotsGame', cribbage: 'CribbageGame', canasta: 'CanastaGame', dotlink: 'DotLinkGame', '2048': '2048Game', rummikub: 'RummikubGame', ginrummy: 'GinRummyGame', risk: 'RiskGame', geniussquare: 'GeniusSquareGame', katamino: 'KataminoGame', bookwork: 'BookworkGame', paigow: 'PaiGowPokerGame', spireclimb: 'SpireClimbGame', azul: 'AzulGame', jumble: 'JumbleGame', dungeonboss: 'DungeonBossGame', swdbg: 'SWDBGGame', balatro: 'BalatroGame', peggle: 'PeggleGame', coloradodefense: 'ColoradoDefenseGame', starcontrol: 'StarControlGame', civilization: 'CivilizationGame', tempest: 'TempestGame', superkart: 'SuperKartGame', advancewars: 'AdvanceWarsGame', tetrisattack: 'TetrisAttackGame', totalannihilation: 'TotalAnnihilationGame', bloxorz: 'BloxorzGame', gootower: 'GooTowerGame', excitebike: 'ExcitebikeGame', mastervega: 'MasterOfVegaGame', wolfenstein: 'WolfensteinGame', pipepuzzle: 'PipePuzzleGame' }; + const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame', monopoly: 'MonopolyGame', triominoes: 'TriominoesGame', freecell: 'FreecellGame', rushhour: 'RushHourGame', hexsweeper: 'HexsweeperGame', puddingmonsters: 'PuddingMonstersGame', shift: 'ShiftGame', blockfighter: 'BlockFighterGame', mahjongmatch: 'MahjongMatchGame', mahjong: 'MahjongGame', jewelquest: 'JewelQuestGame', zuma: 'ZumaGame', bejeweled: 'BejeweledGame', minimotorways: 'MiniMotorwaysGame', slots: 'SlotsGame', cribbage: 'CribbageGame', canasta: 'CanastaGame', dotlink: 'DotLinkGame', '2048': '2048Game', rummikub: 'RummikubGame', ginrummy: 'GinRummyGame', risk: 'RiskGame', geniussquare: 'GeniusSquareGame', katamino: 'KataminoGame', bookwork: 'BookworkGame', paigow: 'PaiGowPokerGame', spireclimb: 'SpireClimbGame', azul: 'AzulGame', jumble: 'JumbleGame', dungeonboss: 'DungeonBossGame', swdbg: 'SWDBGGame', balatro: 'BalatroGame', peggle: 'PeggleGame', coloradodefense: 'ColoradoDefenseGame', starcontrol: 'StarControlGame', civilization: 'CivilizationGame', tempest: 'TempestGame', superkart: 'SuperKartGame', advancewars: 'AdvanceWarsGame', tetrisattack: 'TetrisAttackGame', totalannihilation: 'TotalAnnihilationGame', bloxorz: 'BloxorzGame', gootower: 'GooTowerGame', excitebike: 'ExcitebikeGame', mastervega: 'MasterOfVegaGame', wolfenstein: 'WolfensteinGame', pipepuzzle: 'PipePuzzleGame', tents: 'TentsGame' }; if (slugDispatch[this.game.slug]) { const sceneKey = slugDispatch[this.game.slug]; const startData = { diff --git a/tools/smokeTents.js b/tools/smokeTents.js new file mode 100644 index 0000000..f5de726 --- /dev/null +++ b/tools/smokeTents.js @@ -0,0 +1,168 @@ +// 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 s = new TentsGame(); + +// Wire up scene services. +s.add = stub(); +s.scale = { setZoom() {} }; +s.input = { mouse: { disableContextMenu() {} } }; +s.sound = { 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('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('win triggered', s.won === true); +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); + +// ── 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); diff --git a/tools/verifyTents.js b/tools/verifyTents.js new file mode 100644 index 0000000..36bccaa --- /dev/null +++ b/tools/verifyTents.js @@ -0,0 +1,178 @@ +// Verifier for Tents & Trees (Node only — no browser). +// +// 1. Unit-tests the solver against hand-built boards (unique / multi / no +// solution, and the no-touch rule). +// 2. Unit-tests the play-state helpers (toggle, diagnose, solve, answer). +// 3. Generation soak: produces puzzles for every difficulty and re-verifies +// each one independently (validity + uniqueness). +// +// Usage: node tools/verifyTents.js + +import { + DIFFICULTIES, DIFFICULTY_ORDER, countSolutions, generatePuzzle, + newGame, toggleTent, diagnose, isSolved, solutionTents, keyOf, +} from '../src/games/tents/TentsLogic.js'; + +let passes = 0; +let failures = 0; +function check(name, cond, detail = '') { + if (cond) { passes++; console.log(` ok ${name}`); } + else { failures++; console.error(`FAIL ${name}${detail ? ` — ${detail}` : ''}`); } +} + +// ── Solver unit tests ──────────────────────────────────────────────────────── + +console.log('[verify] solver'); + +// Two trees far apart, each with a single candidate: exactly one solution. +{ + // 4×4: trees at (1,1) and (2,2) would share diagonal cells; use (0,0) & (3,3). + const trees = [[0, 0], [3, 3]]; + const sols = countSolutions(trees, 4, { limit: 5 }); + // (0,0)'s candidates: (1,0),(0,1); (3,3)'s: (2,3),(3,2). None touch → 4 solutions. + check('disjoint trees → 4 solutions', sols.length === 4, `got ${sols.length}`); +} + +{ + // 4×4: trees at (1,1) and (2,2) — diagonal neighbours. Their candidate sets + // overlap in touching cells; tent for one blocks the other's options. + const trees = [[1, 1], [2, 2]]; + const sols = countSolutions(trees, 4, { limit: 5 }); + // Each tree's candidates: (0,1),(1,0),(1,2) and (2,1),(3,2),(2,3). + // Valid pairs must not touch (8-way): (0,1)&(2,3)? dist (2,2) ok → no touch. + // Count them all: any combo where neither touches the other. + check('diagonal trees → 0 or more, each valid', sols.length >= 0, `got ${sols.length}`); + for (const sol of sols) { + const [a, b] = sol; + const touch = Math.max(Math.abs(a[0] - b[0]), Math.abs(a[1] - b[1])) <= 1; + check(`solution ${JSON.stringify(sol)} tents do not touch`, !touch); + } +} + +{ + // Tree in a corner with a tree right beside it: (0,0) & (0,1). + // (0,0)'s tent: (1,0) or (0,1)=tree → only (1,0). (0,1)'s tent: (0,0) tree, + // (0,2), (1,1). (1,0) touches (1,1) and (0,2)? (1,0)-(0,2): Δ(1,2) no touch. + // (1,0)-(1,1): touch. So (0,1)'s tent must be (0,2). One solution. + const sols = countSolutions([[0, 0], [0, 1]], 4, { limit: 5 }); + check('corner pair → exactly 1 solution', sols.length === 1, `got ${sols.length}`); + check('corner pair solution', JSON.stringify(sols[0]) === JSON.stringify([[1, 0], [0, 2]]), + JSON.stringify(sols[0])); +} + +{ + // A tree walled in by trees (no empty orthogonal neighbour) → no solutions. + const trees = [[1, 1], [0, 1], [2, 1], [1, 0]]; + const sols = countSolutions(trees, 4, { limit: 5 }); + check('walled-in tree → no solutions', sols.length === 0, `got ${sols.length}`); +} + +{ + // Three in a row: trees (1,1),(2,1),(3,1) on 5×5. + // (1,1) tents: (0,1),(1,0),(1,2). (2,1): (2,0),(2,2). (3,1): (3,0),(3,2),(4,1). + // Must be non-touching. (2,1)'s only options (2,0)/(2,2) touch (1,0)/(1,2) + // diagonally and (3,0)/(3,2) diagonally → whichever chosen blocks both + // neighbours' matching side. Check solver finds the true count. + const sols = countSolutions([[1, 1], [2, 1], [3, 1]], 5, { limit: 10 }); + check('three-in-row solvable', sols.length > 0, `got ${sols.length}`); + for (const sol of sols) { + let ok = true; + for (let i = 0; i < sol.length && ok; i++) + for (let j = i + 1; j < sol.length; j++) + if (Math.max(Math.abs(sol[i][0] - sol[j][0]), Math.abs(sol[i][1] - sol[j][1])) <= 1) ok = false; + check(`3-in-row solution ${JSON.stringify(sol)} non-touching`, ok); + } +} + +// ── Play-state unit tests ──────────────────────────────────────────────────── + +console.log('[verify] play state'); + +{ + // Fixed 4×4 puzzle with a known unique solution. + const trees = [[0, 0], [0, 1]]; + const sols = countSolutions(trees, 4, { limit: 5 }); + check('fixture has unique solution', sols.length === 1, `got ${sols.length}`); + const puzzle = { + difficulty: 'test', size: 4, trees, + rowCounts: [0, 0, 0, 0].map((_, r) => sols[0].filter(([, rr]) => rr === r).length), + colCounts: [0, 0, 0, 0].map((_, c) => sols[0].filter(([cc]) => cc === c).length), + solution: sols[0], + }; + const g = newGame(puzzle); + + check('clicking a tree is a no-op', toggleTent(g, 0, 0).changed === false); + check('place then remove round-trips', + toggleTent(g, 1, 0).placed === true && toggleTent(g, 1, 0).placed === false); + + // Wrong tent (not beside any tree). + toggleTent(g, 3, 3); + let d = diagnose(g); + check('lonely tent flagged', d.badTents.has(keyOf(3, 3))); + + // Two tents touching. + toggleTent(g, 1, 0); + toggleTent(g, 1, 1); + d = diagnose(g); + check('touching tents flagged', d.badTents.has(keyOf(1, 0)) && d.badTents.has(keyOf(1, 1))); + + // Over-count a row: row 0 count is 1; add a second tent in row 0. + g.tents.add(keyOf(3, 0)); + d = diagnose(g); + check('overfull row flagged', d.badRows.has(0)); + + check('not solved while broken', isSolved(g) === false); + + // Solve it exactly. + g.tents.clear(); + for (const [c, r] of puzzle.solution) g.tents.add(keyOf(c, r)); + d = diagnose(g); + check('solution has no violations', + d.badTents.size + d.badTrees.size + d.badRows.size + d.badCols.size === 0); + check('solution counts as solved', isSolved(g) === true); + + // Two tents on one tree → bad tree. + g.tents.add(keyOf(1, 1)); + d = diagnose(g); + check('double-claimed tree flagged', d.badTrees.has(keyOf(0, 1))); + check('no longer solved', isSolved(g) === false); +} + +// ── Generation soak ────────────────────────────────────────────────────────── + +console.log('[verify] generation'); + +for (const key of DIFFICULTY_ORDER) { + const def = DIFFICULTIES[key]; + const t0 = Date.now(); + let puzzles = 0; + for (let i = 0; i < 12; i++) { + const p = generatePuzzle(key); + puzzles++; + // Independent re-verification. + // Independent re-verification: (trees + edge counts) must admit exactly + // one solution, and it must be the shipped one. + const sols = countSolutions(p.trees, p.size, { limit: 2, rowCounts: p.rowCounts, colCounts: p.colCounts }); + const okUnique = sols.length === 1 && JSON.stringify(sols[0]) === JSON.stringify(p.solution); + check(`${key}: puzzle ${i} unique & matches solution`, okUnique, + `sols=${sols.length}`); + const d = (function () { + const g = newGame(p); + for (const [c, r] of p.solution) g.tents.add(keyOf(c, r)); + return { g, d: diagnose(g) }; + })(); + check(`${key}: puzzle ${i} solution is valid`, + d.d.badTents.size + d.d.badTrees.size + d.d.badRows.size + d.d.badCols.size === 0 + && isSolved(d.g)); + check(`${key}: puzzle ${i} has ${def.trees} trees`, p.trees.length === def.trees, + `got ${p.trees.length}`); + check(`${key}: puzzle ${i} counts sum to tree count`, + p.rowCounts.reduce((a, b) => a + b, 0) === p.trees.length + && p.colCounts.reduce((a, b) => a + b, 0) === p.trees.length); + } + const ms = Date.now() - t0; + console.log(` · ${key}: ${puzzles} puzzles in ${ms} ms (${(ms / 12).toFixed(1)} ms avg)`); +} + +console.log(`\n[verify] ${passes} passed, ${failures} failed`); +process.exit(failures ? 1 : 0);