From af6ea9852863ec91c18870c5eb0f28d4b0b8246c Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Thu, 27 Aug 2026 20:51:26 -0600 Subject: [PATCH] Add Tents & Trees logic puzzle game MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Implement full Tents & Trees scene (TentsGame.js) with day-to-night sky transition, campfire/ember/firefly effects, hover hints, violation feedback, and a win panel - Add pure board model (TentsLogic.js) with backtracking solver, unique-solution puzzle generation across four difficulties, toggle/ diagnose/solved helpers — all unit-testable in Node - Register the game in gamesRegistry, main scene list, and slug dispatch - Update game-icons.png with a new icon frame for Tents & Trees - Add tools/verifyTents.js (solver + play-state unit tests and generation soak) and tools/smokeTents.js (Phaser-stub smoke test driving the full select → play → solve → win → restart flow) --- assets/images/game-icons.png | Bin 347099 -> 347453 bytes src/data/gamesRegistry.js | 1 + src/games/tents/TentsGame.js | 937 ++++++++++++++++++++++++++++++++++ src/games/tents/TentsLogic.js | 274 ++++++++++ src/main.js | 2 + src/scenes/GameRoomScene.js | 2 +- tools/smokeTents.js | 168 ++++++ tools/verifyTents.js | 178 +++++++ 8 files changed, 1561 insertions(+), 1 deletion(-) create mode 100644 src/games/tents/TentsGame.js create mode 100644 src/games/tents/TentsLogic.js create mode 100644 tools/smokeTents.js create mode 100644 tools/verifyTents.js diff --git a/assets/images/game-icons.png b/assets/images/game-icons.png index e2c6f9c5d44fbda1e4e10ad3d77809d22cff304e..f1d5f99477085d53133a0d4522c363d971e24db0 100644 GIT binary patch delta 20645 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 Yr`q~}T61G*ql`41FSwn@Ib2WqAK~A%KmY&$ 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); -- 2.40.1