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:
parent
518818dc1b
commit
d27244ac60
|
|
@ -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 (W–E)
|
// • straight — painted horizontal (W–E)
|
||||||
// • elbow — painted N–E (stub from the top edge into the corner)
|
// • 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)
|
// • 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); // 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) {
|
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],
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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];
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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++; }
|
||||||
|
|
|
||||||
|
|
@ -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.`);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue