215 lines
7.8 KiB
TypeScript
215 lines
7.8 KiB
TypeScript
import { createRng, shuffle } from "../rng";
|
||
|
||
export type Cell = "sun" | "moon" | null;
|
||
export type EdgeConstraint = "=" | "x" | null;
|
||
|
||
export interface TangoPuzzle {
|
||
size: number; // always 6
|
||
given: Cell[][];
|
||
hEdges: EdgeConstraint[][];
|
||
vEdges: EdgeConstraint[][];
|
||
solution: Cell[][];
|
||
}
|
||
|
||
// Check if placing v at (r,c) is consistent with current partial grid
|
||
function consistent(
|
||
grid: Cell[][], hEdges: EdgeConstraint[][], vEdges: EdgeConstraint[][],
|
||
r: number, c: number, v: Cell, size: number
|
||
): boolean {
|
||
// Row balance
|
||
let rs = 0, rm = 0;
|
||
for (let j = 0; j < size; j++) {
|
||
const cell = j === c ? v : grid[r][j];
|
||
if (cell === "sun") rs++; else if (cell === "moon") rm++;
|
||
}
|
||
if (rs > size / 2 || rm > size / 2) return false;
|
||
|
||
// Col balance
|
||
let cs = 0, cm = 0;
|
||
for (let i = 0; i < size; i++) {
|
||
const cell = i === r ? v : grid[i][c];
|
||
if (cell === "sun") cs++; else if (cell === "moon") cm++;
|
||
}
|
||
if (cs > size / 2 || cm > size / 2) return false;
|
||
|
||
// No 3 consecutive in row
|
||
for (let j = Math.max(0, c - 2); j <= Math.min(size - 3, c); j++) {
|
||
const a = j === c ? v : grid[r][j];
|
||
const b = j + 1 === c ? v : grid[r][j + 1];
|
||
const d = j + 2 === c ? v : grid[r][j + 2];
|
||
if (a && a === b && b === d) return false;
|
||
}
|
||
|
||
// No 3 consecutive in col
|
||
for (let i = Math.max(0, r - 2); i <= Math.min(size - 3, r); i++) {
|
||
const a = i === r ? v : grid[i][c];
|
||
const b = i + 1 === r ? v : grid[i + 1][c];
|
||
const d = i + 2 === r ? v : grid[i + 2][c];
|
||
if (a && a === b && b === d) return false;
|
||
}
|
||
|
||
// Edge constraints (only check placed neighbours)
|
||
const chk = (e: EdgeConstraint, nb: Cell) => {
|
||
if (!e || !nb) return true;
|
||
if (e === "=" && nb !== v) return false;
|
||
if (e === "x" && nb === v) return false;
|
||
return true;
|
||
};
|
||
if (!chk(c > 0 ? hEdges[r][c - 1] : null, grid[r][c - 1])) return false;
|
||
if (!chk(c < size - 1 ? hEdges[r][c] : null, grid[r][c + 1])) return false;
|
||
if (!chk(r > 0 ? vEdges[r - 1][c] : null, grid[r - 1]?.[c] ?? null)) return false;
|
||
if (!chk(r < size - 1 ? vEdges[r][c] : null, grid[r + 1]?.[c] ?? null)) return false;
|
||
|
||
// Unique rows: if this row is now complete, check it doesn't duplicate an earlier complete row
|
||
const rowComplete = grid[r].every((cell, j) => (j === c ? v : cell) !== null);
|
||
if (rowComplete) {
|
||
const thisRow = grid[r].map((cell, j) => j === c ? v : cell);
|
||
for (let i = 0; i < r; i++) {
|
||
if (grid[i].every(cell => cell !== null) &&
|
||
grid[i].every((cell, j) => cell === thisRow[j])) return false;
|
||
}
|
||
}
|
||
|
||
// Unique cols: if this col is now complete, check it doesn't duplicate an earlier complete col
|
||
const colComplete = Array.from({ length: size }, (_, i) => i === r ? v : grid[i][c]).every(cell => cell !== null);
|
||
if (colComplete) {
|
||
const thisCol = Array.from({ length: size }, (_, i) => i === r ? v : grid[i][c]);
|
||
for (let j = 0; j < c; j++) {
|
||
const other = Array.from({ length: size }, (_, i) => grid[i][j]);
|
||
if (other.every(cell => cell !== null) &&
|
||
other.every((cell, i) => cell === thisCol[i])) return false;
|
||
}
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
// Count solutions (stops at `limit`)
|
||
function countSolutions(
|
||
given: Cell[][], hEdges: EdgeConstraint[][], vEdges: EdgeConstraint[][],
|
||
size: number, limit = 2
|
||
): number {
|
||
const grid = given.map(r => [...r]);
|
||
let count = 0;
|
||
function bt(pos: number): void {
|
||
if (count >= limit) return;
|
||
if (pos === size * size) { count++; return; }
|
||
const r = Math.floor(pos / size), c = pos % size;
|
||
if (grid[r][c] !== null) { bt(pos + 1); return; }
|
||
for (const v of ["sun", "moon"] as Cell[]) {
|
||
if (consistent(grid, hEdges, vEdges, r, c, v, size)) {
|
||
grid[r][c] = v;
|
||
bt(pos + 1);
|
||
grid[r][c] = null;
|
||
}
|
||
}
|
||
}
|
||
bt(0);
|
||
return count;
|
||
}
|
||
|
||
function generateSolution(size: number, rng: () => number): Cell[][] {
|
||
const grid: Cell[][] = Array.from({ length: size }, () => Array(size).fill(null));
|
||
const noHEdges: EdgeConstraint[][] = Array.from({ length: size }, () => Array(size - 1).fill(null));
|
||
const noVEdges: EdgeConstraint[][] = Array.from({ length: size - 1 }, () => Array(size).fill(null));
|
||
function bt(pos: number): boolean {
|
||
if (pos === size * size) return true;
|
||
const r = Math.floor(pos / size), c = pos % size;
|
||
const opts: Cell[] = rng() > 0.5 ? ["sun", "moon"] : ["moon", "sun"];
|
||
for (const v of opts) {
|
||
if (consistent(grid, noHEdges, noVEdges, r, c, v, size)) {
|
||
grid[r][c] = v;
|
||
if (bt(pos + 1)) return true;
|
||
grid[r][c] = null;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
bt(0);
|
||
return grid;
|
||
}
|
||
|
||
|
||
/**
|
||
* Check that the puzzle can be solved purely by logical deduction (no guessing).
|
||
* At each step, at least one empty cell must be forced (only one consistent value).
|
||
* This ensures the puzzle never requires trial-and-error.
|
||
*/
|
||
function isHumanSolvable(
|
||
given: Cell[][], hEdges: EdgeConstraint[][], vEdges: EdgeConstraint[][], size: number
|
||
): boolean {
|
||
const grid = given.map(r => [...r]);
|
||
let progress = true;
|
||
while (progress) {
|
||
progress = false;
|
||
for (let r = 0; r < size; r++) {
|
||
for (let c = 0; c < size; c++) {
|
||
if (grid[r][c] !== null) continue;
|
||
const canSun = consistent(grid, hEdges, vEdges, r, c, "sun", size);
|
||
const canMoon = consistent(grid, hEdges, vEdges, r, c, "moon", size);
|
||
if (!canSun && !canMoon) return false; // contradiction
|
||
if (canSun !== canMoon) {
|
||
grid[r][c] = canSun ? "sun" : "moon"; // forced
|
||
progress = true;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return grid.every(row => row.every(c => c !== null));
|
||
}
|
||
|
||
export function generateTango(date: string): TangoPuzzle {
|
||
const seed = date.split("-").reduce((a, n) => a * 1000 + parseInt(n), 0) + 999;
|
||
const rng = createRng(seed);
|
||
const size = 6;
|
||
|
||
const solution = generateSolution(size, rng);
|
||
|
||
// Start with ALL edges as constraints
|
||
const hEdges: EdgeConstraint[][] = Array.from({ length: size }, () => Array(size - 1).fill(null));
|
||
const vEdges: EdgeConstraint[][] = Array.from({ length: size - 1 }, () => Array(size).fill(null));
|
||
for (let r = 0; r < size; r++)
|
||
for (let c = 0; c < size - 1; c++)
|
||
hEdges[r][c] = solution[r][c] === solution[r][c + 1] ? "=" : "x";
|
||
for (let r = 0; r < size - 1; r++)
|
||
for (let c = 0; c < size; c++)
|
||
vEdges[r][c] = solution[r][c] === solution[r + 1][c] ? "=" : "x";
|
||
|
||
// Build shuffled edge list
|
||
const edges: Array<{ type: "h" | "v"; r: number; c: number }> = [];
|
||
for (let r = 0; r < size; r++)
|
||
for (let c = 0; c < size - 1; c++) edges.push({ type: "h", r, c });
|
||
for (let r = 0; r < size - 1; r++)
|
||
for (let c = 0; c < size; c++) edges.push({ type: "v", r, c });
|
||
|
||
const given: Cell[][] = Array.from({ length: size }, () => Array(size).fill(null));
|
||
|
||
// Place given cells first (like LinkedIn: ~9-11 pre-placed sun/moon values)
|
||
const allCells: [number, number][] = [];
|
||
for (let r = 0; r < size; r++) for (let c = 0; c < size; c++) allCells.push([r, c]);
|
||
const cellOrder = shuffle(allCells, rng);
|
||
|
||
const targetGivens = 9 + Math.floor(rng() * 3); // 9–11 given cells
|
||
for (const [r, c] of cellOrder) {
|
||
if (given.flat().filter(Boolean).length >= targetGivens) break;
|
||
given[r][c] = solution[r][c];
|
||
if (countSolutions(given, hEdges, vEdges, size) > 1) given[r][c] = null;
|
||
}
|
||
|
||
// Remove edges greedily while maintaining unique solution
|
||
for (const e of shuffle(edges, rng)) {
|
||
if (e.type === "h") {
|
||
const old = hEdges[e.r][e.c];
|
||
hEdges[e.r][e.c] = null;
|
||
if (countSolutions(given, hEdges, vEdges, size) !== 1 ||
|
||
!isHumanSolvable(given, hEdges, vEdges, size)) hEdges[e.r][e.c] = old;
|
||
} else {
|
||
const old = vEdges[e.r][e.c];
|
||
vEdges[e.r][e.c] = null;
|
||
if (countSolutions(given, hEdges, vEdges, size) !== 1 ||
|
||
!isHumanSolvable(given, hEdges, vEdges, size)) vEdges[e.r][e.c] = old;
|
||
}
|
||
}
|
||
|
||
return { size, given, hEdges, vEdges, solution };
|
||
}
|