254 lines
8.4 KiB
TypeScript
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;
|
|
}
|