From a4efa5b68a5e8e6a4e4446bb14a94468c4e63862 Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Mon, 24 Aug 2026 22:14:08 -0600 Subject: [PATCH] Simplify pipe puzzle to pure spanning-tree boards - Remove extra-edge generation and cross pieces; board is now a single random spanning tree (one unique route between every pair of cells, no loops) - Update leak rendering to place droplets on exact open edges using DELTA/EDGE and render water/flash layers above tiles in the container - Adjust mini preview tiles and difficulty config (drop `extra` param) - Rework tutorial to explain tree structure, scattered dead ends, and branch-by-branch solving strategy - Update verification tool: assert tree edge count, no 4-way cells, leaf distribution across rows, faucet row variety, and board randomness; replace cross-based fixture with a valid tree --- src/games/pipepuzzle/PipePuzzleGame.js | 47 ++++++++----- src/games/pipepuzzle/PipePuzzleLogic.js | 91 +++++++++---------------- src/games/pipepuzzle/tutorial.md | 22 ++++-- tools/verifyPipePuzzle.js | 58 +++++++++++----- 4 files changed, 116 insertions(+), 102 deletions(-) diff --git a/src/games/pipepuzzle/PipePuzzleGame.js b/src/games/pipepuzzle/PipePuzzleGame.js index fc2c7fb..2613d19 100644 --- a/src/games/pipepuzzle/PipePuzzleGame.js +++ b/src/games/pipepuzzle/PipePuzzleGame.js @@ -13,7 +13,7 @@ import { MusicPlayer } from '../../ui/MusicPlayer.js'; import { playSound, SFX } from '../../ui/Sounds.js'; import { ensureTileTextures, angleFor, tileKeyFor, TILE_PX } from './PipePuzzleArt.js'; import { - N, E, S, W, DIRS, OPP, + N, E, S, W, DIRS, OPP, DELTA, cellRC, matchedDirs, neighborOf, generatePuzzle, rotateAt, isSolved, wetOrder, countLeaks, DIFFICULTIES, difficultyByKey, @@ -150,8 +150,8 @@ export default class PipePuzzleGame extends Phaser.Scene { const MINI = [ { tex: 'pp-tile-elbow', angle: 0 }, { tex: 'pp-tile-t', angle: 180 }, - { tex: 'pp-tile-cross', angle: 0 }, - { tex: 'pp-tile-stub', angle: 0 }, + { tex: 'pp-tile-stub', angle: 90 }, + { tex: 'pp-tile-straight', angle: 0 }, ]; DIFFICULTIES.forEach((diff, i) => { @@ -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, diff.extra ?? 0); + this._board = generatePuzzle(diff.n); this._moves = 0; this._time0 = null; this._won = false; @@ -274,10 +274,13 @@ export default class PipePuzzleGame extends Phaser.Scene { } sc.add(frame); - // Water layer (under tiles? no — over tiles, low alpha so pipes show through). - this._waterGfx = this.add.graphics().setDepth(D.water); - this._flashGfx = this.add.graphics().setDepth(D.flash); - sc.add([this._waterGfx, this._flashGfx]); + // Water & flash layers. Phaser Containers render children in list order + // (children's depths are ignored inside a Container), so these MUST be + // added AFTER the tiles to draw the water flow + leak dots in FRONT of + // the pipes — semi-transparent, so the pipes still show through. + this._waterGfx = this.add.graphics(); + this._flashGfx = this.add.graphics(); + // (added to the container below, after the tiles) // ── Tiles ── this._cells = []; @@ -304,6 +307,9 @@ export default class PipePuzzleGame extends Phaser.Scene { sc.add(img); } + // Water + flash layers go last → they render above the tiles. + sc.add([this._waterGfx, this._flashGfx]); + // ── Header ── const back = new Button(this, 100, 52, '← Menu', () => { playSound(this, SFX.UI_PICK); this._showSelect(); }, @@ -456,21 +462,28 @@ export default class PipePuzzleGame extends Phaser.Scene { } } - // 3) Leak droplets (wet cells with an open end) — pulsing red. + // 3) Leak droplets — one per leaking socket on the WHOLE board, placed on + // the exact edge of the leaking tile where the pipe's open end is. The + // leak test is identical to countLeaks, so the number of dots always + // equals the LEAKS counter. const pulse = 0.55 + 0.35 * Math.sin(this.time.now * 0.008); - for (const i of order) { + for (let i = 0; i < n * n; 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] = EDGE[d]; - const nr = r + dr, nc = c + dc; - if (nr >= 0 && nr < n && nc >= 0 && nc < n && (sockets[nr * n + nc] & OPP[d])) continue; - const x = cx(i) + dc * CELL * 0.5; - const y = cy(i) + dr * CELL * 0.5; + const [gr, gc] = DELTA[d]; // grid [row, col] delta — for the neighbor test + const nr = r + gr, nc = c + gc; + if (nr >= 0 && nr < n && nc >= 0 && nc < n && (sockets[nr * n + nc] & OPP[d])) continue; // matched — no leak + const [ex, ey] = EDGE[d]; // pixel [x, y] delta — for the dot position + // Sit the dot just inside the tile's open edge (at the pipe mouth), + // so it reads as water escaping from that exact opening. + const x = cx(i) + ex * CELL * 0.42; + const y = cy(i) + ey * CELL * 0.42; gfx.fillStyle(0xff5a5a, pulse * 0.9); - gfx.fillCircle(x, y, CELL * 0.075); + gfx.fillCircle(x, y, CELL * 0.09); gfx.fillStyle(0x7a1d24, pulse); - gfx.fillCircle(x, y, CELL * 0.035); + gfx.fillCircle(x, y, CELL * 0.045); } } } diff --git a/src/games/pipepuzzle/PipePuzzleLogic.js b/src/games/pipepuzzle/PipePuzzleLogic.js index be672a0..d94f1a4 100644 --- a/src/games/pipepuzzle/PipePuzzleLogic.js +++ b/src/games/pipepuzzle/PipePuzzleLogic.js @@ -4,22 +4,24 @@ // • 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 +// 1 → stub (dead end) 2 → straight or elbow 3 → T-piece // 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. +// • Generation: a random SPANNING TREE over the grid. A tree has exactly +// one path between any two cells, so from the faucet to every dead end +// there is a single route — no loops, no shortcuts, no crosses. Because +// the tree touches every cell, the board is always fully filled and +// connected, and the degree-1 leaves (dead ends) end up scattered +// anywhere on the grid. // • 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. +// tree-neighbours) is leak-free by construction, and every tile shape +// (stub/straight/elbow/T) 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. +// opens back. There can be many valid solutions — you just need to find +// one (different tiles can point different ways and still be leak-free). // ── Directions ─────────────────────────────────────────────────────────────── export const N = 1, E = 2, S = 4, W = 8; @@ -104,8 +106,7 @@ function allEdges(n) { // 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 edges = shuffle(allEdges(n)); // shuffle() returns a copy — use it! 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; }; @@ -123,16 +124,18 @@ function randomSpanningTree(n) { 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. +// A good base: a couple of branch points (T-pieces) and several dead ends so +// the board reads as a maze — and no 4-way cell, so no cross pieces ever +// appear (a crossing would be the only place two routes could merge). function goodBase(n, tree) { - let leaves = 0, branch = 0; + let leaves = 0, branch = 0, cross = 0; for (let i = 0; i < tree.length; i++) { const d = bitCount(tree[i]); if (d === 1) leaves++; - else if (d >= 3) branch++; + else if (d === 3) branch++; + else if (d === 4) cross++; } - return branch >= 2 && leaves >= 3; + return branch >= 2 && leaves >= 3 && cross === 0; } // Pick two far-apart degree-1 cells to be the faucet & drain. @@ -154,46 +157,14 @@ function pickAnchorPair(n, sockets) { return Math.random() < 0.5 ? [bestA, bestB] : [bestB, bestA]; } -// 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; -} - -export function generatePuzzle(n, extra = 0) { +export function generatePuzzle(n) { + // A random spanning tree: touches every cell (fully filled board), is + // connected, and has exactly one path between any two cells — so every + // dead end is reached by a single route and the faucet can be anywhere. let base = null, tries = 0; do { base = randomSpanningTree(n); tries++; } while (!goodBase(n, base) && tries < 300); - // 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))); - - 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 sockets = base; const [source, drain] = pickAnchorPair(n, sockets); // Scramble non-anchor tiles (anchors stay fixed). @@ -271,13 +242,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. +// Grid size is the difficulty knob: bigger tree → longer single routes, more +// branch points (T-pieces) and dead ends to keep straight. export const DIFFICULTIES = [ - { 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' }, + { key: 'easy', label: 'Easy', n: 6, blurb: '6 × 6 grid' }, + { key: 'medium', label: 'Medium', n: 7, blurb: '7 × 7 grid' }, + { key: 'hard', label: 'Hard', n: 8, blurb: '8 × 8 grid' }, + { key: 'expert', label: 'Expert', n: 10, 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 76880c5..cef67fc 100644 --- a/src/games/pipepuzzle/tutorial.md +++ b/src/games/pipepuzzle/tutorial.md @@ -12,6 +12,15 @@ when not a single socket is left open. No leaks allowed. - 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 the Board Works + +- The network is a **branching tree**: from the faucet there is exactly + **one route** to every dead end. No loops, no shortcuts. +- Dead ends can be **anywhere** on the grid — top, bottom, left, right. Don't + assume the "exit" is on one side. +- The faucet (brass) and the drain (iron) are the two long routes; the rest + of the dead ends hang off branch points. + ## How to Play - **Click (or tap) a pipe** to rotate it 90° clockwise. @@ -28,9 +37,8 @@ when not a single socket is left open. No leaks allowed. - **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. +- **Dead ends** (a single capped stub) are the leaves of the tree — each one + sits at the end of exactly one route from the faucet. - **Water** (the blue glow) shows everything currently connected to the faucet; the animated pulses show the flow direction. - The **LEAKS** counter tells you how many open ends remain. Solved = 0 leaks. @@ -59,9 +67,9 @@ when not a single socket is left open. No leaks allowed. 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. + neighbour. Since there's exactly one route to each dead end, finding the + stub locks in every tile along that whole branch. +- **Work branch by branch.** Solve one dead end's route, lock it in, then take + the next branch off the nearest T-piece. - **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/verifyPipePuzzle.js b/tools/verifyPipePuzzle.js index 260c198..c9b1e1b 100644 --- a/tools/verifyPipePuzzle.js +++ b/tools/verifyPipePuzzle.js @@ -12,7 +12,6 @@ import { rotateSockets, bitCount, generatePuzzle, isSolved, countLeaks, wetOrder, DIFFICULTIES, } from '../src/games/pipepuzzle/PipePuzzleLogic.js'; - let failures = 0; function check(name, cond, detail = '') { if (cond) { console.log(` ok ${name}`); } @@ -29,21 +28,23 @@ check('rotateSockets elbow NE→ES→SW→WN', rotateSockets(N | E, 1) === (E | 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 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. +// A solved 3×3 no-leak board (row-major 0..8) — a plain spanning tree (8 +// edges, no cycles, no 4-way cell) with two T-pieces and four dead ends: +// 0=E (source) 1=W|E|S (T) 2=W (stub) +// 3=E (stub) 4=N|W|E (T) 5=W|S (elbow) +// 6=E (drain) 7=E|W (straight) 8=N|W (elbow) +// Every socket is matched to a neighbour that opens back, so no leaks. { const n = 3; - 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 }; + const sockets = [E, W | E | S, W, E, N | W | E, W | S, E, E | W, N | W]; + const board = { n, sockets, source: 0, drain: 6 }; 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)); + check('fixture 3×3 has no 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]; + // Break one connection: rotate the stub at cell2 W → N (points off the + // top wall). Off-board socket = guaranteed leak. + board.sockets = [E, W | E | S, N, E, N | W | E, W | S, E, E | W, N | 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); } @@ -71,28 +72,49 @@ function isConnected(n, sockets) { return true; } +// Number of matched edges on the board (a tree over n² cells has exactly n²−1). +function matchedEdgeCount(n, sockets) { + let edges = 0; + for (let i = 0; i < n * n; i++) { + if (sockets[i] & E) edges++; // count each E-neighbour once + if (sockets[i] & S) edges++; // count each S-neighbour once + } + return edges; +} + console.log('\n— Generation invariants —'); for (const diff of DIFFICULTIES) { const n = diff.n; const samples = 30; - let solNoLeak = 0, scrLeak = 0, mixOk = 0, connOk = 0, wetAll = 0; + let solNoLeak = 0, scrLeak = 0, connOk = 0, wetAll = 0, treeOk = 0, noCross = 0; + const solutions = new Set(); + const leafRowsSeen = new Array(n).fill(false); + const srcRowsSeen = new Array(n).fill(false); for (let s = 0; s < samples; s++) { - const p = generatePuzzle(n, diff.extra ?? 0); + const p = generatePuzzle(n); 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++; + // A spanning tree: exactly n²−1 matched edges → no cycles → one unique + // route between every pair of cells (faucet → each dead end). + if (matchedEdgeCount(n, p.solution) === n * n - 1) treeOk++; + if (!p.solution.some((sk) => bitCount(sk) === 4)) noCross++; + for (let i = 0; i < n * n; i++) if (bitCount(p.solution[i]) === 1) leafRowsSeen[Math.floor(i / n)] = true; + srcRowsSeen[Math.floor(p.source / n)] = true; + solutions.add(JSON.stringify(p.solution)); } + const leafRowsAll = leafRowsSeen.every(Boolean); 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}): solution is a tree — exactly ${n * n - 1} edges, no loops (${treeOk}/${samples})`, treeOk === samples); + check(`${diff.key} (${n}×${n}): no 4-way crosses on the board (${noCross}/${samples})`, noCross === samples); + check(`${diff.key} (${n}×${n}): dead ends appear in every row across boards`, leafRowsAll); + check(`${diff.key} (${n}×${n}): faucet appears in more than one row across boards`, srcRowsSeen.filter(Boolean).length >= 2); + check(`${diff.key} (${n}×${n}): boards are genuinely random (≥15 distinct of ${samples})`, solutions.size >= 15); check(`${diff.key} (${n}×${n}): scrambled board starts with leaks (${scrLeak}/${samples})`, scrLeak === samples); }