Download script.js from nayohan/Sudoku: direct link, hf CLI and curl.
- Browser
- Download file 26.3 kB
-
https://huggingface.co/spaces/nayohan/Sudoku/resolve/main/script.js
- Command line
-
hf download hf://spaces/nayohan/Sudoku/script.js
-
curl -L -o script.js https://huggingface.co/spaces/nayohan/Sudoku/resolve/main/script.js
26.3 kB
| document.addEventListener('DOMContentLoaded', () => { | |
| const BASE_PUZZLE = '530070000600195000098000060800060003400803001700020006060000280000419005000080079'; | |
| const TECHNIQUE_LABELS = { | |
| 'naked-single': '단일 후보', | |
| 'hidden-single-row': '행 숨은 싱글', | |
| 'hidden-single-col': '열 숨은 싱글', | |
| 'hidden-single-box': '박스 숨은 싱글' | |
| }; | |
| const dom = { | |
| board: document.getElementById('sudoku-board'), | |
| newGameBtn: document.getElementById('new-game-btn'), | |
| hintBtn: document.getElementById('hint-btn'), | |
| applyHintBtn: document.getElementById('apply-hint-btn'), | |
| checkBtn: document.getElementById('check-btn'), | |
| resetBtn: document.getElementById('reset-btn'), | |
| hintMessage: document.getElementById('hint-message'), | |
| gameStatus: document.getElementById('game-status'), | |
| puzzleMeta: document.getElementById('puzzle-meta'), | |
| difficultyBadge: document.getElementById('difficulty-badge'), | |
| filledCount: document.getElementById('filled-count'), | |
| remainingCount: document.getElementById('remaining-count'), | |
| nextTechnique: document.getElementById('next-technique'), | |
| analysisSummary: document.getElementById('analysis-summary'), | |
| logList: document.getElementById('log-list') | |
| }; | |
| const state = { | |
| board: [], | |
| initial: [], | |
| solution: [], | |
| analysis: null, | |
| pendingHint: null, | |
| selected: null, | |
| showMistakes: false, | |
| hintMessage: '힌트 보기를 누르면 다음 논리 단계를 설명합니다.', | |
| gameCount: 0, | |
| learningLog: [], | |
| cells: [] | |
| }; | |
| createBoard(); | |
| bindEvents(); | |
| startNewGame(); | |
| function bindEvents() { | |
| dom.newGameBtn.addEventListener('click', startNewGame); | |
| dom.hintBtn.addEventListener('click', requestHint); | |
| dom.applyHintBtn.addEventListener('click', applyHint); | |
| dom.checkBtn.addEventListener('click', toggleMistakes); | |
| dom.resetBtn.addEventListener('click', resetBoard); | |
| } | |
| function createBoard() { | |
| const fragment = document.createDocumentFragment(); | |
| for (let row = 0; row < 9; row += 1) { | |
| const rowCells = []; | |
| for (let col = 0; col < 9; col += 1) { | |
| const input = document.createElement('input'); | |
| input.type = 'text'; | |
| input.className = 'cell'; | |
| input.maxLength = 1; | |
| input.inputMode = 'numeric'; | |
| input.autocomplete = 'off'; | |
| input.pattern = '[1-9]'; | |
| input.spellcheck = false; | |
| input.setAttribute('aria-describedby', 'sudoku-help'); | |
| input.dataset.row = String(row); | |
| input.dataset.col = String(col); | |
| input.setAttribute('role', 'gridcell'); | |
| input.setAttribute('aria-rowindex', String(row + 1)); | |
| input.setAttribute('aria-colindex', String(col + 1)); | |
| if (col === 2 || col === 5) { | |
| input.classList.add('edge-right'); | |
| } | |
| if (row === 2 || row === 5) { | |
| input.classList.add('edge-bottom'); | |
| } | |
| input.addEventListener('focus', onCellFocus); | |
| input.addEventListener('click', onCellFocus); | |
| input.addEventListener('input', onCellInput); | |
| input.addEventListener('keydown', onCellKeyDown); | |
| rowCells.push(input); | |
| fragment.appendChild(input); | |
| } | |
| state.cells.push(rowCells); | |
| } | |
| dom.board.appendChild(fragment); | |
| } | |
| function startNewGame() { | |
| const initial = generatePuzzle(); | |
| const solution = solveBoard(initial); | |
| if (!solution) { | |
| setStatus('퍼즐 생성에 실패했습니다. 새로고침 후 다시 시도하세요.'); | |
| return; | |
| } | |
| state.initial = cloneBoard(initial); | |
| state.board = cloneBoard(initial); | |
| state.solution = solution; | |
| state.analysis = analyzePuzzle(initial); | |
| state.pendingHint = null; | |
| state.selected = findFirstEmpty(initial); | |
| state.showMistakes = false; | |
| state.hintMessage = '힌트 보기를 누르면 다음 논리 단계를 설명합니다.'; | |
| state.gameCount += 1; | |
| state.learningLog = [ | |
| { | |
| kind: '분석', | |
| message: describeAnalysis(state.analysis) | |
| } | |
| ]; | |
| dom.checkBtn.textContent = '오답 확인'; | |
| setStatus('새 퍼즐을 불러왔습니다.'); | |
| render(); | |
| focusSelectedCell(); | |
| } | |
| function resetBoard() { | |
| state.board = cloneBoard(state.initial); | |
| state.pendingHint = null; | |
| state.showMistakes = false; | |
| state.selected = findFirstEmpty(state.initial); | |
| state.hintMessage = '퍼즐을 초기 상태로 되돌렸습니다. 다시 논리로 풀어보세요.'; | |
| state.learningLog.unshift({ kind: '초기화', message: '입력한 값을 지우고 시작 상태로 돌아갔습니다.' }); | |
| state.learningLog = state.learningLog.slice(0, 8); | |
| dom.checkBtn.textContent = '오답 확인'; | |
| setStatus('퍼즐을 초기화했습니다.'); | |
| render(); | |
| focusSelectedCell(); | |
| } | |
| function requestHint() { | |
| const conflicts = collectConflicts(state.board); | |
| const wrongCells = collectWrongCells(state.board, state.solution); | |
| if (conflicts.size > 0) { | |
| state.pendingHint = null; | |
| state.hintMessage = '같은 행, 열 또는 박스에 중복된 숫자가 있습니다. 충돌을 먼저 정리하세요.'; | |
| setStatus('규칙 충돌이 있어 힌트를 중단했습니다.'); | |
| render(); | |
| return; | |
| } | |
| if (wrongCells.length > 0) { | |
| state.pendingHint = null; | |
| state.showMistakes = true; | |
| dom.checkBtn.textContent = '오답 숨기기'; | |
| state.hintMessage = '정답과 다른 칸이 있어 힌트를 멈췄습니다. 붉은 표시를 먼저 고쳐보세요.'; | |
| setStatus('오답이 있어 힌트를 중단했습니다.'); | |
| render(); | |
| return; | |
| } | |
| if (isSolved(state.board)) { | |
| state.pendingHint = null; | |
| state.hintMessage = '이미 퍼즐을 모두 해결했습니다. 새 퍼즐을 시작할 수 있습니다.'; | |
| setStatus('퍼즐이 이미 완성되어 있습니다.'); | |
| render(); | |
| return; | |
| } | |
| const step = findNextLogicalStep(state.board); | |
| if (!step) { | |
| state.pendingHint = null; | |
| state.hintMessage = '현재 MVP 규칙(단일 후보, 숨은 싱글)로는 다음 단계를 찾지 못했습니다.'; | |
| setStatus('추가 규칙이 필요한 구간입니다.'); | |
| render(); | |
| return; | |
| } | |
| state.pendingHint = step; | |
| state.selected = { row: step.row, col: step.col }; | |
| state.hintMessage = `${TECHNIQUE_LABELS[step.type]}: ${step.detail}`; | |
| recordLog('힌트', `${cellRef(step.row, step.col)} = ${step.value} · ${TECHNIQUE_LABELS[step.type]}`); | |
| setStatus('다음 논리 단계를 찾았습니다.'); | |
| render(); | |
| focusSelectedCell(); | |
| } | |
| function applyHint() { | |
| if (!state.pendingHint) { | |
| requestHint(); | |
| return; | |
| } | |
| const { row, col, value, type } = state.pendingHint; | |
| state.board[row][col] = value; | |
| state.selected = { row, col }; | |
| state.hintMessage = `${cellRef(row, col)}에 ${value}를 넣었습니다. 다음 단계가 필요하면 다시 힌트를 요청하세요.`; | |
| recordLog('적용', `${cellRef(row, col)} = ${value} · ${TECHNIQUE_LABELS[type]}`); | |
| state.pendingHint = null; | |
| if (boardsEqual(state.board, state.solution)) { | |
| state.hintMessage = '퍼즐을 해결했습니다. 새 퍼즐로 다시 시작할 수 있습니다.'; | |
| setStatus('완성했습니다. 모든 칸이 맞습니다.'); | |
| recordLog('완료', '모든 칸을 올바르게 채웠습니다.'); | |
| } else { | |
| setStatus('힌트를 적용했습니다.'); | |
| } | |
| render(); | |
| focusSelectedCell(); | |
| } | |
| function toggleMistakes() { | |
| state.showMistakes = !state.showMistakes; | |
| dom.checkBtn.textContent = state.showMistakes ? '오답 숨기기' : '오답 확인'; | |
| setStatus(state.showMistakes ? '오답 표시를 켰습니다.' : '오답 표시를 껐습니다.'); | |
| render(); | |
| } | |
| function onCellFocus(event) { | |
| const row = Number(event.target.dataset.row); | |
| const col = Number(event.target.dataset.col); | |
| state.selected = { row, col }; | |
| renderBoard(); | |
| } | |
| function onCellInput(event) { | |
| const input = event.target; | |
| const row = Number(input.dataset.row); | |
| const col = Number(input.dataset.col); | |
| const previousValue = state.board[row][col]; | |
| if (state.initial[row][col] !== 0) { | |
| input.value = String(state.initial[row][col]); | |
| return; | |
| } | |
| const raw = input.value.replace(/[^1-9]/g, '').slice(-1); | |
| input.value = raw; | |
| state.board[row][col] = raw ? Number(raw) : 0; | |
| state.pendingHint = null; | |
| state.selected = { row, col }; | |
| const conflicts = collectConflicts(state.board); | |
| const wrongCells = collectWrongCells(state.board, state.solution); | |
| if (boardsEqual(state.board, state.solution)) { | |
| state.hintMessage = '퍼즐을 해결했습니다. 새 퍼즐로 다시 시작할 수 있습니다.'; | |
| setStatus('완성했습니다. 모든 칸이 맞습니다.'); | |
| recordLog('완료', '모든 칸을 올바르게 채웠습니다.'); | |
| } else if (!raw && previousValue !== 0) { | |
| setStatus('칸을 비웠습니다.'); | |
| } else if (!raw) { | |
| setStatus('1부터 9까지의 숫자만 입력할 수 있습니다.'); | |
| } else if (conflicts.size > 0) { | |
| setStatus('규칙 충돌이 있습니다.'); | |
| } else if (wrongCells.length > 0 && countFilled(state.board) === 81) { | |
| setStatus('오답이 있습니다. 오답 확인으로 점검하세요.'); | |
| } else { | |
| const filled = countFilled(state.board); | |
| setStatus(`${filled}칸을 채웠습니다.`); | |
| } | |
| render(); | |
| } | |
| function onCellKeyDown(event) { | |
| const row = Number(event.target.dataset.row); | |
| const col = Number(event.target.dataset.col); | |
| let nextRow = row; | |
| let nextCol = col; | |
| if (event.key === 'ArrowUp') { | |
| nextRow = Math.max(0, row - 1); | |
| } else if (event.key === 'ArrowDown') { | |
| nextRow = Math.min(8, row + 1); | |
| } else if (event.key === 'ArrowLeft') { | |
| nextCol = Math.max(0, col - 1); | |
| } else if (event.key === 'ArrowRight') { | |
| nextCol = Math.min(8, col + 1); | |
| } else if (event.key === 'Backspace' || event.key === 'Delete' || event.key === 'Escape' || event.key === '0' || event.key === ' ') { | |
| if (state.initial[row][col] === 0) { | |
| state.board[row][col] = 0; | |
| state.pendingHint = null; | |
| state.selected = { row, col }; | |
| setStatus('칸을 비웠습니다.'); | |
| render(); | |
| } | |
| event.preventDefault(); | |
| return; | |
| } else { | |
| return; | |
| } | |
| event.preventDefault(); | |
| state.selected = { row: nextRow, col: nextCol }; | |
| focusSelectedCell(); | |
| renderBoard(); | |
| } | |
| function render() { | |
| renderBoard(); | |
| renderSidebar(); | |
| updateActionButtons(); | |
| } | |
| function updateActionButtons() { | |
| const isComplete = boardsEqual(state.board, state.solution); | |
| dom.hintBtn.disabled = isComplete; | |
| dom.applyHintBtn.disabled = !state.pendingHint || isComplete; | |
| dom.resetBtn.disabled = boardsEqual(state.board, state.initial); | |
| dom.checkBtn.textContent = state.showMistakes ? '오답 숨기기' : '오답 확인'; | |
| dom.checkBtn.setAttribute('aria-pressed', String(state.showMistakes)); | |
| } | |
| function renderBoard() { | |
| const conflicts = collectConflicts(state.board); | |
| const wrongCells = state.showMistakes ? new Set(collectWrongCells(state.board, state.solution)) : new Set(); | |
| for (let row = 0; row < 9; row += 1) { | |
| for (let col = 0; col < 9; col += 1) { | |
| const input = state.cells[row][col]; | |
| const value = state.board[row][col]; | |
| const key = toKey(row, col); | |
| const isGiven = state.initial[row][col] !== 0; | |
| const isSelected = Boolean(state.selected && state.selected.row === row && state.selected.col === col); | |
| const isHint = Boolean(state.pendingHint && state.pendingHint.row === row && state.pendingHint.col === col); | |
| const hasConflict = conflicts.has(key); | |
| const hasMistake = wrongCells.has(key); | |
| input.value = value === 0 ? '' : String(value); | |
| input.readOnly = isGiven; | |
| input.classList.toggle('is-given', isGiven); | |
| input.classList.toggle('is-selected', isSelected); | |
| input.classList.toggle('is-hint', isHint); | |
| input.classList.toggle('is-conflict', hasConflict); | |
| input.classList.toggle('is-mistake', hasMistake); | |
| input.setAttribute('aria-label', buildCellLabel(row, col, value, isGiven)); | |
| input.setAttribute('aria-selected', String(isSelected)); | |
| input.setAttribute('aria-invalid', String(hasConflict || hasMistake)); | |
| input.setAttribute('aria-readonly', String(isGiven)); | |
| } | |
| } | |
| } | |
| function renderSidebar() { | |
| const filled = countFilled(state.board); | |
| const conflicts = collectConflicts(state.board); | |
| const wrongCells = collectWrongCells(state.board, state.solution); | |
| const isComplete = boardsEqual(state.board, state.solution); | |
| let nextTechniqueLabel = '대기 중'; | |
| if (isComplete) { | |
| nextTechniqueLabel = '완료'; | |
| } else if (conflicts.size > 0) { | |
| nextTechniqueLabel = '충돌 정리 필요'; | |
| } else if (wrongCells.length > 0) { | |
| nextTechniqueLabel = '오답 수정 필요'; | |
| } else { | |
| const nextStep = findNextLogicalStep(state.board); | |
| nextTechniqueLabel = nextStep ? TECHNIQUE_LABELS[nextStep.type] : '현재 규칙으로 찾지 못함'; | |
| } | |
| dom.puzzleMeta.textContent = `${state.gameCount}번째 퍼즐 · 랜덤 변형 학습 퍼즐`; | |
| dom.difficultyBadge.textContent = state.analysis.difficulty; | |
| dom.hintMessage.textContent = state.hintMessage; | |
| dom.filledCount.textContent = String(filled); | |
| dom.remainingCount.textContent = String(81 - filled); | |
| dom.nextTechnique.textContent = nextTechniqueLabel; | |
| dom.analysisSummary.textContent = describeAnalysis(state.analysis); | |
| dom.logList.innerHTML = ''; | |
| const items = state.learningLog.length > 0 ? state.learningLog : [{ kind: '안내', message: '힌트 로그가 여기에 표시됩니다.' }]; | |
| items.slice(0, 8).forEach((item) => { | |
| const li = document.createElement('li'); | |
| li.textContent = `${item.kind} · ${item.message}`; | |
| dom.logList.appendChild(li); | |
| }); | |
| } | |
| function setStatus(message) { | |
| dom.gameStatus.textContent = message; | |
| } | |
| function recordLog(kind, message) { | |
| const latest = `${kind}:${message}`; | |
| const exists = state.learningLog.some((entry) => `${entry.kind}:${entry.message}` === latest); | |
| if (!exists) { | |
| state.learningLog.unshift({ kind, message }); | |
| state.learningLog = state.learningLog.slice(0, 8); | |
| } | |
| } | |
| function buildCellLabel(row, col, value, isGiven) { | |
| if (isGiven) { | |
| return `${row + 1}행 ${col + 1}열, 고정 숫자 ${value}`; | |
| } | |
| if (value === 0) { | |
| return `${row + 1}행 ${col + 1}열, 빈칸`; | |
| } | |
| return `${row + 1}행 ${col + 1}열, 입력값 ${value}`; | |
| } | |
| function focusSelectedCell() { | |
| if (!state.selected) { | |
| return; | |
| } | |
| const cell = state.cells[state.selected.row][state.selected.col]; | |
| if (cell) { | |
| cell.focus(); | |
| } | |
| } | |
| function analyzePuzzle(board) { | |
| const solverResult = buildSolverLog(board); | |
| const counts = solverResult.steps.reduce((acc, step) => { | |
| acc[step.type] = (acc[step.type] || 0) + 1; | |
| return acc; | |
| }, {}); | |
| return { | |
| steps: solverResult.steps, | |
| solved: solverResult.solved, | |
| counts, | |
| difficulty: classifyDifficulty(solverResult.solved, counts) | |
| }; | |
| } | |
| function describeAnalysis(analysis) { | |
| const countSummary = formatTechniqueCounts(analysis.counts); | |
| if (analysis.solved) { | |
| return `${analysis.steps.length}단계 논리 풀이 · ${countSummary}`; | |
| } | |
| return `일부만 논리 풀이 가능 · ${countSummary}`; | |
| } | |
| function formatTechniqueCounts(counts) { | |
| const parts = []; | |
| if (counts['naked-single']) { | |
| parts.push(`단일 후보 ${counts['naked-single']}회`); | |
| } | |
| if (counts['hidden-single-row']) { | |
| parts.push(`행 숨은 싱글 ${counts['hidden-single-row']}회`); | |
| } | |
| if (counts['hidden-single-col']) { | |
| parts.push(`열 숨은 싱글 ${counts['hidden-single-col']}회`); | |
| } | |
| if (counts['hidden-single-box']) { | |
| parts.push(`박스 숨은 싱글 ${counts['hidden-single-box']}회`); | |
| } | |
| return parts.length > 0 ? parts.join(', ') : '기본 규칙 분석 없음'; | |
| } | |
| function classifyDifficulty(solved, counts) { | |
| const hiddenCount = (counts['hidden-single-row'] || 0) + (counts['hidden-single-col'] || 0) + (counts['hidden-single-box'] || 0); | |
| if (!solved) { | |
| return '연습'; | |
| } | |
| if (hiddenCount === 0) { | |
| return '입문'; | |
| } | |
| if (hiddenCount <= 8) { | |
| return '초급'; | |
| } | |
| return '중급'; | |
| } | |
| function buildSolverLog(board) { | |
| const working = cloneBoard(board); | |
| const steps = []; | |
| let guard = 0; | |
| while (guard < 100) { | |
| if (isSolved(working)) { | |
| return { solved: true, steps }; | |
| } | |
| const next = findNextLogicalStep(working); | |
| if (!next) { | |
| break; | |
| } | |
| working[next.row][next.col] = next.value; | |
| steps.push(next); | |
| guard += 1; | |
| } | |
| return { solved: isSolved(working), steps }; | |
| } | |
| function findNextLogicalStep(board) { | |
| const candidates = []; | |
| for (let row = 0; row < 9; row += 1) { | |
| candidates[row] = []; | |
| for (let col = 0; col < 9; col += 1) { | |
| candidates[row][col] = board[row][col] === 0 ? getCandidates(board, row, col) : []; | |
| } | |
| } | |
| for (let row = 0; row < 9; row += 1) { | |
| for (let col = 0; col < 9; col += 1) { | |
| if (board[row][col] !== 0) { | |
| continue; | |
| } | |
| if (candidates[row][col].length === 1) { | |
| const value = candidates[row][col][0]; | |
| return { | |
| type: 'naked-single', | |
| row, | |
| col, | |
| value, | |
| detail: `${cellRef(row, col)}의 후보는 ${value} 하나뿐입니다. 행, 열, 박스에서 다른 숫자가 모두 제외됩니다.` | |
| }; | |
| } | |
| } | |
| } | |
| for (let row = 0; row < 9; row += 1) { | |
| for (let value = 1; value <= 9; value += 1) { | |
| const spots = []; | |
| for (let col = 0; col < 9; col += 1) { | |
| if (candidates[row][col].includes(value)) { | |
| spots.push({ row, col }); | |
| } | |
| } | |
| if (spots.length === 1) { | |
| return { | |
| type: 'hidden-single-row', | |
| row: spots[0].row, | |
| col: spots[0].col, | |
| value, | |
| detail: `${row + 1}행에서 숫자 ${value}가 들어갈 수 있는 칸은 ${cellRef(spots[0].row, spots[0].col)} 하나뿐입니다.` | |
| }; | |
| } | |
| } | |
| } | |
| for (let col = 0; col < 9; col += 1) { | |
| for (let value = 1; value <= 9; value += 1) { | |
| const spots = []; | |
| for (let row = 0; row < 9; row += 1) { | |
| if (candidates[row][col].includes(value)) { | |
| spots.push({ row, col }); | |
| } | |
| } | |
| if (spots.length === 1) { | |
| return { | |
| type: 'hidden-single-col', | |
| row: spots[0].row, | |
| col: spots[0].col, | |
| value, | |
| detail: `${col + 1}열에서 숫자 ${value}가 들어갈 수 있는 칸은 ${cellRef(spots[0].row, spots[0].col)} 하나뿐입니다.` | |
| }; | |
| } | |
| } | |
| } | |
| for (let box = 0; box < 9; box += 1) { | |
| const startRow = Math.floor(box / 3) * 3; | |
| const startCol = (box % 3) * 3; | |
| for (let value = 1; value <= 9; value += 1) { | |
| const spots = []; | |
| for (let row = startRow; row < startRow + 3; row += 1) { | |
| for (let col = startCol; col < startCol + 3; col += 1) { | |
| if (candidates[row][col].includes(value)) { | |
| spots.push({ row, col }); | |
| } | |
| } | |
| } | |
| if (spots.length === 1) { | |
| return { | |
| type: 'hidden-single-box', | |
| row: spots[0].row, | |
| col: spots[0].col, | |
| value, | |
| detail: `${box + 1}번 박스에서 숫자 ${value}가 들어갈 수 있는 칸은 ${cellRef(spots[0].row, spots[0].col)} 하나뿐입니다.` | |
| }; | |
| } | |
| } | |
| } | |
| return null; | |
| } | |
| function generatePuzzle() { | |
| let board = parseBoard(BASE_PUZZLE); | |
| board = remapDigits(board); | |
| board = shuffleRows(board); | |
| board = shuffleCols(board); | |
| if (Math.random() > 0.5) { | |
| board = transpose(board); | |
| } | |
| return board; | |
| } | |
| function remapDigits(board) { | |
| const digits = shuffle([1, 2, 3, 4, 5, 6, 7, 8, 9]); | |
| const map = new Map(); | |
| for (let i = 1; i <= 9; i += 1) { | |
| map.set(i, digits[i - 1]); | |
| } | |
| return board.map((row) => row.map((value) => (value === 0 ? 0 : map.get(value)))); | |
| } | |
| function shuffleRows(board) { | |
| const bands = shuffle([0, 1, 2]); | |
| const order = []; | |
| bands.forEach((band) => { | |
| shuffle([0, 1, 2]).forEach((offset) => order.push(band * 3 + offset)); | |
| }); | |
| return order.map((rowIndex) => board[rowIndex].slice()); | |
| } | |
| function shuffleCols(board) { | |
| const stacks = shuffle([0, 1, 2]); | |
| const order = []; | |
| stacks.forEach((stack) => { | |
| shuffle([0, 1, 2]).forEach((offset) => order.push(stack * 3 + offset)); | |
| }); | |
| return board.map((row) => order.map((colIndex) => row[colIndex])); | |
| } | |
| function transpose(board) { | |
| return board[0].map((_, col) => board.map((row) => row[col])); | |
| } | |
| function shuffle(values) { | |
| const copy = values.slice(); | |
| for (let i = copy.length - 1; i > 0; i -= 1) { | |
| const j = Math.floor(Math.random() * (i + 1)); | |
| [copy[i], copy[j]] = [copy[j], copy[i]]; | |
| } | |
| return copy; | |
| } | |
| function solveBoard(board) { | |
| const working = cloneBoard(board); | |
| return solveRecursive(working) ? working : null; | |
| } | |
| function solveRecursive(board) { | |
| let bestCell = null; | |
| let bestCandidates = null; | |
| for (let row = 0; row < 9; row += 1) { | |
| for (let col = 0; col < 9; col += 1) { | |
| if (board[row][col] !== 0) { | |
| continue; | |
| } | |
| const candidates = getCandidates(board, row, col); | |
| if (candidates.length === 0) { | |
| return false; | |
| } | |
| if (!bestCandidates || candidates.length < bestCandidates.length) { | |
| bestCell = { row, col }; | |
| bestCandidates = candidates; | |
| } | |
| } | |
| } | |
| if (!bestCell) { | |
| return true; | |
| } | |
| for (const value of bestCandidates) { | |
| board[bestCell.row][bestCell.col] = value; | |
| if (solveRecursive(board)) { | |
| return true; | |
| } | |
| } | |
| board[bestCell.row][bestCell.col] = 0; | |
| return false; | |
| } | |
| function getCandidates(board, row, col) { | |
| if (board[row][col] !== 0) { | |
| return []; | |
| } | |
| const candidates = []; | |
| for (let value = 1; value <= 9; value += 1) { | |
| if (isValidPlacement(board, row, col, value)) { | |
| candidates.push(value); | |
| } | |
| } | |
| return candidates; | |
| } | |
| function isValidPlacement(board, row, col, value) { | |
| for (let index = 0; index < 9; index += 1) { | |
| if (board[row][index] === value) { | |
| return false; | |
| } | |
| if (board[index][col] === value) { | |
| return false; | |
| } | |
| } | |
| const startRow = Math.floor(row / 3) * 3; | |
| const startCol = Math.floor(col / 3) * 3; | |
| for (let r = startRow; r < startRow + 3; r += 1) { | |
| for (let c = startCol; c < startCol + 3; c += 1) { | |
| if (board[r][c] === value) { | |
| return false; | |
| } | |
| } | |
| } | |
| return true; | |
| } | |
| function collectConflicts(board) { | |
| const conflicts = new Set(); | |
| for (let row = 0; row < 9; row += 1) { | |
| markHouseConflicts(conflicts, Array.from({ length: 9 }, (_, col) => ({ row, col, value: board[row][col] }))); | |
| } | |
| for (let col = 0; col < 9; col += 1) { | |
| markHouseConflicts(conflicts, Array.from({ length: 9 }, (_, row) => ({ row, col, value: board[row][col] }))); | |
| } | |
| for (let box = 0; box < 9; box += 1) { | |
| const startRow = Math.floor(box / 3) * 3; | |
| const startCol = (box % 3) * 3; | |
| const cells = []; | |
| for (let row = startRow; row < startRow + 3; row += 1) { | |
| for (let col = startCol; col < startCol + 3; col += 1) { | |
| cells.push({ row, col, value: board[row][col] }); | |
| } | |
| } | |
| markHouseConflicts(conflicts, cells); | |
| } | |
| return conflicts; | |
| } | |
| function markHouseConflicts(conflicts, cells) { | |
| const buckets = new Map(); | |
| cells.forEach((cell) => { | |
| if (cell.value === 0) { | |
| return; | |
| } | |
| if (!buckets.has(cell.value)) { | |
| buckets.set(cell.value, []); | |
| } | |
| buckets.get(cell.value).push(cell); | |
| }); | |
| buckets.forEach((items) => { | |
| if (items.length > 1) { | |
| items.forEach((cell) => conflicts.add(toKey(cell.row, cell.col))); | |
| } | |
| }); | |
| } | |
| function collectWrongCells(board, solution) { | |
| const wrong = []; | |
| for (let row = 0; row < 9; row += 1) { | |
| for (let col = 0; col < 9; col += 1) { | |
| if (board[row][col] !== 0 && board[row][col] !== solution[row][col]) { | |
| wrong.push(toKey(row, col)); | |
| } | |
| } | |
| } | |
| return wrong; | |
| } | |
| function findFirstEmpty(board) { | |
| for (let row = 0; row < 9; row += 1) { | |
| for (let col = 0; col < 9; col += 1) { | |
| if (board[row][col] === 0) { | |
| return { row, col }; | |
| } | |
| } | |
| } | |
| return { row: 0, col: 0 }; | |
| } | |
| function isSolved(board) { | |
| return board.every((row) => row.every((value) => value !== 0)); | |
| } | |
| function countFilled(board) { | |
| return board.flat().filter((value) => value !== 0).length; | |
| } | |
| function boardsEqual(a, b) { | |
| for (let row = 0; row < 9; row += 1) { | |
| for (let col = 0; col < 9; col += 1) { | |
| if (a[row][col] !== b[row][col]) { | |
| return false; | |
| } | |
| } | |
| } | |
| return true; | |
| } | |
| function cloneBoard(board) { | |
| return board.map((row) => row.slice()); | |
| } | |
| function parseBoard(serialized) { | |
| const board = []; | |
| for (let row = 0; row < 9; row += 1) { | |
| const currentRow = []; | |
| for (let col = 0; col < 9; col += 1) { | |
| currentRow.push(Number(serialized[row * 9 + col])); | |
| } | |
| board.push(currentRow); | |
| } | |
| return board; | |
| } | |
| function toKey(row, col) { | |
| return `${row}-${col}`; | |
| } | |
| function cellRef(row, col) { | |
| return `${row + 1}행 ${col + 1}열`; | |
| } | |
| }); |