| 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}์ด`; | |
| } | |
| }); |