From d27244ac60f0505963ccbbb57f56b8143d77973a Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Mon, 24 Aug 2026 21:04:27 -0600 Subject: [PATCH] Rework Pipe Puzzle to branching network with no-leak win condition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the single Hamiltonian-path model with a spanning-tree + extra-edges approach that produces boards full of T-pieces, crosses and dead ends. The win condition is now simply "zero leaks" — every socket matched to a neighbour that opens back — instead of requiring one continuous path from faucet to drain. Key changes: - Logic: generate puzzles via randomized Kruskal spanning tree plus tunable extra edges per difficulty; add bitCount, countLeaks, and derived angleFor/canonical rotation table - Art: new tile textures for stub (dead end), T-piece, and cross; tileKeyFor and angleFor now handle all five piece types - Game: faucet and drain are fixed anchors (not rotatable); removed the CONNECTED counter in favour of a simpler LEAKS stat; updated difficulty tiers (6×6 through 10×10) with extra-edge counts; improved water rendering contrast - Tests: rewrite verify script for the new generation model (spanning tree, connectivity, piece mix, no-leak solution); update smoke test to skip anchor tiles - Tutorial: updated rules, board reading guide, and tips for the branching network variant --- src/games/pipepuzzle/PipePuzzleArt.js | 120 ++++++++-- src/games/pipepuzzle/PipePuzzleGame.js | 88 ++++--- src/games/pipepuzzle/PipePuzzleLogic.js | 298 +++++++++++++----------- src/games/pipepuzzle/tutorial.md | 67 +++--- tools/smokePipePuzzle.cjs | 6 +- tools/verifyPipePuzzle.js | 117 +++++----- 6 files changed, 407 insertions(+), 289 deletions(-) diff --git a/src/games/pipepuzzle/PipePuzzleArt.js b/src/games/pipepuzzle/PipePuzzleArt.js index 6455237..6120fbe 100644 --- a/src/games/pipepuzzle/PipePuzzleArt.js +++ b/src/games/pipepuzzle/PipePuzzleArt.js @@ -10,11 +10,14 @@ // Tiles are painted in a canonical orientation and rotated: // • straight — painted horizontal (W–E) // • elbow — painted N–E (stub from the top edge into the corner) +// • stub — painted socket-N (a dead end: stub from the top edge) +// • t — painted N|E|W (the open mouth points S) +// • cross — painted all four (rotation-invariant) // • source — painted socket-N (stub up, brass faucet body below) // • drain — painted socket-N (stub up, iron grate below) // See `angleFor(sockets)` for the rotation table. -import { N, E, S, W } from './PipePuzzleLogic.js'; +import { N, E, S, W, bitCount, rotateSockets } from './PipePuzzleLogic.js'; export const TILE_PX = 320; const PW = TILE_PX * 0.30; // pipe width @@ -197,6 +200,81 @@ function paintElbow(ctx) { } } +function paintStub(ctx) { + const cx = TILE_PX / 2; + plate(ctx); + pipeV(ctx, cx, 0, cx); + edgeCollar(ctx, 'N', cx, true); + // Rounded cap + end ring at the dead end. + const r = PW * 0.5; + ctx.beginPath(); ctx.arc(cx, cx, r, 0, Math.PI * 2); + ctx.fillStyle = darken(STEEL, 0.25); ctx.fill(); + ctx.strokeStyle = OUTLINE; ctx.lineWidth = 7; ctx.stroke(); + const g = ctx.createRadialGradient(cx - r * 0.4, cx - r * 0.4, r * 0.15, cx, cx, r * 0.7); + g.addColorStop(0, lighten(STEEL, 0.35)); + g.addColorStop(1, darken(STEEL, 0.35)); + ctx.fillStyle = g; + ctx.beginPath(); ctx.arc(cx, cx, r * 0.62, 0, Math.PI * 2); ctx.fill(); + ctx.strokeStyle = OUTLINE; ctx.lineWidth = 4; ctx.stroke(); +} + +function paintT(ctx) { + const cx = TILE_PX / 2; + plate(ctx); + pipeV(ctx, cx, 0, TILE_PX / 2); // N stub + pipeH(ctx, cx, 0, TILE_PX); // W–E through the centre (full width) + edgeCollar(ctx, 'N', cx, true); + edgeCollar(ctx, 'W', cx, false); + edgeCollar(ctx, 'E', cx, false); + // Central tee body over the junction. + const r = PW * 0.78; + const g = ctx.createRadialGradient(cx - r * 0.35, cx - r * 0.35, r * 0.2, cx, cx, r * 1.1); + g.addColorStop(0, lighten(STEEL, 0.4)); + g.addColorStop(0.7, STEEL); + g.addColorStop(1, darken(STEEL, 0.42)); + ctx.fillStyle = g; + ctx.beginPath(); ctx.arc(cx, cx, r, 0, Math.PI * 2); ctx.fill(); + ctx.strokeStyle = OUTLINE; ctx.lineWidth = 7; ctx.stroke(); + // Bolts on the three open arms (N, W, E). + for (const a of [Math.PI * 1.5, Math.PI, 0]) { + const px = cx + Math.cos(a) * r * 0.66; + const py = cx + Math.sin(a) * r * 0.66; + ctx.beginPath(); ctx.arc(px, py, 8, 0, Math.PI * 2); + ctx.fillStyle = '#2c333d'; ctx.fill(); + ctx.beginPath(); ctx.arc(px, py, 3.6, 0, Math.PI * 2); + ctx.fillStyle = '#b7c2cf'; ctx.fill(); + } +} + +function paintCross(ctx) { + const cx = TILE_PX / 2; + plate(ctx); + pipeH(ctx, cx, 0, TILE_PX); + pipeV(ctx, cx, 0, TILE_PX); + edgeCollar(ctx, 'N', cx, true); + edgeCollar(ctx, 'S', cx, true); + edgeCollar(ctx, 'W', cx, false); + edgeCollar(ctx, 'E', cx, false); + // Big cross fitting over the centre. + const r = PW * 0.8; + const g = ctx.createRadialGradient(cx - r * 0.35, cx - r * 0.35, r * 0.2, cx, cx, r * 1.05); + g.addColorStop(0, lighten(STEEL, 0.45)); + g.addColorStop(0.6, STEEL); + g.addColorStop(1, darken(STEEL, 0.45)); + ctx.fillStyle = g; + ctx.beginPath(); ctx.arc(cx, cx, r, 0, Math.PI * 2); ctx.fill(); + ctx.strokeStyle = OUTLINE; ctx.lineWidth = 8; ctx.stroke(); + // Bolts on the four arms. + for (const a of [0, Math.PI / 2, Math.PI, Math.PI * 1.5]) { + const px = cx + Math.cos(a) * r * 0.68; + const py = cx + Math.sin(a) * r * 0.68; + ctx.beginPath(); ctx.arc(px, py, 9, 0, Math.PI * 2); + ctx.fillStyle = '#2c333d'; ctx.fill(); + ctx.beginPath(); ctx.arc(px, py, 4, 0, Math.PI * 2); + ctx.fillStyle = '#b7c2cf'; ctx.fill(); + } +} + function paintSource(ctx) { const cx = TILE_PX / 2; plate(ctx); @@ -304,23 +382,32 @@ function paintDot(ctx) { export function tileKeyFor(sockets, isSource, isDrain) { if (isSource) return 'pp-tile-source'; if (isDrain) return 'pp-tile-drain'; - const straight = (sockets & N && sockets & S) || (sockets & E && sockets & W); - return straight ? 'pp-tile-straight' : 'pp-tile-elbow'; + switch (bitCount(sockets)) { + case 1: return 'pp-tile-stub'; + case 2: return ((sockets & N && sockets & S) || (sockets & E && sockets & W)) ? 'pp-tile-straight' : 'pp-tile-elbow'; + case 3: return 'pp-tile-t'; + default: return 'pp-tile-cross'; + } } -// Rotation (degrees, clockwise) for a tile painted in its canonical pose. +// The socket mask each texture is baked in (see the paint* functions above). +const CANONICAL = { + 'pp-tile-stub': N, + 'pp-tile-straight': E | W, + 'pp-tile-elbow': N | E, + 'pp-tile-t': N | E | W, // open mouth faces S + 'pp-tile-cross': N | E | S | W, +}; + +// Rotation (degrees, clockwise) for a tile painted in its canonical pose: +// the unique k ∈ 0..3 with rotate(canonical, k) === current sockets. This is +// derived rather than tabulated, so every piece type is provably correct. export function angleFor(sockets) { - if (sockets === (N | E)) return 0; - if (sockets === (E | S)) return 90; - if (sockets === (S | W)) return 180; - if (sockets === (W | N)) return 270; - if (sockets === (E | W)) return 0; - if (sockets === (N | S)) return 90; - if (sockets === N) return 0; - if (sockets === E) return 90; - if (sockets === S) return 180; - if (sockets === W) return 270; - return 0; + const canonical = CANONICAL[tileKeyFor(sockets, false, false)]; + for (let k = 0; k < 4; k++) { + if (rotateSockets(canonical, k) === sockets) return k * 90; + } + return 0; // cross — rotation-invariant } // Bake every texture the game needs (idempotent — guarded by textures.exists). @@ -328,6 +415,9 @@ export function ensureTileTextures(scene) { const jobs = [ ['pp-tile-straight', paintStraight], ['pp-tile-elbow', paintElbow], + ['pp-tile-stub', paintStub], + ['pp-tile-t', paintT], + ['pp-tile-cross', paintCross], ['pp-tile-source', paintSource], ['pp-tile-drain', paintDrain], ]; diff --git a/src/games/pipepuzzle/PipePuzzleGame.js b/src/games/pipepuzzle/PipePuzzleGame.js index 42a5213..fc2c7fb 100644 --- a/src/games/pipepuzzle/PipePuzzleGame.js +++ b/src/games/pipepuzzle/PipePuzzleGame.js @@ -3,8 +3,8 @@ // One screen (difficulty select) and one play screen, the same structure as // Katamino. The board is a grid of baked tile textures (PipePuzzleArt.js) // that the player rotates; water flows out from the faucet through every -// matched socket and is drawn live as an animated dashed stream. Win = every -// cell connected to the faucet with zero leaks. +// matched socket and is drawn live as an animated stream. WIN = no leaks: +// every socket on every tile is matched to a neighbour that opens back. import * as Phaser from 'phaser'; import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js'; @@ -15,14 +15,14 @@ import { ensureTileTextures, angleFor, tileKeyFor, TILE_PX } from './PipePuzzleA import { N, E, S, W, DIRS, OPP, cellRC, matchedDirs, neighborOf, - generatePuzzle, rotateAt, isSolved, wetOrder, + generatePuzzle, rotateAt, isSolved, wetOrder, countLeaks, DIFFICULTIES, difficultyByKey, } from './PipePuzzleLogic.js'; const D = { bg: -2, board: 0, tile: 2, water: 4, flash: 6, ui: 20, overlay: 60, overlayUI: 62 }; // Per-difficulty par times (seconds) for the 3-star rating. -const PAR = { easy: 45, medium: 90, hard: 180, legendary: 360 }; +const PAR = { easy: 60, medium: 100, hard: 150, expert: 260 }; const bestKey = (diff) => `pipepuzzle-best-${diff}`; function getBest(diff) { @@ -46,7 +46,7 @@ export default class PipePuzzleGame extends Phaser.Scene { init() { this._screen = null; // 'select' | 'play' this._diff = null; // difficulty key - this._board = null; // { n, sockets, solution, source, drain, path } + this._board = null; // { n, sockets, solution, source, drain } this._cells = null; // array of tile images this._hover = null; this._time0 = null; // performance timestamp of first move @@ -134,7 +134,7 @@ export default class PipePuzzleGame extends Phaser.Scene { }).setOrigin(0.5); title.postFX.addShadow(0, 6, 0.004, 1.4, 0x000000, 10, 0.8); const sub = this.add.text(GAME_WIDTH / 2, 205, - 'Route the water: spin the pipes so every tile flows from the faucet to the drain — no leaks.', + 'Spin the pipes until the whole network is sealed — every open end must meet a neighbour. No leaks allowed.', { fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.mutedHex, align: 'center', wordWrap: { width: 1100 } } ).setOrigin(0.5); sc.add([title, sub]); @@ -149,9 +149,9 @@ export default class PipePuzzleGame extends Phaser.Scene { const left = (GAME_WIDTH - totalW) / 2; const MINI = [ { tex: 'pp-tile-elbow', angle: 0 }, - { tex: 'pp-tile-straight', angle: 0 }, - { tex: 'pp-tile-source', angle: 180 }, - { tex: 'pp-tile-drain', angle: 180 }, + { tex: 'pp-tile-t', angle: 180 }, + { tex: 'pp-tile-cross', angle: 0 }, + { tex: 'pp-tile-stub', angle: 0 }, ]; DIFFICULTIES.forEach((diff, i) => { @@ -183,7 +183,7 @@ export default class PipePuzzleGame extends Phaser.Scene { gfx.lineStyle(2, 0x4a5568, 1); gfx.strokeRoundedRect(cx - w / 2, cy - h / 2, w, h, 18); // Top accent bar in the difficulty's hue. - const HUES = { easy: 0x3fae62, medium: 0xc8a84b, hard: 0xd07b3a, legendary: 0xc2555f }; + const HUES = { easy: 0x3fae62, medium: 0xc8a84b, hard: 0xd07b3a, expert: 0xc2555f }; gfx.fillStyle(HUES[diff.key], 1); gfx.fillRoundedRect(cx - w / 2 + 18, cy - h / 2 + 14, w - 36, 6, 3); sc.add(gfx); @@ -240,7 +240,7 @@ export default class PipePuzzleGame extends Phaser.Scene { this._screen = 'play'; this._diff = diffKey; const diff = difficultyByKey(diffKey); - this._board = generatePuzzle(diff.n); + this._board = generatePuzzle(diff.n, diff.extra ?? 0); this._moves = 0; this._time0 = null; this._won = false; @@ -285,17 +285,21 @@ export default class PipePuzzleGame extends Phaser.Scene { const r = Math.floor(i / n), c = i % n; const isSource = i === this._board.source; const isDrain = i === this._board.drain; + const isAnchor = isSource || isDrain; // faucet & drain are fixed const key = tileKeyFor(this._board.sockets[i], isSource, isDrain); const img = this.add.image(bx + c * CELL + CELL / 2, by + r * CELL + CELL / 2, key) .setDisplaySize(CELL, CELL) .setAngle(angleFor(this._board.sockets[i])) - .setDepth(D.tile) - .setInteractive({ useHandCursor: true }); + .setDepth(D.tile); img._idx = i; + img._anchor = isAnchor; img._targetAngle = angleFor(this._board.sockets[i]); - img.on('pointerover', () => this._setHover(i, true)); - img.on('pointerout', () => this._setHover(i, false)); - img.on('pointerdown', () => this._rotateCell(i)); + if (!isAnchor) { + img.setInteractive({ useHandCursor: true }); + img.on('pointerover', () => this._setHover(i, true)); + img.on('pointerout', () => this._setHover(i, false)); + img.on('pointerdown', () => this._rotateCell(i)); + } this._cells.push(img); sc.add(img); } @@ -330,11 +334,9 @@ export default class PipePuzzleGame extends Phaser.Scene { sc.add([l, v]); return v; }; - this._timeText = makeStat('TIME', '0:00', GAME_WIDTH - 760); - this._movesText = makeStat('MOVES', '0', GAME_WIDTH - 660); - const conn = makeStat('CONNECTED', '0 / ' + n * n, GAME_WIDTH - 520); - const leak = makeStat('LEAKS', '0', GAME_WIDTH - 390); - this._connText = conn; this._leakText = leak; + this._timeText = makeStat('TIME', '0:00', GAME_WIDTH - 700); + this._movesText = makeStat('MOVES', '0', GAME_WIDTH - 560); + this._leakText = makeStat('LEAKS', '0', GAME_WIDTH - 400); // Footer hint. const hint = this.add.text(GAME_WIDTH / 2, GAME_HEIGHT - 34, @@ -365,8 +367,9 @@ export default class PipePuzzleGame extends Phaser.Scene { } } - _rotateCell(i) { + _rotateCell(i, checkWin = true) { if (this._screen !== 'play' || this._won) return; + if (i === this._board.source || i === this._board.drain) return; // anchors are fixed if (this._time0 == null) this._time0 = this.time.now; this._moves++; rotateAt(this._board, i); @@ -386,28 +389,15 @@ export default class PipePuzzleGame extends Phaser.Scene { this._updateStats(); this._drawWater(); - if (isSolved(this._board)) { + if (checkWin && isSolved(this._board)) { this._won = true; this._winSequence(); } } _updateStats() { - const { n, sockets, source } = this._board; - const wet = wetOrder(sockets, n, source).length; - let leaks = 0; - const wetSet = new Set(wetOrder(sockets, n, source)); - for (const i of wetSet) { - const [r, c] = cellRC(i, n); - for (const d of DIRS) { - if (!(sockets[i] & d)) continue; - const [dr, dc] = { [N]: [-1, 0], [S]: [1, 0], [E]: [0, 1], [W]: [0, -1] }[d]; - const nr = r + dr, nc = c + dc; - if (nr < 0 || nr >= n || nc < 0 || nc >= n) { leaks++; continue; } - if (!(sockets[nr * n + nc] & OPP[d])) leaks++; - } - } - this._connText.setText(`${wet} / ${n * n}`); + const { n, sockets } = this._board; + const leaks = countLeaks(sockets, n); this._leakText.setText(String(leaks)); this._leakText.setColor(leaks > 0 ? '#e06c75' : '#3fae62'); this._movesText.setText(String(this._moves)); @@ -430,39 +420,39 @@ export default class PipePuzzleGame extends Phaser.Scene { const cy = (i) => by + Math.floor(i / n) * CELL + CELL / 2; const EDGE = { [N]: [0, -1], [S]: [0, 1], [E]: [1, 0], [W]: [-1, 0] }; - // 1) Wet-cell pools. + // 1) Wet-cell pools — a vivid water-blue fill so connected pipes clearly + // read as "full of water". for (const i of order) { const x = bx + (i % n) * CELL, y = by + Math.floor(i / n) * CELL; - const a = 0.16 + Math.min(0.3, boost * 0.25); - gfx.fillStyle(0x3fa0dc, a); - gfx.fillRoundedRect(x + 7, y + 7, CELL - 14, CELL - 14, CELL * 0.16); + const a = 0.34 + Math.min(0.42, boost * 0.3); + gfx.fillStyle(0x1f8fdd, a); + gfx.fillRoundedRect(x + 5, y + 5, CELL - 10, CELL - 10, CELL * 0.16); } // 2) Water flow along matched edges: a translucent base line plus a bright // travelling pulse. The pulse always marches toward the drain (the // direction water flows) rather than toward the source. - const EDGE2 = { [N]: [0, -1], [S]: [0, 1], [E]: [1, 0], [W]: [-1, 0] }; const period = CELL * 1.4; const off = (this.time.now * 0.12) % period; - const baseA = 0.42 + Math.min(0.25, boost * 0.25); - const pulseA = 0.85; + const baseA = 0.6 + Math.min(0.3, boost * 0.3); + const pulseA = 0.95; for (const i of order) { for (const d of matchedDirs(sockets, n, i)) { const j = neighborOf(i, n, d); if (!orderIdx.has(j)) continue; const towardDrain = orderIdx.get(j) > orderIdx.get(i); - const [ex, ey] = EDGE2[d]; + const [ex, ey] = EDGE[d]; const x0 = cx(i), y0 = cy(i); const x1 = x0 + ex * CELL / 2, y1 = y0 + ey * CELL / 2; // Base water line (translucent so pipe still reads through). - gfx.lineStyle(Math.max(10, CELL * 0.24), 0x2f7fb8, baseA * 0.8); + gfx.lineStyle(Math.max(12, CELL * 0.3), 0x2f9bdf, baseA * 0.9); gfx.lineBetween(x0, y0, x1, y1); // Travelling bright pulse. const u = (off / period); // 0→1 along the edge const px = x0 + (x1 - x0) * (towardDrain ? u : 1 - u); const py = y0 + (y1 - y0) * (towardDrain ? u : 1 - u); - gfx.lineStyle(Math.max(6, CELL * 0.14), 0xaee6ff, pulseA); - gfx.lineBetween(px - (x1 - x0) * 0.10, py - (y1 - y0) * 0.10, px + (x1 - x0) * 0.10, py + (y1 - y0) * 0.10); + gfx.lineStyle(Math.max(8, CELL * 0.18), 0xc9f1ff, pulseA); + gfx.lineBetween(px - (x1 - x0) * 0.12, py - (y1 - y0) * 0.12, px + (x1 - x0) * 0.12, py + (y1 - y0) * 0.12); } } diff --git a/src/games/pipepuzzle/PipePuzzleLogic.js b/src/games/pipepuzzle/PipePuzzleLogic.js index 86707c1..be672a0 100644 --- a/src/games/pipepuzzle/PipePuzzleLogic.js +++ b/src/games/pipepuzzle/PipePuzzleLogic.js @@ -1,20 +1,25 @@ // Pipe Puzzle — pure game logic (no Phaser, runs in Node for verification). // -// Strict "all-tiles-connected" variant: -// • The board is an N×N grid, **every cell holds a pipe tile**. -// • Exactly two special 1-socket tiles: a SOURCE (faucet) and a DRAIN. -// • Every other tile is a 2-socket STRAIGHT or ELBOW. -// • The solved state is a single continuous, leak-free pipe path that runs -// from the faucet to the drain and visits every cell exactly once — a -// Hamiltonian path. So "every tile is connected" and "no leaks" fall out -// of one clean condition. -// • The puzzle is generated by building a random Hamiltonian path, orienting -// every tile along it (the solution), then randomly rotating the tiles. -// It is therefore always solvable. -// -// Directions / sockets -// Bit flags per socket: N=1, E=2, S=4, W=8. A tile's `sockets` value is the -// OR of the sockets it currently has. `rotateSockets` turns it clockwise. +// "No leaks" variant: +// • N×N grid, EVERY cell holds a pipe tile (no empty squares). +// • A board is a set of EDGES between adjacent cells. A cell's degree = +// how many sockets it has: +// 1 → stub (dead end) 2 → straight or elbow +// 3 → T-piece 4 → cross +// plus two fixed anchors the player cannot rotate: +// source — the faucet (degree 1) +// drain — the drain (degree 1) +// • Generation: a random SPANNING TREE (touches every cell, so no empty +// squares and the board is connected) plus a tunable number of EXTRA +// edges, which create cycles and raise cell degrees so the board is full +// of T-pieces, crosses and branching — a maze, not a single line. +// • Always solvable: the solved pose (each cell's sockets pointing at its +// neighbours in the edge set) is leak-free by construction, and every tile +// shape (stub/straight/elbow/T/cross) can be rotated to any orientation of +// that shape, so that pose is always reachable by the player. +// • Scramble = rotate every non-anchor tile by a random multiple of 90°. +// • WIN = no leaks anywhere: every socket is matched to a neighbour that +// opens back. There can be many valid solutions — you just need to find one. // ── Directions ─────────────────────────────────────────────────────────────── export const N = 1, E = 2, S = 4, W = 8; @@ -22,10 +27,6 @@ export const DIRS = [N, E, S, W]; export const OPP = { [N]: S, [S]: N, [E]: W, [W]: E }; export const DELTA = { [N]: [-1, 0], [S]: [1, 0], [E]: [0, 1], [W]: [0, -1] }; -// Tile kinds (used by the renderer for art; the socket mask is the source of -// truth for connectivity). -export const TILE = { SOURCE: 'source', DRAIN: 'drain', STRAIGHT: 'straight', ELBOW: 'elbow' }; - // ── Random helpers ─────────────────────────────────────────────────────────── export function randInt(maxExclusive) { return Math.floor(Math.random() * maxExclusive); } export function shuffle(arr) { @@ -47,6 +48,14 @@ export function neighborOf(i, n, d) { const [dr, dc] = DELTA[d]; return (r + dr) * n + (c + dc); } +export const cellKey = (r, c, n) => r * n + c; +function dirBetween(a, b, n) { + const [ar, ac] = cellRC(a, n), [br, bc] = cellRC(b, n); + if (br === ar - 1) return N; + if (br === ar + 1) return S; + if (bc === ac - 1) return W; + return E; +} // Directions of i whose sockets are matched by the neighbor (water can flow there). export function matchedDirs(sockets, n, i) { const [r, c] = cellRC(i, n); @@ -60,19 +69,6 @@ export function matchedDirs(sockets, n, i) { } return out; } -export const cellKey = (r, c, n) => r * n + c; -export function gridAdjacent(a, b, n) { - const [ar, ac] = cellRC(a, n), [br, bc] = cellRC(b, n); - return Math.abs(ar - br) + Math.abs(ac - bc) === 1; -} -// Direction bit from cell a toward adjacent cell b. -function dirBetween(a, b, n) { - const [ar, ac] = cellRC(a, n), [br, bc] = cellRC(b, n); - if (br === ar - 1) return N; - if (br === ar + 1) return S; - if (bc === ac - 1) return W; - return E; -} // ── Socket algebra ─────────────────────────────────────────────────────────── // Rotate a socket mask `rot` steps clockwise (N→E→S→W→N). @@ -88,107 +84,136 @@ export function rotateSockets(sock, rot) { } return sock; } +export function bitCount(x) { let c = 0; while (x) { x &= x - 1; c++; } return c; } -// ── Hamiltonian path generation ────────────────────────────────────────────── -// A guaranteed-valid snake (boustrophedon) path covering every cell. -function snakePath(n) { - const horizontal = Math.random() < 0.5; - const path = []; - if (horizontal) { - for (let r = 0; r < n; r++) { - for (let c = 0; c < n; c++) path.push(cellKey(r, (r % 2 === 0) ? c : n - 1 - c, n)); - } - } else { +// ── Generation ─────────────────────────────────────────────────────────────── +// Every possible edge in the n×n grid (each once): down and right neighbours. +function allEdges(n) { + const edges = []; + for (let r = 0; r < n; r++) { for (let c = 0; c < n; c++) { - for (let r = 0; r < n; r++) path.push(cellKey((c % 2 === 0) ? r : n - 1 - r, c, n)); + const i = r * n + c; + if (r + 1 < n) edges.push([i, i + n]); + if (c + 1 < n) edges.push([i, i + 1]); } } - if (Math.random() < 0.5) path.reverse(); - return path; + return edges; } -// Randomize a Hamiltonian path with "2-switch" (detour) moves. -// -// Pick two path edges (a→b) and (c→d) with a non-trivial segment between -// them; if a~c and b~d are both valid grid adjacencies, reroute to -// a→c … d→b by reversing the middle segment. This is the standard -// Hamiltonian-path improvement move: it keeps the path a permutation of all -// cells (nothing is duplicated or dropped) and preserves every adjacency, -// so the result is always a valid Hamiltonian path — the puzzle stays -// solvable by construction. -// -// On a snake this produces detours that weave between rows/columns, giving -// each puzzle a distinct shape and distinct source/drain cells. -function randomizePath(path, n, attempts = 600) { - const len = path.length; - for (let t = 0; t < attempts; t++) { - let p = randInt(len - 1); - let q = randInt(len - 1); - if (p > q) [p, q] = [q, p]; - if (q - p < 1) continue; // need at least one cell between the edges - const a = path[p], b = path[p + 1], c = path[q], d = path[q + 1]; - if (gridAdjacent(a, c, n) && gridAdjacent(b, d, n)) { - const left = path.slice(0, p + 1); // … a - const mid = path.slice(p + 1, q + 1); // b … c - const right = path.slice(q + 1); // d … - path = left.concat(mid.reverse(), right); +// Random spanning tree via randomized Kruskal. Returns `tree[i]` = socket mask +// of cell i in the solved pose (a bit set for each tree-neighbour direction). +function randomSpanningTree(n) { + const N2 = n * n; + const edges = allEdges(n); + shuffle(edges); + const parent = new Array(N2); + for (let i = 0; i < N2; i++) parent[i] = i; + const find = (x) => { while (parent[x] !== x) { parent[x] = parent[parent[x]]; x = parent[x]; } return x; }; + const tree = new Array(N2).fill(0); + let count = 0; + for (const [a, b] of edges) { + const ra = find(a), rb = find(b); + if (ra === rb) continue; // would form a cycle + parent[ra] = rb; + const d = dirBetween(a, b, n); + tree[a] |= d; + tree[b] |= OPP[d]; + if (++count === N2 - 1) break; + } + return tree; +} + +// A good base: at least a couple of branch points (T/cross) and a few dead +// ends so the board reads as a maze. +function goodBase(n, tree) { + let leaves = 0, branch = 0; + for (let i = 0; i < tree.length; i++) { + const d = bitCount(tree[i]); + if (d === 1) leaves++; + else if (d >= 3) branch++; + } + return branch >= 2 && leaves >= 3; +} + +// Pick two far-apart degree-1 cells to be the faucet & drain. +function pickAnchorPair(n, sockets) { + const leaves = []; + for (let i = 0; i < sockets.length; i++) if (bitCount(sockets[i]) === 1) leaves.push(i); + if (leaves.length < 2) { + // Fallback (shouldn't happen for a connected board): use two corners. + return [0, sockets.length - 1]; + } + let bestA = leaves[0], bestB = leaves[1], bestD = -1; + for (let a = 0; a < leaves.length; a++) { + for (let b = a + 1; b < leaves.length; b++) { + const [ar, ac] = cellRC(leaves[a], n), [br, bc] = cellRC(leaves[b], n); + const d = Math.abs(ar - br) + Math.abs(ac - bc); + if (d > bestD) { bestD = d; bestA = leaves[a]; bestB = leaves[b]; } } } - if (Math.random() < 0.5) path.reverse(); - return path; + return Math.random() < 0.5 ? [bestA, bestB] : [bestB, bestA]; } -export function randomHamiltonianPath(n) { - return randomizePath(snakePath(n), n); -} - -// ── Puzzle construction ────────────────────────────────────────────────────── -// Orient every tile along the path (the solution), then scramble. -export function generatePuzzle(n) { - const path = randomHamiltonianPath(n); - const total = path.length; - - // Solution: sockets per cell, following the path. - const solution = new Array(total).fill(0); - for (let i = 0; i < total; i++) { - let sock = 0; - if (i > 0) sock |= dirBetween(path[i], path[i - 1], n); - if (i < total - 1) sock |= dirBetween(path[i], path[i + 1], n); - solution[path[i]] = sock; +// Does the final board have a good mix of interesting pieces? +function hasGoodMix(n, sockets) { + let tc = 0, stubs = 0; + for (let i = 0; i < sockets.length; i++) { + const d = bitCount(sockets[i]); + if (d >= 3) tc++; + else if (d === 1) stubs++; } + return tc >= 3 && stubs >= 3; +} - const source = path[0]; - const drain = path[total - 1]; +export function generatePuzzle(n, extra = 0) { + let base = null, tries = 0; + do { base = randomSpanningTree(n); tries++; } while (!goodBase(n, base) && tries < 300); - // Scramble: random rotation of every tile (source & drain included). - const sockets = solution.map((s) => (s === 0 ? 0 : rotateSockets(s, randInt(4)))); + // Add `extra` random edges (not already in the tree) to create cycles and + // raise degrees → more T-pieces and crosses. + const treeEdges = new Set(); + for (let i = 0; i < n * n; i++) { + for (const d of [E, S]) { // each edge once + if (!(base[i] & d)) continue; + const [dr, dc] = { [E]: [0, 1], [S]: [1, 0] }[d]; + const j = (Math.floor(i / n) + dr) * n + (i % n + dc); + treeEdges.add(Math.min(i, j) * 10000 + Math.max(i, j)); + } + } + const candidates = allEdges(n).filter(([a, b]) => !treeEdges.has(Math.min(a, b) * 10000 + Math.max(a, b))); - // If the scramble happened to produce the solved board (only possible for a - // 1-socket/2-socket board when rotations coincide), nudge one interior tile. - if (isSolved({ n, sockets, source, drain })) { - for (let i = 0; i < total; i++) { + let sockets, mixTries = 0; + do { + sockets = base.slice(); + const picked = shuffle(candidates).slice(0, extra); + for (const [a, b] of picked) { + const d = dirBetween(a, b, n); + sockets[a] |= d; + sockets[b] |= OPP[d]; + } + mixTries++; + } while (!hasGoodMix(n, sockets) && mixTries < 300); + + const [source, drain] = pickAnchorPair(n, sockets); + + // Scramble non-anchor tiles (anchors stay fixed). + const scrambled = sockets.map((s, i) => (i === source || i === drain) ? s : rotateSockets(s, randInt(4))); + + // If the scramble happened to land on a leak-free board, nudge one tile. + if (countLeaks(scrambled, n) === 0) { + for (let i = 0; i < n * n; i++) { if (i === source || i === drain) continue; - if (sockets[i] !== 0 && sockets[i] !== solution[i]) break; - // rotate a tile whose solution orientation is not its only option - if (countBits(sockets[i]) === 2) { sockets[i] = rotateSockets(sockets[i], 1); break; } + if (bitCount(scrambled[i]) >= 2) { scrambled[i] = rotateSockets(scrambled[i], 1); break; } } } - return { n, sockets, solution, source, drain, path }; + return { n, sockets: scrambled, solution: sockets, source, drain }; } // ── Board queries ──────────────────────────────────────────────────────────── -function countBits(x) { let c = 0; while (x) { x &= x - 1; c++; } return c; } - export function isSpecial(i, source, drain) { return i === source || i === drain; } -// Set of cell indices reachable from `start` through *matched* sockets -// (a socket counts only when the neighbor opens back). This is the wet set. -export function wetCells(sockets, n, start) { - return new Set(wetOrder(sockets, n, start)); -} - -// BFS order of wet cells from the source — used for the win "wave". +// BFS order of wet cells from `start` through matched sockets (the "wave"). export function wetOrder(sockets, n, start) { const seen = new Set([start]); const order = [start]; @@ -211,36 +236,33 @@ export function wetOrder(sockets, n, start) { } return order; } +export function wetCells(sockets, n, start) { return new Set(wetOrder(sockets, n, start)); } -// True if cell i has at least one socket that is a leak (points off the board -// or at a neighbor that does not open back). -export function tileHasLeak(sockets, n, i) { - const [r, c] = cellRC(i, n); - for (const d of DIRS) { - if (!(sockets[i] & d)) continue; - const [dr, dc] = DELTA[d]; - const nr = r + dr, nc = c + dc; - if (nr < 0 || nr >= n || nc < 0 || nc >= n) return true; - if (!(sockets[nr * n + nc] & OPP[d])) return true; +// Number of leaking sockets on the whole board. +export function countLeaks(sockets, n) { + let leaks = 0; + for (let i = 0; i < sockets.length; i++) { + if (sockets[i] === 0) continue; + const [r, c] = cellRC(i, n); + for (const d of DIRS) { + if (!(sockets[i] & d)) continue; + const [dr, dc] = DELTA[d]; + const nr = r + dr, nc = c + dc; + if (nr < 0 || nr >= n || nc < 0 || nc >= n) { leaks++; continue; } + if (!(sockets[nr * n + nc] & OPP[d])) leaks++; + } } - return false; + return leaks; } +export function boardHasLeak(sockets, n) { return countLeaks(sockets, n) > 0; } -// Any leak on the board? -export function boardHasLeak(sockets, n) { - for (let i = 0; i < sockets.length; i++) if (sockets[i] !== 0 && tileHasLeak(sockets, n, i)) return true; - return false; -} - -// Solved = every cell is wet (connected to the faucet) AND there are no leaks. +// WIN: no leaks anywhere. export function isSolved(board) { - const { n, sockets, source } = board; - if (wetCells(sockets, n, source).size !== n * n) return false; - if (boardHasLeak(sockets, n)) return false; - return true; + const { n, sockets } = board; + return countLeaks(sockets, n) === 0; } -// Rotate the tile at index i one step clockwise (specials rotate visually too). +// Rotate the tile at index i one step clockwise. export function rotateAt(board, i) { const s = board.sockets[i]; if (s === 0) return board; @@ -249,11 +271,13 @@ export function rotateAt(board, i) { } // ── Difficulty tiers ───────────────────────────────────────────────────────── +// `extra` = number of extra edges added on top of the spanning tree. Higher +// → more T-pieces and crosses, denser and harder to trace. export const DIFFICULTIES = [ - { key: 'easy', label: 'Easy', n: 4, blurb: '4 × 4 grid' }, - { key: 'medium', label: 'Medium', n: 5, blurb: '5 × 5 grid' }, - { key: 'hard', label: 'Hard', n: 6, blurb: '6 × 6 grid' }, - { key: 'legendary', label: 'Legendary', n: 8, blurb: '8 × 8 grid' }, + { key: 'easy', label: 'Easy', n: 6, extra: 5, blurb: '6 × 6 grid' }, + { key: 'medium', label: 'Medium', n: 7, extra: 9, blurb: '7 × 7 grid' }, + { key: 'hard', label: 'Hard', n: 8, extra: 15, blurb: '8 × 8 grid' }, + { key: 'expert', label: 'Expert', n: 10, extra: 26, blurb: '10 × 10 grid' }, ]; export function difficultyByKey(key) { return DIFFICULTIES.find((d) => d.key === key) ?? DIFFICULTIES[0]; diff --git a/src/games/pipepuzzle/tutorial.md b/src/games/pipepuzzle/tutorial.md index 3b971b2..76880c5 100644 --- a/src/games/pipepuzzle/tutorial.md +++ b/src/games/pipepuzzle/tutorial.md @@ -1,18 +1,16 @@ # Pipe Puzzle -A single continuous pipe network must carry water from the **faucet** to the -**drain** — and it has to use *every* pipe on the board. No leaks, no dead -ends. +A branching pipe network must be **sealed**: every open pipe end has to meet +neighbouring pipe. Water enters at the **faucet** and the network is complete +when not a single socket is left open. No leaks allowed. ## The Goal -- Turn the pipes until **every tile is connected** to the faucet through - open, matched pipe ends. -- The whole board must form **one** unbroken pipe: in the solved state the - water flows from the faucet, through every single tile, and out of the - drain. -- An open pipe end that points at a wall, or at a tile whose socket isn't - open, is a **leak** (it glows red). Leaks keep the puzzle unsolved. +- Spin the pipes until **no pipe end leaks** — every socket is matched with a + neighbour that opens back. +- Open pipe ends are fine *as long as they connect* to another pipe. +- An open end that points at a wall, or at a tile whose socket isn't open, is + a **leak** (it glows red). Leaks keep the puzzle unsolved. ## How to Play @@ -21,27 +19,30 @@ ends. - **ESC** takes you back to the difficulty screen. - **New Puzzle** deals a fresh board of the same size; **Leave** / **Menu** goes back to the game menu. -- The faucet (brass valve) is where water enters; the iron grate is the - drain. Both must be part of the final pipe. +- The **faucet** (brass valve) and the **drain** (iron grate) are *fixed* — + they don't rotate. Use them as your anchor points. ## Reading the Board - **Straight pipes** (two flanged collars) carry water in a line. -- **Elbow pipes** (the corner collar with three bolts) turn water 90°. +- **Elbow pipes** (corner collar with three bolts) turn water 90°. +- **T-pieces** (a tee body with three arms) split the flow — one arm is the + "dead end" of that branch. +- **Crosses** (the big four-arm fitting) meet four pipes. +- **Dead ends** (a single capped stub) are the red herrings — they look like + they could be the path but aren't. - **Water** (the blue glow) shows everything currently connected to the - faucet — the animated dashes show the direction the water is flowing. -- The **CONNECTED** counter tracks how many tiles the faucet reaches; the - **LEAKS** counter tracks open ends. Solved = connected count is the whole - board and leaks are 0. + faucet; the animated pulses show the flow direction. +- The **LEAKS** counter tells you how many open ends remain. Solved = 0 leaks. ## Difficulty -| Tier | Grid | Notes | -|-----------|------|------------------------------------------------| -| Easy | 4 × 4| A gentle introduction — short pipe to trace. | -| Medium | 5 × 5| The classic feel; a single meandering run. | -| Hard | 6 × 6| Longer path, more turns to line up. | -| Legendary | 8 × 8| 64 pipes, one unbroken route. Bring coffee. | +| Tier | Grid | Notes | +|--------|--------|------------------------------------------------| +| Easy | 6 × 6 | A gentle introduction — a small branching net. | +| Medium | 7 × 7 | More T-pieces, longer branches. | +| Hard | 8 × 8 | Real mazes; plan ahead. | +| Expert | 10 × 10| 100 pipes, dense network. Bring coffee. | ## Scoring @@ -52,11 +53,15 @@ ends. ## Tips -- Work **outward from the faucet**: keep the wet region growing instead of - chasing pieces at random. -- The board is one long pipe — if you can trace a continuous route from the - faucet covering every tile, you've found the solution; you're just looking - for the rotations that make it happen. -- Corners (elbows) are the constraints. If an elbow's two openings can't - both point at pipes you need, rotate its neighbor instead of itself. -- Dead ends near the edge are usually the last two moves. +- **Anchors first.** The faucet and drain are fixed. Find the pieces that must + connect to them — that constrains a lot of the board. +- **Trace branches, not just the main line.** Every T-piece has a "dead arm." + That arm must end in a dead-end stub. Find the matching stub and you've + locked in the T's orientation. +- **Dead ends are your clues.** A capped stub must rotate to face the correct + neighbour. If two stubs must both face the same cell, that cell has to be a + T or cross — rotate the surrounding pieces to make room. +- **Work in regions.** Solve a corner or a branch, then lock it in and move + to the next. Don't chase a single long path. +- **If you're stuck**, check for pairs of stubs that must both face the same + tile — that's usually the key move. diff --git a/tools/smokePipePuzzle.cjs b/tools/smokePipePuzzle.cjs index 690fd17..5c637b2 100644 --- a/tools/smokePipePuzzle.cjs +++ b/tools/smokePipePuzzle.cjs @@ -44,14 +44,16 @@ const BASE = process.argv[2] || 'http://localhost:8123'; console.log('board ready:', st1); if (st1.screen !== 'play') { console.error('FAIL: not on play screen'); await browser.close(); process.exit(1); } - // Rotate every tile to its solution orientation (all synchronous, no await). + // Rotate every *rotatable* tile to its solution orientation (all synchronous, + // no await). The faucet & drain are fixed anchors — skip them. const rot = await page.evaluate(() => { const sc = window.game.scene.getScene('PipePuzzleGame'); - const { n, solution } = sc._board; + const { n, solution, source, drain } = sc._board; const N = 1, E = 2, S = 4, W = 8; const rot1 = (s) => ((s & N ? E : 0) | (s & E ? S : 0) | (s & S ? W : 0) | (s & W ? N : 0)); let clicks = 0; for (let i = 0; i < n * n; i++) { + if (i === source || i === drain) continue; // anchors are fixed let cur = sc._board.sockets[i], k = 0; while (cur !== solution[i] && k < 4) { cur = rot1(cur); k++; } for (let c = 0; c < k; c++) { sc._rotateCell(i); clicks++; } diff --git a/tools/verifyPipePuzzle.js b/tools/verifyPipePuzzle.js index 34f40d6..260c198 100644 --- a/tools/verifyPipePuzzle.js +++ b/tools/verifyPipePuzzle.js @@ -2,16 +2,15 @@ // node tools/verifyPipePuzzle.js // Exits non-zero on any failure. // -// 1. Fixture tests: hand-built boards, rotation algebra, win check. -// 2. Generation invariant sweep: for many random puzzles at every -// difficulty, the generated path is a valid Hamiltonian path and the -// solution board is solved while the scrambled board is not. +// 1. Fixture tests: socket algebra + a hand-built no-leak board. +// 2. Generation invariant sweep: for many random puzzles at every difficulty, +// the solution board has no leaks, the board is connected, the piece mix +// is rich, and the scrambled board starts with leaks. import { - N, E, S, W, DIRS, OPP, DELTA, - rotateSockets, generatePuzzle, isSolved, wetCells, - boardHasLeak, tileHasLeak, randomHamiltonianPath, gridAdjacent, - cellRC, DIFFICULTIES, + N, E, S, W, DIRS, OPP, + rotateSockets, bitCount, generatePuzzle, isSolved, countLeaks, + wetOrder, DIFFICULTIES, } from '../src/games/pipepuzzle/PipePuzzleLogic.js'; let failures = 0; @@ -27,66 +26,74 @@ check('OPP is an involution', DIRS.every((d) => OPP[OPP[d]] === d)); check('rotateSockets 4 steps = identity', [1, 2, 4, 8, 3, 5, 6, 10, 12, 9, 15].every((s) => rotateSockets(s, 4) === s)); check('rotateSockets N→E→S→W', rotateSockets(N, 1) === E && rotateSockets(N, 2) === S && rotateSockets(N, 3) === W); check('rotateSockets elbow NE→ES→SW→WN', rotateSockets(N | E, 1) === (E | S) && rotateSockets(N | E, 2) === (S | W) && rotateSockets(N | E, 3) === (W | N)); +check('rotateSockets T N|E|W → N|S|E', rotateSockets(N | E | W, 1) === (N | S | E)); +check('rotateSockets cross = invariant', rotateSockets(N | E | S | W, 3) === (N | E | S | W)); -// A solved 2×2 board (row-major: 0=(0,0) 1=(0,1) / 2=(1,0) 3=(1,1)): -// source cell0 → cell1 → cell3 → drain cell2. -// Sockets (solution): cell0 = E (source, 1 socket), cell1 = W|S, cell2 = E -// (drain, 1 socket), cell3 = N|W. -{ - const n = 2; - const sockets = [E, W | S, E, N | W]; - const board = { n, sockets, source: 0, drain: 2 }; - check('fixture 2×2 solved', isSolved(board) === true); - check('fixture 2×2 wet = all cells', wetCells(sockets, n, 0).size === 4); - check('fixture 2×2 no leaks', boardHasLeak(sockets, n) === false); - - // Rotate the source: E → S. Now it points at cell2 (which has only E), - // so the source leaks and the board is unsolved. - board.sockets = [S, W | S, E, N | W]; - check('fixture 2×2 after rotation not solved', isSolved(board) === false); - check('fixture 2×2 after rotation has leak', boardHasLeak(board.sockets, n) === true); -} - -// A leaked board: single socket pointing at wall +// A solved 3×3 no-leak board (row-major 0..8): +// 0=E (source) 1=W|E|S (T) 2=W (stub) +// 3=E|S (elbow) 4=N|E|S|W (cross) 5=W (stub) +// 6=N (stub) 7=N|E (elbow) 8=W (drain) +// Every socket is matched to a neighbour that opens back — no leaks. { const n = 3; - const sockets = [N, 0, 0, 0, 0, 0, 0, 0, 0]; - check('wall socket is a leak', tileHasLeak(sockets, n, 0) === true); + const sockets = [E, W | E | S, W, E | S, N | E | S | W, W, N, N | E, W]; + const board = { n, sockets, source: 0, drain: 8 }; + check('fixture 3×3 solved (no leaks)', isSolved(board) === true); + check('fixture 3×3 has a T-piece (3 sockets)', sockets.filter((s) => bitCount(s) === 3).length >= 1); + check('fixture 3×3 has a cross (4 sockets)', sockets.some((s) => bitCount(s) === 4)); + + // Break one connection: rotate the stub at cell2 W → N (points off the wall). + board.sockets = [E, W | E | S, N, E | S, N | E | S | W, W, N, N | E, W]; + check('fixture 3×3 after rotation not solved', isSolved(board) === false); + check('fixture 3×3 after rotation has ≥1 leak', countLeaks(board.sockets, n) >= 1); } // ── 2. Generation invariant sweep ─────────────────────────────────────────── +function isConnected(n, sockets) { + const total = n * n; + const parent = new Array(total); + for (let i = 0; i < total; i++) parent[i] = i; + const find = (x) => { while (parent[x] !== x) { parent[x] = parent[parent[x]]; x = parent[x]; } return x; }; + for (let i = 0; i < total; i++) { + for (const d of DIRS) { + if (!(sockets[i] & d)) continue; + const [dr, dc] = { [N]: [-1, 0], [S]: [1, 0], [E]: [0, 1], [W]: [0, -1] }[d]; + const nr = Math.floor(i / n) + dr, nc = (i % n) + dc; + if (nr < 0 || nr >= n || nc < 0 || nc >= n) continue; + const j = nr * n + nc; + if (!(sockets[j] & OPP[d])) continue; + const ra = find(i), rb = find(j); + if (ra !== rb) parent[ra] = rb; + } + } + const root = find(0); + for (let i = 1; i < total; i++) if (find(i) !== root) return false; + return true; +} + console.log('\n— Generation invariants —'); for (const diff of DIFFICULTIES) { const n = diff.n; - const samples = 40; - let pathValid = 0, solutionSolved = 0, scrambledUnsolved = 0, tileCounts = 0; + const samples = 30; + let solNoLeak = 0, scrLeak = 0, mixOk = 0, connOk = 0, wetAll = 0; for (let s = 0; s < samples; s++) { - const path = randomHamiltonianPath(n); - const total = n * n; - const isPerm = new Set(path).size === total && path.length === total && - path.every((i) => Number.isInteger(i) && i >= 0 && i < total); - const adjacent = path.slice(0, -1).every((c, i) => gridAdjacent(c, path[i + 1], n)); - if (isPerm && adjacent) pathValid++; - - const p = generatePuzzle(n); - // Solution board must be solved. - if (isSolved({ n, sockets: p.solution, source: p.source, drain: p.drain })) solutionSolved++; - // Scrambled board must not already be solved. - if (!isSolved({ n, sockets: p.sockets, source: p.source, drain: p.drain })) scrambledUnsolved++; - // Tile socket counts: 1 for source/drain, 2 for the rest. - const countsOk = p.sockets.every((sk, i) => { - const bits = (x) => { let c = 0; while (x) { x &= x - 1; c++; } return c; }; - if (i === p.source || i === p.drain) return bits(sk) === 1; - return bits(sk) === 2; - }); - if (countsOk) tileCounts++; + const p = generatePuzzle(n, diff.extra ?? 0); + if (countLeaks(p.solution, n) === 0) solNoLeak++; + if (countLeaks(p.sockets, n) > 0) scrLeak++; + const kinds = p.solution.map((sk) => bitCount(sk)); + const tc = kinds.filter((d) => d >= 3).length; + const stubs = kinds.filter((d) => d === 1).length; + if (tc >= 3 && stubs >= 3) mixOk++; + if (isConnected(n, p.solution)) connOk++; + if (wetOrder(p.solution, n, p.source).length === n * n) wetAll++; } - check(`${diff.key} (${n}×${n}): path is a valid Hamiltonian path (${pathValid}/${samples})`, pathValid === samples); - check(`${diff.key} (${n}×${n}): solution board is solved (${solutionSolved}/${samples})`, solutionSolved === samples); - check(`${diff.key} (${n}×${n}): scrambled board starts unsolved (${scrambledUnsolved}/${samples})`, scrambledUnsolved === samples); - check(`${diff.key} (${n}×${n}): socket counts 1/1/2…/2 (${tileCounts}/${samples})`, tileCounts === samples); + check(`${diff.key} (${n}×${n}): solution board has no leaks (${solNoLeak}/${samples})`, solNoLeak === samples); + check(`${diff.key} (${n}×${n}): board is connected (${connOk}/${samples})`, connOk === samples); + check(`${diff.key} (${n}×${n}): faucet reaches every cell (${wetAll}/${samples})`, wetAll === samples); + check(`${diff.key} (${n}×${n}): rich mix — ≥3 T/cross + ≥3 dead-ends (${mixOk}/${samples})`, mixOk === samples); + check(`${diff.key} (${n}×${n}): scrambled board starts with leaks (${scrLeak}/${samples})`, scrLeak === samples); } console.log(failures === 0 ? '\nAll Pipe Puzzle checks passed.' : `\n${failures} check(s) FAILED.`);