Rework Pipe Puzzle to branching network with no-leak win condition

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
This commit is contained in:
Brian Fertig 2026-08-24 21:04:27 -06:00
parent 518818dc1b
commit d27244ac60
6 changed files with 407 additions and 289 deletions

View File

@ -10,11 +10,14 @@
// Tiles are painted in a canonical orientation and rotated: // Tiles are painted in a canonical orientation and rotated:
// • straight — painted horizontal (WE) // • straight — painted horizontal (WE)
// • elbow — painted NE (stub from the top edge into the corner) // • elbow — painted NE (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) // • source — painted socket-N (stub up, brass faucet body below)
// • drain — painted socket-N (stub up, iron grate below) // • drain — painted socket-N (stub up, iron grate below)
// See `angleFor(sockets)` for the rotation table. // 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; export const TILE_PX = 320;
const PW = TILE_PX * 0.30; // pipe width 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); // WE 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) { function paintSource(ctx) {
const cx = TILE_PX / 2; const cx = TILE_PX / 2;
plate(ctx); plate(ctx);
@ -304,23 +382,32 @@ function paintDot(ctx) {
export function tileKeyFor(sockets, isSource, isDrain) { export function tileKeyFor(sockets, isSource, isDrain) {
if (isSource) return 'pp-tile-source'; if (isSource) return 'pp-tile-source';
if (isDrain) return 'pp-tile-drain'; if (isDrain) return 'pp-tile-drain';
const straight = (sockets & N && sockets & S) || (sockets & E && sockets & W); switch (bitCount(sockets)) {
return straight ? 'pp-tile-straight' : 'pp-tile-elbow'; 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) { export function angleFor(sockets) {
if (sockets === (N | E)) return 0; const canonical = CANONICAL[tileKeyFor(sockets, false, false)];
if (sockets === (E | S)) return 90; for (let k = 0; k < 4; k++) {
if (sockets === (S | W)) return 180; if (rotateSockets(canonical, k) === sockets) return k * 90;
if (sockets === (W | N)) return 270; }
if (sockets === (E | W)) return 0; return 0; // cross — rotation-invariant
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;
} }
// Bake every texture the game needs (idempotent — guarded by textures.exists). // Bake every texture the game needs (idempotent — guarded by textures.exists).
@ -328,6 +415,9 @@ export function ensureTileTextures(scene) {
const jobs = [ const jobs = [
['pp-tile-straight', paintStraight], ['pp-tile-straight', paintStraight],
['pp-tile-elbow', paintElbow], ['pp-tile-elbow', paintElbow],
['pp-tile-stub', paintStub],
['pp-tile-t', paintT],
['pp-tile-cross', paintCross],
['pp-tile-source', paintSource], ['pp-tile-source', paintSource],
['pp-tile-drain', paintDrain], ['pp-tile-drain', paintDrain],
]; ];

View File

@ -3,8 +3,8 @@
// One screen (difficulty select) and one play screen, the same structure as // One screen (difficulty select) and one play screen, the same structure as
// Katamino. The board is a grid of baked tile textures (PipePuzzleArt.js) // Katamino. The board is a grid of baked tile textures (PipePuzzleArt.js)
// that the player rotates; water flows out from the faucet through every // 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 // matched socket and is drawn live as an animated stream. WIN = no leaks:
// cell connected to the faucet with zero leaks. // every socket on every tile is matched to a neighbour that opens back.
import * as Phaser from 'phaser'; import * as Phaser from 'phaser';
import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js'; import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js';
@ -15,14 +15,14 @@ import { ensureTileTextures, angleFor, tileKeyFor, TILE_PX } from './PipePuzzleA
import { import {
N, E, S, W, DIRS, OPP, N, E, S, W, DIRS, OPP,
cellRC, matchedDirs, neighborOf, cellRC, matchedDirs, neighborOf,
generatePuzzle, rotateAt, isSolved, wetOrder, generatePuzzle, rotateAt, isSolved, wetOrder, countLeaks,
DIFFICULTIES, difficultyByKey, DIFFICULTIES, difficultyByKey,
} from './PipePuzzleLogic.js'; } from './PipePuzzleLogic.js';
const D = { bg: -2, board: 0, tile: 2, water: 4, flash: 6, ui: 20, overlay: 60, overlayUI: 62 }; 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. // 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}`; const bestKey = (diff) => `pipepuzzle-best-${diff}`;
function getBest(diff) { function getBest(diff) {
@ -46,7 +46,7 @@ export default class PipePuzzleGame extends Phaser.Scene {
init() { init() {
this._screen = null; // 'select' | 'play' this._screen = null; // 'select' | 'play'
this._diff = null; // difficulty key 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._cells = null; // array of tile images
this._hover = null; this._hover = null;
this._time0 = null; // performance timestamp of first move this._time0 = null; // performance timestamp of first move
@ -134,7 +134,7 @@ export default class PipePuzzleGame extends Phaser.Scene {
}).setOrigin(0.5); }).setOrigin(0.5);
title.postFX.addShadow(0, 6, 0.004, 1.4, 0x000000, 10, 0.8); title.postFX.addShadow(0, 6, 0.004, 1.4, 0x000000, 10, 0.8);
const sub = this.add.text(GAME_WIDTH / 2, 205, 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 } } { fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.mutedHex, align: 'center', wordWrap: { width: 1100 } }
).setOrigin(0.5); ).setOrigin(0.5);
sc.add([title, sub]); sc.add([title, sub]);
@ -149,9 +149,9 @@ export default class PipePuzzleGame extends Phaser.Scene {
const left = (GAME_WIDTH - totalW) / 2; const left = (GAME_WIDTH - totalW) / 2;
const MINI = [ const MINI = [
{ tex: 'pp-tile-elbow', angle: 0 }, { tex: 'pp-tile-elbow', angle: 0 },
{ tex: 'pp-tile-straight', angle: 0 }, { tex: 'pp-tile-t', angle: 180 },
{ tex: 'pp-tile-source', angle: 180 }, { tex: 'pp-tile-cross', angle: 0 },
{ tex: 'pp-tile-drain', angle: 180 }, { tex: 'pp-tile-stub', angle: 0 },
]; ];
DIFFICULTIES.forEach((diff, i) => { DIFFICULTIES.forEach((diff, i) => {
@ -183,7 +183,7 @@ export default class PipePuzzleGame extends Phaser.Scene {
gfx.lineStyle(2, 0x4a5568, 1); gfx.lineStyle(2, 0x4a5568, 1);
gfx.strokeRoundedRect(cx - w / 2, cy - h / 2, w, h, 18); gfx.strokeRoundedRect(cx - w / 2, cy - h / 2, w, h, 18);
// Top accent bar in the difficulty's hue. // 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.fillStyle(HUES[diff.key], 1);
gfx.fillRoundedRect(cx - w / 2 + 18, cy - h / 2 + 14, w - 36, 6, 3); gfx.fillRoundedRect(cx - w / 2 + 18, cy - h / 2 + 14, w - 36, 6, 3);
sc.add(gfx); sc.add(gfx);
@ -240,7 +240,7 @@ export default class PipePuzzleGame extends Phaser.Scene {
this._screen = 'play'; this._screen = 'play';
this._diff = diffKey; this._diff = diffKey;
const diff = difficultyByKey(diffKey); const diff = difficultyByKey(diffKey);
this._board = generatePuzzle(diff.n); this._board = generatePuzzle(diff.n, diff.extra ?? 0);
this._moves = 0; this._moves = 0;
this._time0 = null; this._time0 = null;
this._won = false; this._won = false;
@ -285,17 +285,21 @@ export default class PipePuzzleGame extends Phaser.Scene {
const r = Math.floor(i / n), c = i % n; const r = Math.floor(i / n), c = i % n;
const isSource = i === this._board.source; const isSource = i === this._board.source;
const isDrain = i === this._board.drain; const isDrain = i === this._board.drain;
const isAnchor = isSource || isDrain; // faucet & drain are fixed
const key = tileKeyFor(this._board.sockets[i], isSource, isDrain); 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) const img = this.add.image(bx + c * CELL + CELL / 2, by + r * CELL + CELL / 2, key)
.setDisplaySize(CELL, CELL) .setDisplaySize(CELL, CELL)
.setAngle(angleFor(this._board.sockets[i])) .setAngle(angleFor(this._board.sockets[i]))
.setDepth(D.tile) .setDepth(D.tile);
.setInteractive({ useHandCursor: true });
img._idx = i; img._idx = i;
img._anchor = isAnchor;
img._targetAngle = angleFor(this._board.sockets[i]); img._targetAngle = angleFor(this._board.sockets[i]);
if (!isAnchor) {
img.setInteractive({ useHandCursor: true });
img.on('pointerover', () => this._setHover(i, true)); img.on('pointerover', () => this._setHover(i, true));
img.on('pointerout', () => this._setHover(i, false)); img.on('pointerout', () => this._setHover(i, false));
img.on('pointerdown', () => this._rotateCell(i)); img.on('pointerdown', () => this._rotateCell(i));
}
this._cells.push(img); this._cells.push(img);
sc.add(img); sc.add(img);
} }
@ -330,11 +334,9 @@ export default class PipePuzzleGame extends Phaser.Scene {
sc.add([l, v]); sc.add([l, v]);
return v; return v;
}; };
this._timeText = makeStat('TIME', '0:00', GAME_WIDTH - 760); this._timeText = makeStat('TIME', '0:00', GAME_WIDTH - 700);
this._movesText = makeStat('MOVES', '0', GAME_WIDTH - 660); this._movesText = makeStat('MOVES', '0', GAME_WIDTH - 560);
const conn = makeStat('CONNECTED', '0 / ' + n * n, GAME_WIDTH - 520); this._leakText = makeStat('LEAKS', '0', GAME_WIDTH - 400);
const leak = makeStat('LEAKS', '0', GAME_WIDTH - 390);
this._connText = conn; this._leakText = leak;
// Footer hint. // Footer hint.
const hint = this.add.text(GAME_WIDTH / 2, GAME_HEIGHT - 34, 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 (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; if (this._time0 == null) this._time0 = this.time.now;
this._moves++; this._moves++;
rotateAt(this._board, i); rotateAt(this._board, i);
@ -386,28 +389,15 @@ export default class PipePuzzleGame extends Phaser.Scene {
this._updateStats(); this._updateStats();
this._drawWater(); this._drawWater();
if (isSolved(this._board)) { if (checkWin && isSolved(this._board)) {
this._won = true; this._won = true;
this._winSequence(); this._winSequence();
} }
} }
_updateStats() { _updateStats() {
const { n, sockets, source } = this._board; const { n, sockets } = this._board;
const wet = wetOrder(sockets, n, source).length; const leaks = countLeaks(sockets, n);
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}`);
this._leakText.setText(String(leaks)); this._leakText.setText(String(leaks));
this._leakText.setColor(leaks > 0 ? '#e06c75' : '#3fae62'); this._leakText.setColor(leaks > 0 ? '#e06c75' : '#3fae62');
this._movesText.setText(String(this._moves)); 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 cy = (i) => by + Math.floor(i / n) * CELL + CELL / 2;
const EDGE = { [N]: [0, -1], [S]: [0, 1], [E]: [1, 0], [W]: [-1, 0] }; 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) { for (const i of order) {
const x = bx + (i % n) * CELL, y = by + Math.floor(i / n) * CELL; const x = bx + (i % n) * CELL, y = by + Math.floor(i / n) * CELL;
const a = 0.16 + Math.min(0.3, boost * 0.25); const a = 0.34 + Math.min(0.42, boost * 0.3);
gfx.fillStyle(0x3fa0dc, a); gfx.fillStyle(0x1f8fdd, a);
gfx.fillRoundedRect(x + 7, y + 7, CELL - 14, CELL - 14, CELL * 0.16); 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 // 2) Water flow along matched edges: a translucent base line plus a bright
// travelling pulse. The pulse always marches toward the drain (the // travelling pulse. The pulse always marches toward the drain (the
// direction water flows) rather than toward the source. // 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 period = CELL * 1.4;
const off = (this.time.now * 0.12) % period; const off = (this.time.now * 0.12) % period;
const baseA = 0.42 + Math.min(0.25, boost * 0.25); const baseA = 0.6 + Math.min(0.3, boost * 0.3);
const pulseA = 0.85; const pulseA = 0.95;
for (const i of order) { for (const i of order) {
for (const d of matchedDirs(sockets, n, i)) { for (const d of matchedDirs(sockets, n, i)) {
const j = neighborOf(i, n, d); const j = neighborOf(i, n, d);
if (!orderIdx.has(j)) continue; if (!orderIdx.has(j)) continue;
const towardDrain = orderIdx.get(j) > orderIdx.get(i); 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 x0 = cx(i), y0 = cy(i);
const x1 = x0 + ex * CELL / 2, y1 = y0 + ey * CELL / 2; const x1 = x0 + ex * CELL / 2, y1 = y0 + ey * CELL / 2;
// Base water line (translucent so pipe still reads through). // 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); gfx.lineBetween(x0, y0, x1, y1);
// Travelling bright pulse. // Travelling bright pulse.
const u = (off / period); // 0→1 along the edge const u = (off / period); // 0→1 along the edge
const px = x0 + (x1 - x0) * (towardDrain ? u : 1 - u); const px = x0 + (x1 - x0) * (towardDrain ? u : 1 - u);
const py = y0 + (y1 - y0) * (towardDrain ? u : 1 - u); const py = y0 + (y1 - y0) * (towardDrain ? u : 1 - u);
gfx.lineStyle(Math.max(6, CELL * 0.14), 0xaee6ff, pulseA); gfx.lineStyle(Math.max(8, CELL * 0.18), 0xc9f1ff, pulseA);
gfx.lineBetween(px - (x1 - x0) * 0.10, py - (y1 - y0) * 0.10, px + (x1 - x0) * 0.10, py + (y1 - y0) * 0.10); gfx.lineBetween(px - (x1 - x0) * 0.12, py - (y1 - y0) * 0.12, px + (x1 - x0) * 0.12, py + (y1 - y0) * 0.12);
} }
} }

View File

@ -1,20 +1,25 @@
// Pipe Puzzle — pure game logic (no Phaser, runs in Node for verification). // Pipe Puzzle — pure game logic (no Phaser, runs in Node for verification).
// //
// Strict "all-tiles-connected" variant: // "No leaks" variant:
// • The board is an N×N grid, **every cell holds a pipe tile**. // • N×N grid, EVERY cell holds a pipe tile (no empty squares).
// • Exactly two special 1-socket tiles: a SOURCE (faucet) and a DRAIN. // • A board is a set of EDGES between adjacent cells. A cell's degree =
// • Every other tile is a 2-socket STRAIGHT or ELBOW. // how many sockets it has:
// • The solved state is a single continuous, leak-free pipe path that runs // 1 → stub (dead end) 2 → straight or elbow
// from the faucet to the drain and visits every cell exactly once — a // 3 → T-piece 4 → cross
// Hamiltonian path. So "every tile is connected" and "no leaks" fall out // plus two fixed anchors the player cannot rotate:
// of one clean condition. // source — the faucet (degree 1)
// • The puzzle is generated by building a random Hamiltonian path, orienting // drain — the drain (degree 1)
// every tile along it (the solution), then randomly rotating the tiles. // • Generation: a random SPANNING TREE (touches every cell, so no empty
// It is therefore always solvable. // 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
// Directions / sockets // of T-pieces, crosses and branching — a maze, not a single line.
// Bit flags per socket: N=1, E=2, S=4, W=8. A tile's `sockets` value is the // • Always solvable: the solved pose (each cell's sockets pointing at its
// OR of the sockets it currently has. `rotateSockets` turns it clockwise. // 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 ─────────────────────────────────────────────────────────────── // ── Directions ───────────────────────────────────────────────────────────────
export const N = 1, E = 2, S = 4, W = 8; 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 OPP = { [N]: S, [S]: N, [E]: W, [W]: E };
export const DELTA = { [N]: [-1, 0], [S]: [1, 0], [E]: [0, 1], [W]: [0, -1] }; 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 ─────────────────────────────────────────────────────────── // ── Random helpers ───────────────────────────────────────────────────────────
export function randInt(maxExclusive) { return Math.floor(Math.random() * maxExclusive); } export function randInt(maxExclusive) { return Math.floor(Math.random() * maxExclusive); }
export function shuffle(arr) { export function shuffle(arr) {
@ -47,6 +48,14 @@ export function neighborOf(i, n, d) {
const [dr, dc] = DELTA[d]; const [dr, dc] = DELTA[d];
return (r + dr) * n + (c + dc); 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). // Directions of i whose sockets are matched by the neighbor (water can flow there).
export function matchedDirs(sockets, n, i) { export function matchedDirs(sockets, n, i) {
const [r, c] = cellRC(i, n); const [r, c] = cellRC(i, n);
@ -60,19 +69,6 @@ export function matchedDirs(sockets, n, i) {
} }
return out; 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 ─────────────────────────────────────────────────────────── // ── Socket algebra ───────────────────────────────────────────────────────────
// Rotate a socket mask `rot` steps clockwise (N→E→S→W→N). // Rotate a socket mask `rot` steps clockwise (N→E→S→W→N).
@ -88,107 +84,136 @@ export function rotateSockets(sock, rot) {
} }
return sock; return sock;
} }
export function bitCount(x) { let c = 0; while (x) { x &= x - 1; c++; } return c; }
// ── Hamiltonian path generation ────────────────────────────────────────────── // ── Generation ───────────────────────────────────────────────────────────────
// A guaranteed-valid snake (boustrophedon) path covering every cell. // Every possible edge in the n×n grid (each once): down and right neighbours.
function snakePath(n) { function allEdges(n) {
const horizontal = Math.random() < 0.5; const edges = [];
const path = [];
if (horizontal) {
for (let r = 0; r < n; r++) { 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 {
for (let c = 0; c < n; c++) { 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 edges;
return path;
} }
// Randomize a Hamiltonian path with "2-switch" (detour) moves. // 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).
// Pick two path edges (a→b) and (c→d) with a non-trivial segment between function randomSpanningTree(n) {
// them; if a~c and b~d are both valid grid adjacencies, reroute to const N2 = n * n;
// a→c … d→b by reversing the middle segment. This is the standard const edges = allEdges(n);
// Hamiltonian-path improvement move: it keeps the path a permutation of all shuffle(edges);
// cells (nothing is duplicated or dropped) and preserves every adjacency, const parent = new Array(N2);
// so the result is always a valid Hamiltonian path — the puzzle stays for (let i = 0; i < N2; i++) parent[i] = i;
// solvable by construction. const find = (x) => { while (parent[x] !== x) { parent[x] = parent[parent[x]]; x = parent[x]; } return x; };
// const tree = new Array(N2).fill(0);
// On a snake this produces detours that weave between rows/columns, giving let count = 0;
// each puzzle a distinct shape and distinct source/drain cells. for (const [a, b] of edges) {
function randomizePath(path, n, attempts = 600) { const ra = find(a), rb = find(b);
const len = path.length; if (ra === rb) continue; // would form a cycle
for (let t = 0; t < attempts; t++) { parent[ra] = rb;
let p = randInt(len - 1); const d = dirBetween(a, b, n);
let q = randInt(len - 1); tree[a] |= d;
if (p > q) [p, q] = [q, p]; tree[b] |= OPP[d];
if (q - p < 1) continue; // need at least one cell between the edges if (++count === N2 - 1) break;
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);
} }
} return tree;
if (Math.random() < 0.5) path.reverse();
return path;
} }
export function randomHamiltonianPath(n) { // A good base: at least a couple of branch points (T/cross) and a few dead
return randomizePath(snakePath(n), n); // 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;
} }
// ── Puzzle construction ────────────────────────────────────────────────────── // Pick two far-apart degree-1 cells to be the faucet & drain.
// Orient every tile along the path (the solution), then scramble. function pickAnchorPair(n, sockets) {
export function generatePuzzle(n) { const leaves = [];
const path = randomHamiltonianPath(n); for (let i = 0; i < sockets.length; i++) if (bitCount(sockets[i]) === 1) leaves.push(i);
const total = path.length; if (leaves.length < 2) {
// Fallback (shouldn't happen for a connected board): use two corners.
// Solution: sockets per cell, following the path. return [0, sockets.length - 1];
const solution = new Array(total).fill(0); }
for (let i = 0; i < total; i++) { let bestA = leaves[0], bestB = leaves[1], bestD = -1;
let sock = 0; for (let a = 0; a < leaves.length; a++) {
if (i > 0) sock |= dirBetween(path[i], path[i - 1], n); for (let b = a + 1; b < leaves.length; b++) {
if (i < total - 1) sock |= dirBetween(path[i], path[i + 1], n); const [ar, ac] = cellRC(leaves[a], n), [br, bc] = cellRC(leaves[b], n);
solution[path[i]] = sock; const d = Math.abs(ar - br) + Math.abs(ac - bc);
if (d > bestD) { bestD = d; bestA = leaves[a]; bestB = leaves[b]; }
}
}
return Math.random() < 0.5 ? [bestA, bestB] : [bestB, bestA];
} }
const source = path[0]; // Does the final board have a good mix of interesting pieces?
const drain = path[total - 1]; 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;
}
// Scramble: random rotation of every tile (source & drain included). export function generatePuzzle(n, extra = 0) {
const sockets = solution.map((s) => (s === 0 ? 0 : rotateSockets(s, randInt(4)))); let base = null, tries = 0;
do { base = randomSpanningTree(n); tries++; } while (!goodBase(n, base) && tries < 300);
// If the scramble happened to produce the solved board (only possible for a // Add `extra` random edges (not already in the tree) to create cycles and
// 1-socket/2-socket board when rotations coincide), nudge one interior tile. // raise degrees → more T-pieces and crosses.
if (isSolved({ n, sockets, source, drain })) { const treeEdges = new Set();
for (let i = 0; i < total; i++) { 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 [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 (i === source || i === drain) continue;
if (sockets[i] !== 0 && sockets[i] !== solution[i]) break; if (bitCount(scrambled[i]) >= 2) { scrambled[i] = rotateSockets(scrambled[i], 1); break; }
// rotate a tile whose solution orientation is not its only option
if (countBits(sockets[i]) === 2) { sockets[i] = rotateSockets(sockets[i], 1); break; }
} }
} }
return { n, sockets, solution, source, drain, path }; return { n, sockets: scrambled, solution: sockets, source, drain };
} }
// ── Board queries ──────────────────────────────────────────────────────────── // ── 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; } export function isSpecial(i, source, drain) { return i === source || i === drain; }
// Set of cell indices reachable from `start` through *matched* sockets // BFS order of wet cells from `start` through matched sockets (the "wave").
// (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".
export function wetOrder(sockets, n, start) { export function wetOrder(sockets, n, start) {
const seen = new Set([start]); const seen = new Set([start]);
const order = [start]; const order = [start];
@ -211,36 +236,33 @@ export function wetOrder(sockets, n, start) {
} }
return order; 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 // Number of leaking sockets on the whole board.
// or at a neighbor that does not open back). export function countLeaks(sockets, n) {
export function tileHasLeak(sockets, n, i) { let leaks = 0;
for (let i = 0; i < sockets.length; i++) {
if (sockets[i] === 0) continue;
const [r, c] = cellRC(i, n); const [r, c] = cellRC(i, n);
for (const d of DIRS) { for (const d of DIRS) {
if (!(sockets[i] & d)) continue; if (!(sockets[i] & d)) continue;
const [dr, dc] = DELTA[d]; const [dr, dc] = DELTA[d];
const nr = r + dr, nc = c + dc; const nr = r + dr, nc = c + dc;
if (nr < 0 || nr >= n || nc < 0 || nc >= n) return true; if (nr < 0 || nr >= n || nc < 0 || nc >= n) { leaks++; continue; }
if (!(sockets[nr * n + nc] & OPP[d])) return true; 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? // WIN: no leaks anywhere.
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.
export function isSolved(board) { export function isSolved(board) {
const { n, sockets, source } = board; const { n, sockets } = board;
if (wetCells(sockets, n, source).size !== n * n) return false; return countLeaks(sockets, n) === 0;
if (boardHasLeak(sockets, n)) return false;
return true;
} }
// 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) { export function rotateAt(board, i) {
const s = board.sockets[i]; const s = board.sockets[i];
if (s === 0) return board; if (s === 0) return board;
@ -249,11 +271,13 @@ export function rotateAt(board, i) {
} }
// ── Difficulty tiers ───────────────────────────────────────────────────────── // ── 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 = [ export const DIFFICULTIES = [
{ key: 'easy', label: 'Easy', n: 4, blurb: '4 × 4 grid' }, { key: 'easy', label: 'Easy', n: 6, extra: 5, blurb: '6 × 6 grid' },
{ key: 'medium', label: 'Medium', n: 5, blurb: '5 × 5 grid' }, { key: 'medium', label: 'Medium', n: 7, extra: 9, blurb: '7 × 7 grid' },
{ key: 'hard', label: 'Hard', n: 6, blurb: '6 × 6 grid' }, { key: 'hard', label: 'Hard', n: 8, extra: 15, blurb: '8 × 8 grid' },
{ key: 'legendary', label: 'Legendary', n: 8, blurb: '8 × 8 grid' }, { key: 'expert', label: 'Expert', n: 10, extra: 26, blurb: '10 × 10 grid' },
]; ];
export function difficultyByKey(key) { export function difficultyByKey(key) {
return DIFFICULTIES.find((d) => d.key === key) ?? DIFFICULTIES[0]; return DIFFICULTIES.find((d) => d.key === key) ?? DIFFICULTIES[0];

View File

@ -1,18 +1,16 @@
# Pipe Puzzle # Pipe Puzzle
A single continuous pipe network must carry water from the **faucet** to the A branching pipe network must be **sealed**: every open pipe end has to meet
**drain** — and it has to use *every* pipe on the board. No leaks, no dead neighbouring pipe. Water enters at the **faucet** and the network is complete
ends. when not a single socket is left open. No leaks allowed.
## The Goal ## The Goal
- Turn the pipes until **every tile is connected** to the faucet through - Spin the pipes until **no pipe end leaks** — every socket is matched with a
open, matched pipe ends. neighbour that opens back.
- The whole board must form **one** unbroken pipe: in the solved state the - Open pipe ends are fine *as long as they connect* to another pipe.
water flows from the faucet, through every single tile, and out of the - An open end that points at a wall, or at a tile whose socket isn't open, is
drain. a **leak** (it glows red). Leaks keep the puzzle unsolved.
- 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.
## How to Play ## How to Play
@ -21,27 +19,30 @@ ends.
- **ESC** takes you back to the difficulty screen. - **ESC** takes you back to the difficulty screen.
- **New Puzzle** deals a fresh board of the same size; **Leave** / **Menu** - **New Puzzle** deals a fresh board of the same size; **Leave** / **Menu**
goes back to the game menu. goes back to the game menu.
- The faucet (brass valve) is where water enters; the iron grate is the - The **faucet** (brass valve) and the **drain** (iron grate) are *fixed*
drain. Both must be part of the final pipe. they don't rotate. Use them as your anchor points.
## Reading the Board ## Reading the Board
- **Straight pipes** (two flanged collars) carry water in a line. - **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 - **Water** (the blue glow) shows everything currently connected to the
faucet — the animated dashes show the direction the water is flowing. faucet; the animated pulses show the flow direction.
- The **CONNECTED** counter tracks how many tiles the faucet reaches; the - The **LEAKS** counter tells you how many open ends remain. Solved = 0 leaks.
**LEAKS** counter tracks open ends. Solved = connected count is the whole
board and leaks are 0.
## Difficulty ## Difficulty
| Tier | Grid | Notes | | Tier | Grid | Notes |
|-----------|------|------------------------------------------------| |--------|--------|------------------------------------------------|
| Easy | 4 × 4| A gentle introduction — short pipe to trace. | | Easy | 6 × 6 | A gentle introduction — a small branching net. |
| Medium | 5 × 5| The classic feel; a single meandering run. | | Medium | 7 × 7 | More T-pieces, longer branches. |
| Hard | 6 × 6| Longer path, more turns to line up. | | Hard | 8 × 8 | Real mazes; plan ahead. |
| Legendary | 8 × 8| 64 pipes, one unbroken route. Bring coffee. | | Expert | 10 × 10| 100 pipes, dense network. Bring coffee. |
## Scoring ## Scoring
@ -52,11 +53,15 @@ ends.
## Tips ## Tips
- Work **outward from the faucet**: keep the wet region growing instead of - **Anchors first.** The faucet and drain are fixed. Find the pieces that must
chasing pieces at random. connect to them — that constrains a lot of the board.
- The board is one long pipe — if you can trace a continuous route from the - **Trace branches, not just the main line.** Every T-piece has a "dead arm."
faucet covering every tile, you've found the solution; you're just looking That arm must end in a dead-end stub. Find the matching stub and you've
for the rotations that make it happen. locked in the T's orientation.
- Corners (elbows) are the constraints. If an elbow's two openings can't - **Dead ends are your clues.** A capped stub must rotate to face the correct
both point at pipes you need, rotate its neighbor instead of itself. neighbour. If two stubs must both face the same cell, that cell has to be a
- Dead ends near the edge are usually the last two moves. 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.

View File

@ -44,14 +44,16 @@ const BASE = process.argv[2] || 'http://localhost:8123';
console.log('board ready:', st1); console.log('board ready:', st1);
if (st1.screen !== 'play') { console.error('FAIL: not on play screen'); await browser.close(); process.exit(1); } 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 rot = await page.evaluate(() => {
const sc = window.game.scene.getScene('PipePuzzleGame'); 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 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)); const rot1 = (s) => ((s & N ? E : 0) | (s & E ? S : 0) | (s & S ? W : 0) | (s & W ? N : 0));
let clicks = 0; let clicks = 0;
for (let i = 0; i < n * n; i++) { 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; let cur = sc._board.sockets[i], k = 0;
while (cur !== solution[i] && k < 4) { cur = rot1(cur); k++; } while (cur !== solution[i] && k < 4) { cur = rot1(cur); k++; }
for (let c = 0; c < k; c++) { sc._rotateCell(i); clicks++; } for (let c = 0; c < k; c++) { sc._rotateCell(i); clicks++; }

View File

@ -2,16 +2,15 @@
// node tools/verifyPipePuzzle.js // node tools/verifyPipePuzzle.js
// Exits non-zero on any failure. // Exits non-zero on any failure.
// //
// 1. Fixture tests: hand-built boards, rotation algebra, win check. // 1. Fixture tests: socket algebra + a hand-built no-leak board.
// 2. Generation invariant sweep: for many random puzzles at every // 2. Generation invariant sweep: for many random puzzles at every difficulty,
// difficulty, the generated path is a valid Hamiltonian path and the // the solution board has no leaks, the board is connected, the piece mix
// solution board is solved while the scrambled board is not. // is rich, and the scrambled board starts with leaks.
import { import {
N, E, S, W, DIRS, OPP, DELTA, N, E, S, W, DIRS, OPP,
rotateSockets, generatePuzzle, isSolved, wetCells, rotateSockets, bitCount, generatePuzzle, isSolved, countLeaks,
boardHasLeak, tileHasLeak, randomHamiltonianPath, gridAdjacent, wetOrder, DIFFICULTIES,
cellRC, DIFFICULTIES,
} from '../src/games/pipepuzzle/PipePuzzleLogic.js'; } from '../src/games/pipepuzzle/PipePuzzleLogic.js';
let failures = 0; 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 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 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 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)): // A solved 3×3 no-leak board (row-major 0..8):
// source cell0 → cell1 → cell3 → drain cell2. // 0=E (source) 1=W|E|S (T) 2=W (stub)
// Sockets (solution): cell0 = E (source, 1 socket), cell1 = W|S, cell2 = E // 3=E|S (elbow) 4=N|E|S|W (cross) 5=W (stub)
// (drain, 1 socket), cell3 = N|W. // 6=N (stub) 7=N|E (elbow) 8=W (drain)
{ // Every socket is matched to a neighbour that opens back — no leaks.
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
{ {
const n = 3; const n = 3;
const sockets = [N, 0, 0, 0, 0, 0, 0, 0, 0]; const sockets = [E, W | E | S, W, E | S, N | E | S | W, W, N, N | E, W];
check('wall socket is a leak', tileHasLeak(sockets, n, 0) === true); 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 ─────────────────────────────────────────── // ── 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 —'); console.log('\n— Generation invariants —');
for (const diff of DIFFICULTIES) { for (const diff of DIFFICULTIES) {
const n = diff.n; const n = diff.n;
const samples = 40; const samples = 30;
let pathValid = 0, solutionSolved = 0, scrambledUnsolved = 0, tileCounts = 0; let solNoLeak = 0, scrLeak = 0, mixOk = 0, connOk = 0, wetAll = 0;
for (let s = 0; s < samples; s++) { for (let s = 0; s < samples; s++) {
const path = randomHamiltonianPath(n); const p = generatePuzzle(n, diff.extra ?? 0);
const total = n * n; if (countLeaks(p.solution, n) === 0) solNoLeak++;
const isPerm = new Set(path).size === total && path.length === total && if (countLeaks(p.sockets, n) > 0) scrLeak++;
path.every((i) => Number.isInteger(i) && i >= 0 && i < total); const kinds = p.solution.map((sk) => bitCount(sk));
const adjacent = path.slice(0, -1).every((c, i) => gridAdjacent(c, path[i + 1], n)); const tc = kinds.filter((d) => d >= 3).length;
if (isPerm && adjacent) pathValid++; const stubs = kinds.filter((d) => d === 1).length;
if (tc >= 3 && stubs >= 3) mixOk++;
const p = generatePuzzle(n); if (isConnected(n, p.solution)) connOk++;
// Solution board must be solved. if (wetOrder(p.solution, n, p.source).length === n * n) wetAll++;
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++;
} }
check(`${diff.key} (${n}×${n}): path is a valid Hamiltonian path (${pathValid}/${samples})`, pathValid === samples); check(`${diff.key} (${n}×${n}): solution board has no leaks (${solNoLeak}/${samples})`, solNoLeak === samples);
check(`${diff.key} (${n}×${n}): solution board is solved (${solutionSolved}/${samples})`, solutionSolved === samples); check(`${diff.key} (${n}×${n}): board is connected (${connOk}/${samples})`, connOk === samples);
check(`${diff.key} (${n}×${n}): scrambled board starts unsolved (${scrambledUnsolved}/${samples})`, scrambledUnsolved === samples); check(`${diff.key} (${n}×${n}): faucet reaches every cell (${wetAll}/${samples})`, wetAll === samples);
check(`${diff.key} (${n}×${n}): socket counts 1/1/2…/2 (${tileCounts}/${samples})`, tileCounts === 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.`); console.log(failures === 0 ? '\nAll Pipe Puzzle checks passed.' : `\n${failures} check(s) FAILED.`);