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
This commit is contained in:
Brian Fertig 2026-08-24 22:14:08 -06:00
parent d27244ac60
commit a4efa5b68a
4 changed files with 116 additions and 102 deletions

View File

@ -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);
}
}
}

View File

@ -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];

View File

@ -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.

View File

@ -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):
// 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|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.
// 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);
}