import { createRng, shuffle } from "../rng"; export interface ZipPuzzle { size: number; path: [number, number][]; // solution path (all cells in order) numberedCells: Record; // "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(); 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, 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, 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 = {}; 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; }