pathmoor / core.js
CyberMax-tools's picture
Deploy pathmoor
09eafd8 verified
Raw History Blame Contribute Delete
5.74 kB
// Pathmoor core: pure puzzle logic (no DOM), shared by the game and the tests. (c) 2026 CyberMax. All rights reserved.
// A board is an n×n grid of path stones. Each stone is a 4-bit mask of its openings: N=1, E=2, S=4, W=8.
// The solution is a random spanning tree grown from the lantern stone, so every stone joins the lantern by exactly one
// route. The board is shuffled by turning stones; the player turns them back. Solved = every stone lit by the lantern
// and no opening points at the edge or at a closed side (any arrangement that does that counts, not only ours).
import { rng } from './cmx.js';
export const N = 1, E = 2, S = 4, W = 8;
const DIRS = [[N, 0, -1, S], [E, 1, 0, W], [S, 0, 1, N], [W, -1, 0, E]];
/** Turn a stone a quarter clockwise, k times. */
export function turn(m, k = 1) {
let r = m;
for (let i = 0; i < ((k % 4) + 4) % 4; i++) r = ((r << 1) | (r >> 3)) & 15;
return r;
}
export const bits = (m) => (m & 1) + ((m >> 1) & 1) + ((m >> 2) & 1) + ((m >> 3) & 1);
/** Fewest clockwise turns that take stone `from` to `to` (Infinity if impossible). */
export function turnsTo(from, to) {
for (let k = 0; k < 4; k++) if (turn(from, k) === to) return k;
return Infinity;
}
/**
* Build a puzzle from a seed string. Returns { n, src, sol, start } where sol and start are arrays of n*n masks.
* Grown with a randomised Prim tree; boards with too many straight runs or too few branches are re-rolled so every
* board has real choices. The shuffle turns each stone 0-3 times and is never already solved.
*/
export function generate(seed, n = 6) {
const rand = rng(`pathmoor:${n}:${seed}`);
for (let attempt = 0; attempt < 40; attempt++) {
const sol = new Array(n * n).fill(0);
const c = Math.floor((n - 1) / 2);
const src = (c + Math.floor(rand() * 2)) * n + c + Math.floor(rand() * 2); // one of the 4 middle stones
const inTree = new Array(n * n).fill(false);
inTree[src] = true;
let frontier = [];
const addEdges = (i) => {
const x = i % n, y = (i / n) | 0;
for (const [b, dx, dy, back] of DIRS) {
const nx = x + dx, ny = y + dy;
if (nx >= 0 && ny >= 0 && nx < n && ny < n && !inTree[ny * n + nx]) frontier.push([i, ny * n + nx, b, back]);
}
};
addEdges(src);
let count = 1;
while (count < n * n) {
frontier = frontier.filter(([, j]) => !inTree[j]);
const [i, j, b, back] = frontier[Math.floor(rand() * frontier.length)];
if (inTree[j]) continue;
sol[i] |= b; sol[j] |= back; inTree[j] = true; count++;
addEdges(j);
}
const straights = sol.filter((m) => m === (N | S) || m === (E | W)).length;
const branches = sol.filter((m) => bits(m) >= 3).length;
if (straights > n * n * 0.34 || branches < Math.max(3, Math.floor(n * n / 9))) continue;
const start = sol.map((m) => turn(m, Math.floor(rand() * 4)));
if (isSolved(n, src, start)) continue;
return { n, src, sol, start };
}
// extremely unlikely fallback: keep the last rules but accept the board
const b = generate(`${seed}~`, n);
return b;
}
/** Stones reachable from the lantern through matching openings. Returns a Uint8Array (1 = lit). */
export function lit(n, src, cur) {
const on = new Uint8Array(n * n);
const q = [src];
on[src] = 1;
while (q.length) {
const i = q.pop();
const x = i % n, y = (i / n) | 0;
for (const [b, dx, dy, back] of DIRS) {
if (!(cur[i] & b)) continue;
const nx = x + dx, ny = y + dy;
if (nx < 0 || ny < 0 || nx >= n || ny >= n) continue;
const j = ny * n + nx;
if (!on[j] && (cur[j] & back)) { on[j] = 1; q.push(j); }
}
}
return on;
}
/** Openings that lead nowhere (edge, or a neighbour without the matching opening). */
export function looseEnds(n, cur) {
let loose = 0;
for (let i = 0; i < n * n; i++) {
const x = i % n, y = (i / n) | 0;
for (const [b, dx, dy, back] of DIRS) {
if (!(cur[i] & b)) continue;
const nx = x + dx, ny = y + dy;
if (nx < 0 || ny < 0 || nx >= n || ny >= n || !(cur[ny * n + nx] & back)) loose++;
}
}
return loose;
}
export function isSolved(n, src, cur) {
if (looseEnds(n, cur)) return false;
const on = lit(n, src, cur);
for (let i = 0; i < on.length; i++) if (!on[i]) return false;
return true;
}
/** Par = the fewest taps that solve the board from its shuffle (each tap turns one stone a quarter clockwise). */
export function par(board) {
return board.start.reduce((s, m, i) => s + turnsTo(m, board.sol[i]), 0);
}
/**
* Share grid (no spoilers): one square per stone. 🟩 stone ended in par turns or fewer, 🟨 a few extra turns,
* 🟧 many extra turns, ⬜ stone you never needed to touch.
*/
export function shareGrid(board, taps) {
const rows = [];
for (let y = 0; y < board.n; y++) {
let r = '';
for (let x = 0; x < board.n; x++) {
const i = y * board.n + x;
const need = turnsTo(board.start[i], board.sol[i]);
const used = taps[i] || 0;
r += need === 0 && used === 0 ? '⬜' : used <= Math.max(need, 1) ? '🟩' : used <= need + 4 ? '🟨' : '🟧';
}
rows.push(r);
}
return rows.join('\n');
}
export function fmtTime(ms) {
const s = Math.max(0, Math.round(ms / 1000));
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
}
/** Packs: the free daily is 6×6; the Fun Pass adds numbered packs. */
export const PACKS = [
{ id: 'daily', name: 'Daily', n: 6, free: true },
{ id: 'sprout', name: 'Sprout 5×5', n: 5, levels: 30, freeLevels: 3 },
{ id: 'moorland', name: 'Moorland 7×7', n: 7, levels: 100 },
{ id: 'highland', name: 'Highland 8×8', n: 8, levels: 100 },
{ id: 'summit', name: 'Summit 10×10', n: 10, levels: 50 },
];