puzzle-trainer/generators/zip.ts
2026-05-23 01:05:21 +00:00

254 lines
8.4 KiB
TypeScript

import { createRng, shuffle } from "../rng";
export interface ZipPuzzle {
size: number;
path: [number, number][]; // solution path (all cells in order)
numberedCells: Record<string, number>; // "r,c" → waypoint number (1-based)
// walls[r][c] bitmask: bit0=right wall, bit1=bottom wall, bit2=left wall, bit3=top wall
walls: number[][];
}
export function canMove(walls: number[][], r1: number, c1: number, r2: number, c2: number): boolean {
const dr = r2 - r1, dc = c2 - c1;
if (dr === 0 && dc === 1) return !(walls[r1][c1] & 1); // right
if (dr === 1 && dc === 0) return !(walls[r1][c1] & 2); // down
if (dr === 0 && dc === -1) return !(walls[r1][c1] & 4); // left
if (dr === -1 && dc === 0) return !(walls[r1][c1] & 8); // up
return false;
}
function generateHamiltonianPath(size: number, rng: () => number): [number, number][] | null {
const total = size * size;
const visited = Array.from({ length: size }, () => Array(size).fill(false));
const path: [number, number][] = [];
const dirs: [number, number][] = [[-1, 0], [1, 0], [0, -1], [0, 1]];
const startR = Math.floor(rng() * size);
const startC = Math.floor(rng() * size);
visited[startR][startC] = true;
path.push([startR, startC]);
// Warnsdorff heuristic: prefer neighbors with fewer onward moves
function warnsdorffScore(r: number, c: number): number {
let n = 0;
for (const [dr, dc] of dirs) {
const nr = r + dr, nc = c + dc;
if (nr >= 0 && nr < size && nc >= 0 && nc < size && !visited[nr][nc]) n++;
}
return n;
}
function bt(): boolean {
if (path.length === total) return true;
const [r, c] = path[path.length - 1];
const nbrs = shuffle([...dirs], rng)
.map(([dr, dc]) => [r + dr, c + dc] as [number, number])
.filter(([nr, nc]) => nr >= 0 && nr < size && nc >= 0 && nc < size && !visited[nr][nc])
.sort((a, b) => warnsdorffScore(a[0], a[1]) - warnsdorffScore(b[0], b[1]));
for (const [nr, nc] of nbrs) {
visited[nr][nc] = true;
path.push([nr, nc]);
if (bt()) return true;
path.pop();
visited[nr][nc] = false;
}
return false;
}
return bt() ? path : null;
}
// Build walls on ALL non-path-adjacent edges
function buildAllWalls(size: number, path: [number, number][]): number[][] {
const openEdges = new Set<string>();
for (let i = 0; i < path.length - 1; i++) {
const [r1, c1] = path[i], [r2, c2] = path[i + 1];
const key = r1 === r2
? `h:${r1},${Math.min(c1, c2)}`
: `v:${Math.min(r1, r2)},${c1}`;
openEdges.add(key);
}
const walls: number[][] = Array.from({ length: size }, () => Array(size).fill(0));
for (let r = 0; r < size; r++) {
for (let c = 0; c < size; c++) {
if (c < size - 1 && !openEdges.has(`h:${r},${c}`)) {
walls[r][c] |= 1;
walls[r][c + 1] |= 4;
}
if (r < size - 1 && !openEdges.has(`v:${r},${c}`)) {
walls[r][c] |= 2;
walls[r + 1][c] |= 8;
}
}
}
return walls;
}
// Count Hamiltonian paths visiting waypoints in order — stops at limit
function countZipSolutions(
size: number,
walls: number[][],
numberedCells: Record<string, number>,
limit = 2
): number {
const waypointOrder = Object.entries(numberedCells)
.sort(([, a], [, b]) => a - b)
.map(([k]) => k);
if (!waypointOrder.length) return 0;
const [startR, startC] = waypointOrder[0].split(",").map(Number);
const total = size * size;
let count = 0;
const visited = Array.from({ length: size }, () => Array(size).fill(false));
visited[startR][startC] = true;
let visitedCount = 1;
const dirs: [number, number][] = [[-1, 0], [1, 0], [0, -1], [0, 1]];
// Island pruning: BFS from (r,c) through unvisited cells — if unreachable cells exist, prune
const bfsQueue = new Int32Array(total);
const bfsSeen = new Uint8Array(total);
function allUnvisitedReachable(r: number, c: number): boolean {
const remaining = total - visitedCount;
if (remaining === 0) return true;
bfsSeen.fill(0);
let head = 0, tail = 0, found = 0;
bfsQueue[tail++] = r * size + c;
bfsSeen[r * size + c] = 1;
while (head < tail) {
const idx = bfsQueue[head++];
const cr = (idx / size) | 0, cc = idx % size;
for (const [dr, dc] of dirs) {
const nr = cr + dr, nc = cc + dc;
if (nr < 0 || nr >= size || nc < 0 || nc >= size) continue;
if (visited[nr][nc]) continue;
if (!canMove(walls, cr, cc, nr, nc)) continue;
const nk = nr * size + nc;
if (bfsSeen[nk]) continue;
bfsSeen[nk] = 1;
bfsQueue[tail++] = nk;
found++;
if (found === remaining) return true;
}
}
return found === remaining;
}
function dfs(r: number, c: number, nextWpIdx: number): void {
if (count >= limit) return;
if (visitedCount === total) {
if (nextWpIdx === waypointOrder.length) count++;
return;
}
for (const [dr, dc] of dirs) {
const nr = r + dr, nc = c + dc;
if (nr < 0 || nr >= size || nc < 0 || nc >= size) continue;
if (visited[nr][nc]) continue;
if (!canMove(walls, r, c, nr, nc)) continue;
const nextKey = `${nr},${nc}`;
const wpNum = numberedCells[nextKey];
// If the next cell is a waypoint, it must be the expected one
if (wpNum !== undefined) {
if (nextWpIdx >= waypointOrder.length || waypointOrder[nextWpIdx] !== nextKey) continue;
}
visited[nr][nc] = true;
visitedCount++;
if (allUnvisitedReachable(nr, nc)) {
dfs(nr, nc, wpNum !== undefined ? nextWpIdx + 1 : nextWpIdx);
}
visited[nr][nc] = false;
visitedCount--;
}
}
dfs(startR, startC, 1); // already at waypoint[0], so next expected is index 1
return count;
}
// Remove walls while uniqueness holds → sparse, interesting puzzle
function buildMinimalWalls(
size: number,
path: [number, number][],
numberedCells: Record<string, number>,
rng: () => number
): number[][] {
const walls = buildAllWalls(size, path);
// Collect all removable walls (non-path-adjacent edges)
const candidates: Array<{ r1: number; c1: number; r2: number; c2: number }> = [];
for (let r = 0; r < size; r++) {
for (let c = 0; c < size; c++) {
if (c < size - 1 && walls[r][c] & 1) candidates.push({ r1: r, c1: c, r2: r, c2: c + 1 });
if (r < size - 1 && walls[r][c] & 2) candidates.push({ r1: r, c1: c, r2: r + 1, c2: c });
}
}
for (const { r1, c1, r2, c2 } of shuffle(candidates, rng)) {
const dc = c2 - c1;
// Remove wall
if (dc === 1) { walls[r1][c1] &= ~1; walls[r2][c2] &= ~4; }
else { walls[r1][c1] &= ~2; walls[r2][c2] &= ~8; }
if (countZipSolutions(size, walls, numberedCells) !== 1) {
// Restore wall — this wall is necessary
if (dc === 1) { walls[r1][c1] |= 1; walls[r2][c2] |= 4; }
else { walls[r1][c1] |= 2; walls[r2][c2] |= 8; }
}
}
return walls;
}
export function zipSizeForDate(date: string): number {
const start = new Date("2024-01-01").getTime();
const days = Math.max(0, Math.floor((new Date(date).getTime() - start) / 86400000));
if (days < 200) return 6;
if (days < 500) return 7;
return 8;
}
export function generateZip(date: string, size?: number): ZipPuzzle {
const resolvedSize = size ?? zipSizeForDate(date);
const seed = date.split("-").reduce((a, n) => a * 1000 + parseInt(n), 0) + 777;
const rng = createRng(seed);
let path: [number, number][] | null = null;
let attempts = 0;
while (!path && attempts < 30) {
path = generateHamiltonianPath(resolvedSize, rng);
attempts++;
}
if (!path) path = snakePath(resolvedSize);
// LinkedIn Zip always has exactly 10 waypoints
const total = path.length;
const numWaypoints = 10;
const step = Math.floor((total - 1) / (numWaypoints - 1));
const waypointIndices = Array.from({ length: numWaypoints }, (_, i) =>
i === numWaypoints - 1 ? total - 1 : i * step
);
const numberedCells: Record<string, number> = {};
waypointIndices.forEach((idx, i) => {
const [r, c] = path![idx];
numberedCells[`${r},${c}`] = i + 1;
});
const walls = buildMinimalWalls(resolvedSize, path, numberedCells, rng);
return { size: resolvedSize, path, numberedCells, walls };
}
function snakePath(size: number): [number, number][] {
const path: [number, number][] = [];
for (let r = 0; r < size; r++)
for (let c = r % 2 === 0 ? 0 : size - 1; r % 2 === 0 ? c < size : c >= 0; r % 2 === 0 ? c++ : c--)
path.push([r, c]);
return path;
}