"use client"; import { useState, useEffect, useCallback, useRef } from "react"; import { QueensPuzzle } from "@/lib/generators/queens"; import WinBanner from "./WinBanner"; interface Props { puzzle: QueensPuzzle; date: string; onSolve?: (elapsed: number) => void; } const REGION_COLORS = [ { bg: "#c8b8e8", border: "#9870c8" }, { bg: "#f5c898", border: "#d4986a" }, { bg: "#a8c8f0", border: "#6098d8" }, { bg: "#b8d8b0", border: "#78b870" }, { bg: "#f5a898", border: "#d86858" }, { bg: "#d8d8d8", border: "#a8a8a8" }, { bg: "#d8e888", border: "#b0c840" }, { bg: "#c8b8a8", border: "#a89878" }, { bg: "#f0b8c8", border: "#d880a0" }, { bg: "#a8e0d8", border: "#60b8b0" }, ]; type CellState = "empty" | "queen" | "mark"; interface HintInfo { explanation: string; // cells highlighted in blue (the "why" context) focusCells: Set; // cells highlighted in green (queen to place) or red (cells to eliminate) actionCells: Set; action: | { kind: "queen"; r: number; c: number } | { kind: "marks"; cells: [number, number][] }; } function CrownIcon({ size }: { size: number }) { return ( ); } function XIcon({ size }: { size: number }) { return ( ); } function checkWin(board: CellState[][], puzzle: QueensPuzzle): boolean { const { size, regions } = puzzle; const queens: [number, number][] = []; for (let r = 0; r < size; r++) for (let c = 0; c < size; c++) if (board[r][c] === "queen") queens.push([r, c]); if (queens.length !== size) return false; const rows = new Set(), cols = new Set(), regs = new Set(); for (const [r, c] of queens) { if (rows.has(r) || cols.has(c) || regs.has(regions[r][c])) return false; rows.add(r); cols.add(c); regs.add(regions[r][c]); } for (let i = 0; i < queens.length; i++) for (let j = i + 1; j < queens.length; j++) if (Math.abs(queens[i][0] - queens[j][0]) <= 1 && Math.abs(queens[i][1] - queens[j][1]) <= 1) return false; return true; } function getErrors(board: CellState[][], puzzle: QueensPuzzle): Set { const { size, regions } = puzzle; const errors = new Set(); const queens: [number, number][] = []; for (let r = 0; r < size; r++) for (let c = 0; c < size; c++) if (board[r][c] === "queen") queens.push([r, c]); const byRow = new Map(), byCol = new Map(), byReg = new Map(); for (const [r, c] of queens) { const reg = regions[r][c]; byRow.set(r, [...(byRow.get(r) || []), c]); byCol.set(c, [...(byCol.get(c) || []), r]); byReg.set(reg, [...(byReg.get(reg) || []), r * size + c]); } for (const [r, cs] of byRow) if (cs.length > 1) cs.forEach(c => errors.add(`${r},${c}`)); for (const [c, rs] of byCol) if (rs.length > 1) rs.forEach(r => errors.add(`${r},${c}`)); for (const [, cells] of byReg) if (cells.length > 1) cells.forEach(idx => errors.add(`${Math.floor(idx / size)},${idx % size}`)); for (let i = 0; i < queens.length; i++) for (let j = i + 1; j < queens.length; j++) if (Math.abs(queens[i][0] - queens[j][0]) <= 1 && Math.abs(queens[i][1] - queens[j][1]) <= 1) { errors.add(`${queens[i][0]},${queens[i][1]}`); errors.add(`${queens[j][0]},${queens[j][1]}`); } return errors; } // Logical hint finder: detects which rule applies and explains the reasoning function findLogicalHint(board: CellState[][], puzzle: QueensPuzzle): HintInfo | null { const { size, regions } = puzzle; const queens: [number, number][] = []; const queenRows = new Set(); const queenCols = new Set(); const queenRegs = new Set(); for (let r = 0; r < size; r++) for (let c = 0; c < size; c++) if (board[r][c] === "queen") { queens.push([r, c]); queenRows.add(r); queenCols.add(c); queenRegs.add(regions[r][c]); } // A cell is "possible" if no placed queen or user mark excludes it function isPossible(r: number, c: number): boolean { if (board[r][c] === "queen" || board[r][c] === "mark") return false; if (queenRows.has(r) || queenCols.has(c) || queenRegs.has(regions[r][c])) return false; for (const [qr, qc] of queens) if (Math.abs(r - qr) <= 1 && Math.abs(c - qc) <= 1) return false; return true; } const possSet = new Set(); for (let r = 0; r < size; r++) for (let c = 0; c < size; c++) if (isPossible(r, c)) possSet.add(`${r},${c}`); // Group all cells by region const byRegion = new Map(); for (let r = 0; r < size; r++) for (let c = 0; c < size; c++) { const reg = regions[r][c]; if (!byRegion.has(reg)) byRegion.set(reg, []); byRegion.get(reg)!.push([r, c]); } // Rule 1: A region has exactly one possible cell → forced queen for (const [reg, cells] of byRegion) { if (queenRegs.has(reg)) continue; const poss = cells.filter(([r, c]) => possSet.has(`${r},${c}`)); if (poss.length === 1) { const [r, c] = poss[0]; return { explanation: "Cette zone colorée (en bleu) n'a plus qu'une seule case disponible (en vert). Toutes les autres ont été éliminées par les couronnes déjà posées. La couronne de cette zone doit obligatoirement s'y trouver.", focusCells: new Set(cells.map(([r, c]) => `${r},${c}`)), actionCells: new Set([`${r},${c}`]), action: { kind: "queen", r, c }, }; } } // Rule 2: A row has exactly one possible cell → forced queen for (let r = 0; r < size; r++) { if (queenRows.has(r)) continue; const poss: [number, number][] = []; for (let c = 0; c < size; c++) if (possSet.has(`${r},${c}`)) poss.push([r, c]); if (poss.length === 1) { const [pr, pc] = poss[0]; return { explanation: `La ligne ${r + 1} (en bleu) n'a plus qu'une seule case disponible (en vert). La couronne qui doit occuper cette ligne n'a pas d'autre choix.`, focusCells: new Set(Array.from({ length: size }, (_, c) => `${r},${c}`)), actionCells: new Set([`${pr},${pc}`]), action: { kind: "queen", r: pr, c: pc }, }; } } // Rule 3: A column has exactly one possible cell → forced queen for (let c = 0; c < size; c++) { if (queenCols.has(c)) continue; const poss: [number, number][] = []; for (let r = 0; r < size; r++) if (possSet.has(`${r},${c}`)) poss.push([r, c]); if (poss.length === 1) { const [pr, pc] = poss[0]; return { explanation: `La colonne ${c + 1} (en bleu) n'a plus qu'une seule case disponible (en vert). La couronne qui doit occuper cette colonne n'a pas d'autre choix.`, focusCells: new Set(Array.from({ length: size }, (_, r) => `${r},${c}`)), actionCells: new Set([`${pr},${pc}`]), action: { kind: "queen", r: pr, c: pc }, }; } } // Rules 4+: Generalized naked subset — N regions confined to same N rows or cols // N=1: locked region, N=2: naked pair, N=3: naked triple, N=4: naked quad function* genCombinations(arr: T[], n: number, start = 0): Generator { if (n === 0) { yield []; return; } for (let i = start; i <= arr.length - n; i++) for (const rest of genCombinations(arr, n - 1, i + 1)) yield [arr[i], ...rest]; } const unsolved = [...byRegion.entries()].filter(([reg, cells]) => !queenRegs.has(reg) && cells.some(([r, c]) => possSet.has(`${r},${c}`)) ); for (let n = 1; n <= unsolved.length - 1; n++) { for (const combo of genCombinations(unsolved, n)) { const regSet = new Set(combo.map(([reg]) => reg)); const possCells = combo.flatMap(([, cells]) => cells.filter(([r, c]) => possSet.has(`${r},${c}`))); if (possCells.length === 0) continue; // Row subset const rowSet = new Set(possCells.map(([r]) => r)); if (rowSet.size === n) { const elimCells: [number, number][] = []; for (const lr of rowSet) for (let c = 0; c < size; c++) if (possSet.has(`${lr},${c}`) && !regSet.has(regions[lr][c])) elimCells.push([lr, c]); if (elimCells.length > 0) { const rowNames = [...rowSet].sort((a, b) => a - b).map(r => `ligne ${r + 1}`).join(" et "); return { explanation: n === 1 ? `Toutes les cases disponibles de la zone bleue se trouvent sur la ${rowNames}. La couronne de cette zone occupera forcément cette ligne — aucune autre zone ne peut donc y placer de couronne. Les cases rouges sont éliminées.` : `Ces ${n} zones (en bleu) ne peuvent se placer que sur les ${rowNames}. Ces lignes leur sont réservées — les autres zones ne peuvent plus y placer de couronne. Les cases rouges sont éliminées.`, focusCells: new Set(possCells.map(([r, c]) => `${r},${c}`)), actionCells: new Set(elimCells.map(([r, c]) => `${r},${c}`)), action: { kind: "marks", cells: elimCells }, }; } } // Col subset const colSet = new Set(possCells.map(([, c]) => c)); if (colSet.size === n) { const elimCells: [number, number][] = []; for (const lc of colSet) for (let r = 0; r < size; r++) if (possSet.has(`${r},${lc}`) && !regSet.has(regions[r][lc])) elimCells.push([r, lc]); if (elimCells.length > 0) { const colNames = [...colSet].sort((a, b) => a - b).map(c => `colonne ${c + 1}`).join(" et "); return { explanation: n === 1 ? `Toutes les cases disponibles de la zone bleue se trouvent dans la ${colNames}. La couronne de cette zone occupera forcément cette colonne — aucune autre zone ne peut donc y placer de couronne. Les cases rouges sont éliminées.` : `Ces ${n} zones (en bleu) ne peuvent se placer que dans les ${colNames}. Ces colonnes leur sont réservées — les autres zones ne peuvent plus y placer de couronne. Les cases rouges sont éliminées.`, focusCells: new Set(possCells.map(([r, c]) => `${r},${c}`)), actionCells: new Set(elimCells.map(([r, c]) => `${r},${c}`)), action: { kind: "marks", cells: elimCells }, }; } } } } return null; } const STORAGE_KEY = (date: string) => `queens-v2-${date}`; const DIAGONAL_ERROR = "repeating-linear-gradient(-45deg, rgba(220,38,38,0.55) 0px, rgba(220,38,38,0.55) 3px, rgba(220,38,38,0.2) 3px, rgba(220,38,38,0.2) 6px)"; const MAX_CELL = 52; export default function QueensBoard({ puzzle, date, onSolve }: Props) { const { size, regions } = puzzle; const [CELL, setCELL] = useState(() => typeof window === "undefined" ? MAX_CELL : Math.min(MAX_CELL, Math.floor((window.innerWidth - 32) / size)) ); const [board, setBoard] = useState(() => { if (typeof window !== "undefined") { const s = localStorage.getItem(STORAGE_KEY(date)); if (s) return JSON.parse(s); } return Array.from({ length: size }, () => Array(size).fill("empty")); }); const [won, setWon] = useState(false); const [elapsed, setElapsed] = useState(0); const [t0] = useState(() => Date.now()); const [hintInfo, setHintInfo] = useState(null); const history = useRef([]); const drag = useRef<{ action: "mark" | "clear"; origin: string; originApplied: boolean; lastCell: string; moved: boolean; } | null>(null); const boardSnap = useRef(board); const gridRef = useRef(null); useEffect(() => { boardSnap.current = board; }, [board]); useEffect(() => { const update = () => setCELL(Math.min(MAX_CELL, Math.floor((window.innerWidth - 32) / size))); window.addEventListener("resize", update); return () => window.removeEventListener("resize", update); }, [size]); useEffect(() => { if (won) return; const id = setInterval(() => setElapsed(Math.floor((Date.now() - t0) / 1000)), 500); return () => clearInterval(id); }, [won, t0]); useEffect(() => { localStorage.setItem(STORAGE_KEY(date), JSON.stringify(board)); if (!won && checkWin(board, puzzle)) { setWon(true); onSolve?.(Math.floor((Date.now() - t0) / 1000)); } }, [board, date, puzzle, won, onSolve, t0]); useEffect(() => { const stop = () => { drag.current = null; }; window.addEventListener("pointerup", stop); return () => window.removeEventListener("pointerup", stop); }, []); const errors = won ? new Set() : getErrors(board, puzzle); const queensPlaced = board.flat().filter(c => c === "queen").length; const fmt = (s: number) => `${String(Math.floor(s / 60)).padStart(2, "0")}:${String(s % 60).padStart(2, "0")}`; const applyDragToCell = useCallback((r: number, c: number) => { if (!drag.current || won) return; const { action } = drag.current; setBoard(prev => { const cur = prev[r][c]; if (action === "mark" && cur === "empty") { const next = prev.map(row => [...row]); next[r][c] = "mark"; return next; } if (action === "clear" && cur === "mark") { const next = prev.map(row => [...row]); next[r][c] = "empty"; return next; } return prev; }); }, [won]); const undo = useCallback(() => { if (won || history.current.length === 0) return; setBoard(history.current.pop()!); setHintInfo(null); }, [won]); useEffect(() => { const handler = (e: KeyboardEvent) => { if ((e.ctrlKey || e.metaKey) && e.key === "z") { e.preventDefault(); undo(); } }; window.addEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler); }, [undo]); const handlePointerDown = useCallback((r: number, c: number, e: React.PointerEvent) => { if (won) return; e.preventDefault(); history.current.push(boardSnap.current.map(row => [...row])); setHintInfo(null); const cur = boardSnap.current[r][c]; const action: "mark" | "clear" = cur === "mark" ? "clear" : "mark"; drag.current = { action, origin: `${r},${c}`, originApplied: false, lastCell: `${r},${c}`, moved: false }; }, [won]); const handlePointerMove = useCallback((e: React.PointerEvent) => { if (!drag.current || won) return; if (!gridRef.current) return; const rect = gridRef.current.getBoundingClientRect(); const c = Math.floor((e.clientX - rect.left) / CELL); const r = Math.floor((e.clientY - rect.top) / CELL); if (r < 0 || r >= size || c < 0 || c >= size) return; const key = `${r},${c}`; if (key === drag.current.lastCell) return; // On first move to a different cell, also apply action to origin if (!drag.current.originApplied) { const [or, oc] = drag.current.origin.split(",").map(Number); applyDragToCell(or, oc); drag.current.originApplied = true; } drag.current.moved = true; drag.current.lastCell = key; applyDragToCell(r, c); }, [won, applyDragToCell, size, CELL]); const handlePointerUp = useCallback((r: number, c: number) => { if (!drag.current || won) return; if (!drag.current.moved) { setBoard(prev => { const next = prev.map(row => [...row]); next[r][c] = next[r][c] === "empty" ? "mark" : next[r][c] === "mark" ? "queen" : "empty"; return next; }); } drag.current = null; }, [won]); const reset = () => { history.current = []; setBoard(Array.from({ length: size }, () => Array(size).fill("empty"))); setWon(false); setHintInfo(null); }; const handleHint = () => { if (won) return; if (hintInfo) { setHintInfo(null); return; } // toggle off const hint = findLogicalHint(boardSnap.current, puzzle); if (hint) { setHintInfo(hint); } }; const applyHint = () => { if (!hintInfo) return; if (hintInfo.action.kind === "queen") { const { r, c } = hintInfo.action; setBoard(prev => { const next = prev.map(row => [...row]); next[r][c] = "queen"; return next; }); } else { const { cells } = hintInfo.action; setBoard(prev => { const next = prev.map(row => [...row]); for (const [r, c] of cells) if (next[r][c] === "empty") next[r][c] = "mark"; return next; }); } setHintInfo(null); }; return (
{queensPlaced} / {size} reines {fmt(elapsed)}
{won && } {/* Hint explanation panel */} {hintInfo && (

{hintInfo.explanation}

{hintInfo.actionCells.size > 0 && ( )}
)} {/* Board */}
{Array.from({ length: size }, (_, r) => Array.from({ length: size }, (_, c) => { const reg = regions[r][c]; const state = board[r][c]; const err = errors.has(`${r},${c}`); const color = REGION_COLORS[reg % REGION_COLORS.length]; const key = `${r},${c}`; const bTop = r > 0 && regions[r - 1][c] === reg ? "1px solid rgba(0,0,0,0.08)" : "2px solid #1a1a1a"; const bLeft = c > 0 && regions[r][c - 1] === reg ? "1px solid rgba(0,0,0,0.08)" : "2px solid #1a1a1a"; const bBottom = r < size - 1 && regions[r + 1][c] === reg ? "1px solid rgba(0,0,0,0.08)" : "2px solid #1a1a1a"; const bRight = c < size - 1 && regions[r][c + 1] === reg ? "1px solid rgba(0,0,0,0.08)" : "2px solid #1a1a1a"; // Hint highlighting overrides normal bg let bg = color.bg; const bgImage = err ? DIAGONAL_ERROR : undefined; if (!err && hintInfo) { if (hintInfo.action.kind === "queen" && hintInfo.actionCells.has(key)) { bg = "#bbf7d0"; // green: forced queen } else if (hintInfo.action.kind === "marks" && hintInfo.actionCells.has(key)) { bg = "#fecaca"; // red: cells to eliminate } else if (hintInfo.focusCells.has(key)) { bg = "#bfdbfe"; // blue: context cells } } return (
handlePointerDown(r, c, e)} onPointerUp={() => handlePointerUp(r, c)} style={{ width: CELL, height: CELL, backgroundColor: bg, backgroundImage: bgImage, borderTop: bTop, borderLeft: bLeft, borderBottom: bBottom, borderRight: bRight, cursor: won ? "default" : "pointer", display: "flex", alignItems: "center", justifyContent: "center", userSelect: "none", transition: "background-color 0.12s", }} > {state === "queen" && ( )} {state === "mark" && ( )}
); }) )}
1 clic = ✕ · 2 clics = couronne · glisser = ✕ en série
{!won && ( )}

Une couronne par ligne, par colonne et par zone colorée. Les couronnes ne peuvent pas se toucher.

); }