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

192 lines
5.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { createRng, shuffle } from "../rng";
// Legacy export — board still imports it but we no longer use it for hints
export type ShapeHint = "free";
export interface Region {
id: number;
cells: [number, number][]; // all cells in grid
hintCell: [number, number]; // topmost-leftmost cell (always in solution)
relCells: [number, number][]; // shape normalised to (0,0) origin
previewRows: number;
previewCols: number;
size: number;
color: string;
}
export interface PatchesPuzzle {
size: number; // 6
regions: Region[];
grid: number[][]; // grid[r][c] = regionId
}
export const PATCH_COLORS = [
"#e8b040", // gold
"#4db86e", // green
"#4a9fd4", // blue
"#e05050", // red
"#e07830", // orange
"#30b8b0", // teal
"#9060c8", // purple
"#d06898", // pink
];
/** Place N seeds spread across the grid (no two adjacent). */
function placeSeeds(size: number, n: number, rng: () => number): [number, number][] {
const all: [number, number][] = [];
for (let r = 0; r < size; r++)
for (let c = 0; c < size; c++)
all.push([r, c]);
const pool = shuffle(all, rng);
const seeds: [number, number][] = [];
const banned = new Set<string>();
for (const [r, c] of pool) {
if (seeds.length >= n) break;
if (banned.has(`${r},${c}`)) continue;
seeds.push([r, c]);
// Ban cell + its orthogonal neighbours (avoid touching seeds)
for (const [dr, dc] of [[0,0],[-1,0],[1,0],[0,-1],[0,1]])
banned.add(`${r+dr},${c+dc}`);
}
return seeds;
}
/**
* Grow N polyomino regions from seeds, then flood-fill any remaining cells.
* Returns a size×size grid where grid[r][c] = regionId.
*/
function growPolyominos(size: number, seeds: [number, number][], rng: () => number): number[][] {
const grid: number[][] = Array.from({ length: size }, () => Array(size).fill(-1));
const sizes: number[] = Array(seeds.length).fill(0);
const n = seeds.length;
const target = Math.round((size * size) / n);
// Place seeds
for (let i = 0; i < n; i++) {
grid[seeds[i][0]][seeds[i][1]] = i;
sizes[i] = 1;
}
// BFS frontier per region
const ADJ: [number, number][] = [[-1,0],[1,0],[0,-1],[0,1]];
// Build initial frontiers
const frontiers: Set<string>[] = Array.from({ length: n }, (_, id) => {
const s = new Set<string>();
const [sr, sc] = seeds[id];
for (const [dr, dc] of ADJ) {
const nr = sr+dr, nc = sc+dc;
if (nr>=0 && nr<size && nc>=0 && nc<size && grid[nr][nc] === -1)
s.add(`${nr},${nc}`);
}
return s;
});
// Grow until each region hits its target (randomised round-robin)
let anyGrew = true;
while (anyGrew) {
anyGrew = false;
const order = shuffle(Array.from({ length: n }, (_, i) => i), rng);
for (const id of order) {
if (sizes[id] >= target) continue;
// Pick a random free frontier cell
const candidates = [...frontiers[id]].filter(k => {
const [r, c] = k.split(",").map(Number);
return grid[r][c] === -1;
});
if (!candidates.length) continue;
const k = candidates[Math.floor(rng() * candidates.length)];
const [nr, nc] = k.split(",").map(Number);
if (grid[nr][nc] !== -1) { frontiers[id].delete(k); continue; } // race condition
grid[nr][nc] = id;
sizes[id]++;
frontiers[id].delete(k);
anyGrew = true;
// Expand frontier
for (const [dr, dc] of ADJ) {
const nnr = nr+dr, nnc = nc+dc;
if (nnr>=0 && nnr<size && nnc>=0 && nnc<size && grid[nnr][nnc] === -1)
frontiers[id].add(`${nnr},${nnc}`);
}
}
}
// Flood-fill unassigned cells → smallest adjacent region
let changed = true;
while (changed) {
changed = false;
for (let r = 0; r < size; r++)
for (let c = 0; c < size; c++) {
if (grid[r][c] !== -1) continue;
let best = -1, bestSz = Infinity;
for (const [dr, dc] of ADJ) {
const nr = r+dr, nc = c+dc;
if (nr<0 || nr>=size || nc<0 || nc>=size) continue;
const id = grid[nr][nc];
if (id === -1) continue;
if (sizes[id] < bestSz) { bestSz = sizes[id]; best = id; }
}
if (best !== -1) { grid[r][c] = best; sizes[best]++; changed = true; }
}
}
return grid;
}
export function generatePatches(date: string): PatchesPuzzle {
const seed = date.split("-").reduce((a, n) => a * 1000 + parseInt(n), 0) + 333;
const rng = createRng(seed);
const size = 6;
// 7 or 8 regions → avg 4.55 cells each, with interesting shapes
const n = 7 + Math.floor(rng() * 2);
const seeds = placeSeeds(size, n, rng);
const grid = growPolyominos(size, seeds, rng);
const numRegions = Math.max(...grid.flat()) + 1;
// Collect cells per region
const regionCells = new Map<number, [number, number][]>();
for (let r = 0; r < size; r++)
for (let c = 0; c < size; c++) {
const id = grid[r][c];
if (!regionCells.has(id)) regionCells.set(id, []);
regionCells.get(id)!.push([r, c]);
}
const colors = shuffle([...PATCH_COLORS], rng);
const regions: Region[] = [];
for (let id = 0; id < numRegions; id++) {
const cells = regionCells.get(id) ?? [];
if (!cells.length) continue;
// Hint cell = topmost row, then leftmost col
const hintCell = cells.reduce((best, cur) =>
cur[0] < best[0] || (cur[0] === best[0] && cur[1] < best[1]) ? cur : best
);
// Normalised shape (origin at 0,0)
const minR = Math.min(...cells.map(([r]) => r));
const minC = Math.min(...cells.map(([, c]) => c));
const relCells: [number, number][] = cells.map(([r, c]) => [r - minR, c - minC]);
const previewRows = Math.max(...relCells.map(([r]) => r)) + 1;
const previewCols = Math.max(...relCells.map(([, c]) => c)) + 1;
regions.push({
id,
cells,
hintCell,
relCells,
previewRows,
previewCols,
size: cells.length,
color: colors[id % colors.length],
});
}
return { size, regions, grid };
}