Update space
Browse files- metadata.json +46 -0
- script.js +822 -0
metadata.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"id": "af4a4e2d-dcd3-4f48-9ccb-a3345ff27cba",
|
| 3 |
+
"session_id": "c509d9f7-c51e-479c-b21a-49549dee6b5f",
|
| 4 |
+
"topic": "에이전트들은 1차 버전을 감정형 연출이 아닌 설명 가능한 학습형 수도쿠로 제한하고, solver 로그를 중심으로 hint-engine·difficulty-analyzer·gener",
|
| 5 |
+
"files": [
|
| 6 |
+
"index.html",
|
| 7 |
+
"style.css",
|
| 8 |
+
"script.js"
|
| 9 |
+
],
|
| 10 |
+
"invariants": {
|
| 11 |
+
"file_set": [
|
| 12 |
+
"index.html",
|
| 13 |
+
"script.js",
|
| 14 |
+
"style.css"
|
| 15 |
+
],
|
| 16 |
+
"keybinding_tokens": [
|
| 17 |
+
"ArrowUp",
|
| 18 |
+
"ArrowDown",
|
| 19 |
+
"ArrowLeft",
|
| 20 |
+
"ArrowRight"
|
| 21 |
+
],
|
| 22 |
+
"ui_text_tokens": [
|
| 23 |
+
"배우면서 푸는 수도쿠",
|
| 24 |
+
"Explainable Sudoku MVP",
|
| 25 |
+
"정답만 맞히는 대신, 다음 한 수가 왜 성립하는지 바로 설명해 주는 학습형 수도쿠입니다.",
|
| 26 |
+
"새 퍼즐",
|
| 27 |
+
"힌트 보기",
|
| 28 |
+
"힌트 적용",
|
| 29 |
+
"오답 확인",
|
| 30 |
+
"초기화",
|
| 31 |
+
"현재 퍼즐",
|
| 32 |
+
"랜덤 변형 퍼즐을 준비하는 중입니다.",
|
| 33 |
+
"입문",
|
| 34 |
+
"퍼즐을 준비하는 중입니다.",
|
| 35 |
+
"방향키로 칸을 이동하고 숫자 1부터 9까지 입력할 수 있습니다.",
|
| 36 |
+
"학습 힌트",
|
| 37 |
+
"힌트 보기를 누르면 다음 논리 단계를 설명합니다.",
|
| 38 |
+
"분석 요약",
|
| 39 |
+
"채워진 칸",
|
| 40 |
+
"남은 칸",
|
| 41 |
+
"다음 기술",
|
| 42 |
+
"대기 중"
|
| 43 |
+
]
|
| 44 |
+
},
|
| 45 |
+
"created_at": "2026-03-09T01:59:04.799921+00:00"
|
| 46 |
+
}
|
script.js
ADDED
|
@@ -0,0 +1,822 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
document.addEventListener('DOMContentLoaded', () => {
|
| 2 |
+
const BASE_PUZZLE = '530070000600195000098000060800060003400803001700020006060000280000419005000080079';
|
| 3 |
+
const TECHNIQUE_LABELS = {
|
| 4 |
+
'naked-single': '단일 후보',
|
| 5 |
+
'hidden-single-row': '행 숨은 싱글',
|
| 6 |
+
'hidden-single-col': '열 숨은 싱글',
|
| 7 |
+
'hidden-single-box': '박스 숨은 싱글'
|
| 8 |
+
};
|
| 9 |
+
|
| 10 |
+
const dom = {
|
| 11 |
+
board: document.getElementById('sudoku-board'),
|
| 12 |
+
newGameBtn: document.getElementById('new-game-btn'),
|
| 13 |
+
hintBtn: document.getElementById('hint-btn'),
|
| 14 |
+
applyHintBtn: document.getElementById('apply-hint-btn'),
|
| 15 |
+
checkBtn: document.getElementById('check-btn'),
|
| 16 |
+
resetBtn: document.getElementById('reset-btn'),
|
| 17 |
+
hintMessage: document.getElementById('hint-message'),
|
| 18 |
+
gameStatus: document.getElementById('game-status'),
|
| 19 |
+
puzzleMeta: document.getElementById('puzzle-meta'),
|
| 20 |
+
difficultyBadge: document.getElementById('difficulty-badge'),
|
| 21 |
+
filledCount: document.getElementById('filled-count'),
|
| 22 |
+
remainingCount: document.getElementById('remaining-count'),
|
| 23 |
+
nextTechnique: document.getElementById('next-technique'),
|
| 24 |
+
analysisSummary: document.getElementById('analysis-summary'),
|
| 25 |
+
logList: document.getElementById('log-list')
|
| 26 |
+
};
|
| 27 |
+
|
| 28 |
+
const state = {
|
| 29 |
+
board: [],
|
| 30 |
+
initial: [],
|
| 31 |
+
solution: [],
|
| 32 |
+
analysis: null,
|
| 33 |
+
pendingHint: null,
|
| 34 |
+
selected: null,
|
| 35 |
+
showMistakes: false,
|
| 36 |
+
hintMessage: '힌트 보기를 누르면 다음 논리 단계를 설명합니다.',
|
| 37 |
+
gameCount: 0,
|
| 38 |
+
learningLog: [],
|
| 39 |
+
cells: []
|
| 40 |
+
};
|
| 41 |
+
|
| 42 |
+
createBoard();
|
| 43 |
+
bindEvents();
|
| 44 |
+
startNewGame();
|
| 45 |
+
|
| 46 |
+
function bindEvents() {
|
| 47 |
+
dom.newGameBtn.addEventListener('click', startNewGame);
|
| 48 |
+
dom.hintBtn.addEventListener('click', requestHint);
|
| 49 |
+
dom.applyHintBtn.addEventListener('click', applyHint);
|
| 50 |
+
dom.checkBtn.addEventListener('click', toggleMistakes);
|
| 51 |
+
dom.resetBtn.addEventListener('click', resetBoard);
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
function createBoard() {
|
| 55 |
+
const fragment = document.createDocumentFragment();
|
| 56 |
+
|
| 57 |
+
for (let row = 0; row < 9; row += 1) {
|
| 58 |
+
const rowCells = [];
|
| 59 |
+
for (let col = 0; col < 9; col += 1) {
|
| 60 |
+
const input = document.createElement('input');
|
| 61 |
+
input.type = 'text';
|
| 62 |
+
input.className = 'cell';
|
| 63 |
+
input.maxLength = 1;
|
| 64 |
+
input.inputMode = 'numeric';
|
| 65 |
+
input.autocomplete = 'off';
|
| 66 |
+
input.pattern = '[1-9]';
|
| 67 |
+
input.spellcheck = false;
|
| 68 |
+
input.setAttribute('aria-describedby', 'sudoku-help');
|
| 69 |
+
input.dataset.row = String(row);
|
| 70 |
+
input.dataset.col = String(col);
|
| 71 |
+
input.setAttribute('role', 'gridcell');
|
| 72 |
+
input.setAttribute('aria-rowindex', String(row + 1));
|
| 73 |
+
input.setAttribute('aria-colindex', String(col + 1));
|
| 74 |
+
|
| 75 |
+
if (col === 2 || col === 5) {
|
| 76 |
+
input.classList.add('edge-right');
|
| 77 |
+
}
|
| 78 |
+
if (row === 2 || row === 5) {
|
| 79 |
+
input.classList.add('edge-bottom');
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
input.addEventListener('focus', onCellFocus);
|
| 83 |
+
input.addEventListener('click', onCellFocus);
|
| 84 |
+
input.addEventListener('input', onCellInput);
|
| 85 |
+
input.addEventListener('keydown', onCellKeyDown);
|
| 86 |
+
|
| 87 |
+
rowCells.push(input);
|
| 88 |
+
fragment.appendChild(input);
|
| 89 |
+
}
|
| 90 |
+
state.cells.push(rowCells);
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
dom.board.appendChild(fragment);
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
function startNewGame() {
|
| 97 |
+
const initial = generatePuzzle();
|
| 98 |
+
const solution = solveBoard(initial);
|
| 99 |
+
|
| 100 |
+
if (!solution) {
|
| 101 |
+
setStatus('퍼즐 생성에 실패했습니다. 새로고침 후 다시 시도하세요.');
|
| 102 |
+
return;
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
state.initial = cloneBoard(initial);
|
| 106 |
+
state.board = cloneBoard(initial);
|
| 107 |
+
state.solution = solution;
|
| 108 |
+
state.analysis = analyzePuzzle(initial);
|
| 109 |
+
state.pendingHint = null;
|
| 110 |
+
state.selected = findFirstEmpty(initial);
|
| 111 |
+
state.showMistakes = false;
|
| 112 |
+
state.hintMessage = '힌트 보기를 누르면 다음 논리 단계를 설명합니다.';
|
| 113 |
+
state.gameCount += 1;
|
| 114 |
+
state.learningLog = [
|
| 115 |
+
{
|
| 116 |
+
kind: '분석',
|
| 117 |
+
message: describeAnalysis(state.analysis)
|
| 118 |
+
}
|
| 119 |
+
];
|
| 120 |
+
|
| 121 |
+
dom.checkBtn.textContent = '오답 확인';
|
| 122 |
+
setStatus('새 퍼즐을 불러왔습니다.');
|
| 123 |
+
render();
|
| 124 |
+
focusSelectedCell();
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
function resetBoard() {
|
| 128 |
+
state.board = cloneBoard(state.initial);
|
| 129 |
+
state.pendingHint = null;
|
| 130 |
+
state.showMistakes = false;
|
| 131 |
+
state.selected = findFirstEmpty(state.initial);
|
| 132 |
+
state.hintMessage = '퍼즐을 초기 상태로 되돌렸습니다. 다시 논리로 풀어보세요.';
|
| 133 |
+
state.learningLog.unshift({ kind: '초기화', message: '입력한 값을 지우고 시작 상태로 돌아갔습니다.' });
|
| 134 |
+
state.learningLog = state.learningLog.slice(0, 8);
|
| 135 |
+
dom.checkBtn.textContent = '오답 확인';
|
| 136 |
+
setStatus('퍼즐을 초기화했습니다.');
|
| 137 |
+
render();
|
| 138 |
+
focusSelectedCell();
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
function requestHint() {
|
| 142 |
+
const conflicts = collectConflicts(state.board);
|
| 143 |
+
const wrongCells = collectWrongCells(state.board, state.solution);
|
| 144 |
+
|
| 145 |
+
if (conflicts.size > 0) {
|
| 146 |
+
state.pendingHint = null;
|
| 147 |
+
state.hintMessage = '같은 행, 열 또는 박스에 중복된 숫자가 있습니다. 충돌을 먼저 정리하세요.';
|
| 148 |
+
setStatus('규칙 충돌이 있어 힌트를 중단했습니다.');
|
| 149 |
+
render();
|
| 150 |
+
return;
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
if (wrongCells.length > 0) {
|
| 154 |
+
state.pendingHint = null;
|
| 155 |
+
state.showMistakes = true;
|
| 156 |
+
dom.checkBtn.textContent = '오답 숨기기';
|
| 157 |
+
state.hintMessage = '정답과 다른 칸이 있어 힌트를 멈췄습니다. 붉은 표시를 먼저 고쳐보세요.';
|
| 158 |
+
setStatus('오답이 있어 힌트를 중단했습니다.');
|
| 159 |
+
render();
|
| 160 |
+
return;
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
if (isSolved(state.board)) {
|
| 164 |
+
state.pendingHint = null;
|
| 165 |
+
state.hintMessage = '이미 퍼즐을 모두 해결했습니다. 새 퍼즐을 시작할 수 있습니다.';
|
| 166 |
+
setStatus('퍼즐이 이미 완성되어 있습니다.');
|
| 167 |
+
render();
|
| 168 |
+
return;
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
const step = findNextLogicalStep(state.board);
|
| 172 |
+
|
| 173 |
+
if (!step) {
|
| 174 |
+
state.pendingHint = null;
|
| 175 |
+
state.hintMessage = '현재 MVP 규칙(단일 후보, 숨은 싱글)로는 다음 단계를 찾지 못했습니다.';
|
| 176 |
+
setStatus('추가 규칙이 필요한 구간입니다.');
|
| 177 |
+
render();
|
| 178 |
+
return;
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
state.pendingHint = step;
|
| 182 |
+
state.selected = { row: step.row, col: step.col };
|
| 183 |
+
state.hintMessage = `${TECHNIQUE_LABELS[step.type]}: ${step.detail}`;
|
| 184 |
+
recordLog('힌트', `${cellRef(step.row, step.col)} = ${step.value} · ${TECHNIQUE_LABELS[step.type]}`);
|
| 185 |
+
setStatus('다음 논리 단계를 찾았습니다.');
|
| 186 |
+
render();
|
| 187 |
+
focusSelectedCell();
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
function applyHint() {
|
| 191 |
+
if (!state.pendingHint) {
|
| 192 |
+
requestHint();
|
| 193 |
+
return;
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
const { row, col, value, type } = state.pendingHint;
|
| 197 |
+
state.board[row][col] = value;
|
| 198 |
+
state.selected = { row, col };
|
| 199 |
+
state.hintMessage = `${cellRef(row, col)}에 ${value}를 넣었습니다. 다음 단계가 필요하면 다시 힌트를 요청하세요.`;
|
| 200 |
+
recordLog('적용', `${cellRef(row, col)} = ${value} · ${TECHNIQUE_LABELS[type]}`);
|
| 201 |
+
state.pendingHint = null;
|
| 202 |
+
|
| 203 |
+
if (boardsEqual(state.board, state.solution)) {
|
| 204 |
+
state.hintMessage = '퍼즐을 해결했습니다. 새 퍼즐로 다시 시작할 수 있습니다.';
|
| 205 |
+
setStatus('완성했습니다. 모든 칸이 맞습니다.');
|
| 206 |
+
recordLog('완료', '모든 칸을 올바르게 채웠습니다.');
|
| 207 |
+
} else {
|
| 208 |
+
setStatus('힌트를 적용했습니다.');
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
render();
|
| 212 |
+
focusSelectedCell();
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
function toggleMistakes() {
|
| 216 |
+
state.showMistakes = !state.showMistakes;
|
| 217 |
+
dom.checkBtn.textContent = state.showMistakes ? '오답 숨기기' : '오답 확인';
|
| 218 |
+
setStatus(state.showMistakes ? '오답 표시를 켰습니다.' : '오답 표시를 껐습니다.');
|
| 219 |
+
render();
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
function onCellFocus(event) {
|
| 223 |
+
const row = Number(event.target.dataset.row);
|
| 224 |
+
const col = Number(event.target.dataset.col);
|
| 225 |
+
state.selected = { row, col };
|
| 226 |
+
renderBoard();
|
| 227 |
+
}
|
| 228 |
+
|
| 229 |
+
function onCellInput(event) {
|
| 230 |
+
const input = event.target;
|
| 231 |
+
const row = Number(input.dataset.row);
|
| 232 |
+
const col = Number(input.dataset.col);
|
| 233 |
+
const previousValue = state.board[row][col];
|
| 234 |
+
|
| 235 |
+
if (state.initial[row][col] !== 0) {
|
| 236 |
+
input.value = String(state.initial[row][col]);
|
| 237 |
+
return;
|
| 238 |
+
}
|
| 239 |
+
|
| 240 |
+
const raw = input.value.replace(/[^1-9]/g, '').slice(-1);
|
| 241 |
+
input.value = raw;
|
| 242 |
+
state.board[row][col] = raw ? Number(raw) : 0;
|
| 243 |
+
state.pendingHint = null;
|
| 244 |
+
state.selected = { row, col };
|
| 245 |
+
const conflicts = collectConflicts(state.board);
|
| 246 |
+
const wrongCells = collectWrongCells(state.board, state.solution);
|
| 247 |
+
|
| 248 |
+
if (boardsEqual(state.board, state.solution)) {
|
| 249 |
+
state.hintMessage = '퍼즐을 해결했습니다. 새 퍼즐로 다시 시작할 수 있습니다.';
|
| 250 |
+
setStatus('완성했습니다. 모든 칸이 맞습니다.');
|
| 251 |
+
recordLog('완료', '모든 칸을 올바르게 채웠습니다.');
|
| 252 |
+
} else if (!raw && previousValue !== 0) {
|
| 253 |
+
setStatus('칸을 비웠습니다.');
|
| 254 |
+
} else if (!raw) {
|
| 255 |
+
setStatus('1부터 9까지의 숫자만 입력할 수 있습니다.');
|
| 256 |
+
} else if (conflicts.size > 0) {
|
| 257 |
+
setStatus('규칙 충돌이 있습니다.');
|
| 258 |
+
} else if (wrongCells.length > 0 && countFilled(state.board) === 81) {
|
| 259 |
+
setStatus('오답이 있습니다. 오답 확인으로 점검하세요.');
|
| 260 |
+
} else {
|
| 261 |
+
const filled = countFilled(state.board);
|
| 262 |
+
setStatus(`${filled}칸을 채웠습니다.`);
|
| 263 |
+
}
|
| 264 |
+
|
| 265 |
+
render();
|
| 266 |
+
}
|
| 267 |
+
|
| 268 |
+
function onCellKeyDown(event) {
|
| 269 |
+
const row = Number(event.target.dataset.row);
|
| 270 |
+
const col = Number(event.target.dataset.col);
|
| 271 |
+
let nextRow = row;
|
| 272 |
+
let nextCol = col;
|
| 273 |
+
|
| 274 |
+
if (event.key === 'ArrowUp') {
|
| 275 |
+
nextRow = Math.max(0, row - 1);
|
| 276 |
+
} else if (event.key === 'ArrowDown') {
|
| 277 |
+
nextRow = Math.min(8, row + 1);
|
| 278 |
+
} else if (event.key === 'ArrowLeft') {
|
| 279 |
+
nextCol = Math.max(0, col - 1);
|
| 280 |
+
} else if (event.key === 'ArrowRight') {
|
| 281 |
+
nextCol = Math.min(8, col + 1);
|
| 282 |
+
} else if (event.key === 'Backspace' || event.key === 'Delete' || event.key === 'Escape' || event.key === '0' || event.key === ' ') {
|
| 283 |
+
if (state.initial[row][col] === 0) {
|
| 284 |
+
state.board[row][col] = 0;
|
| 285 |
+
state.pendingHint = null;
|
| 286 |
+
state.selected = { row, col };
|
| 287 |
+
setStatus('칸을 비웠습니다.');
|
| 288 |
+
render();
|
| 289 |
+
}
|
| 290 |
+
event.preventDefault();
|
| 291 |
+
return;
|
| 292 |
+
} else {
|
| 293 |
+
return;
|
| 294 |
+
}
|
| 295 |
+
|
| 296 |
+
event.preventDefault();
|
| 297 |
+
state.selected = { row: nextRow, col: nextCol };
|
| 298 |
+
focusSelectedCell();
|
| 299 |
+
renderBoard();
|
| 300 |
+
}
|
| 301 |
+
|
| 302 |
+
function render() {
|
| 303 |
+
renderBoard();
|
| 304 |
+
renderSidebar();
|
| 305 |
+
updateActionButtons();
|
| 306 |
+
}
|
| 307 |
+
|
| 308 |
+
function updateActionButtons() {
|
| 309 |
+
const isComplete = boardsEqual(state.board, state.solution);
|
| 310 |
+
|
| 311 |
+
dom.hintBtn.disabled = isComplete;
|
| 312 |
+
dom.applyHintBtn.disabled = !state.pendingHint || isComplete;
|
| 313 |
+
dom.resetBtn.disabled = boardsEqual(state.board, state.initial);
|
| 314 |
+
dom.checkBtn.textContent = state.showMistakes ? '오답 숨기기' : '오답 확인';
|
| 315 |
+
dom.checkBtn.setAttribute('aria-pressed', String(state.showMistakes));
|
| 316 |
+
}
|
| 317 |
+
|
| 318 |
+
function renderBoard() {
|
| 319 |
+
const conflicts = collectConflicts(state.board);
|
| 320 |
+
const wrongCells = state.showMistakes ? new Set(collectWrongCells(state.board, state.solution)) : new Set();
|
| 321 |
+
|
| 322 |
+
for (let row = 0; row < 9; row += 1) {
|
| 323 |
+
for (let col = 0; col < 9; col += 1) {
|
| 324 |
+
const input = state.cells[row][col];
|
| 325 |
+
const value = state.board[row][col];
|
| 326 |
+
const key = toKey(row, col);
|
| 327 |
+
const isGiven = state.initial[row][col] !== 0;
|
| 328 |
+
const isSelected = Boolean(state.selected && state.selected.row === row && state.selected.col === col);
|
| 329 |
+
const isHint = Boolean(state.pendingHint && state.pendingHint.row === row && state.pendingHint.col === col);
|
| 330 |
+
const hasConflict = conflicts.has(key);
|
| 331 |
+
const hasMistake = wrongCells.has(key);
|
| 332 |
+
|
| 333 |
+
input.value = value === 0 ? '' : String(value);
|
| 334 |
+
input.readOnly = isGiven;
|
| 335 |
+
input.classList.toggle('is-given', isGiven);
|
| 336 |
+
input.classList.toggle('is-selected', isSelected);
|
| 337 |
+
input.classList.toggle('is-hint', isHint);
|
| 338 |
+
input.classList.toggle('is-conflict', hasConflict);
|
| 339 |
+
input.classList.toggle('is-mistake', hasMistake);
|
| 340 |
+
input.setAttribute('aria-label', buildCellLabel(row, col, value, isGiven));
|
| 341 |
+
input.setAttribute('aria-selected', String(isSelected));
|
| 342 |
+
input.setAttribute('aria-invalid', String(hasConflict || hasMistake));
|
| 343 |
+
input.setAttribute('aria-readonly', String(isGiven));
|
| 344 |
+
}
|
| 345 |
+
}
|
| 346 |
+
}
|
| 347 |
+
|
| 348 |
+
function renderSidebar() {
|
| 349 |
+
const filled = countFilled(state.board);
|
| 350 |
+
const conflicts = collectConflicts(state.board);
|
| 351 |
+
const wrongCells = collectWrongCells(state.board, state.solution);
|
| 352 |
+
const isComplete = boardsEqual(state.board, state.solution);
|
| 353 |
+
let nextTechniqueLabel = '대기 중';
|
| 354 |
+
|
| 355 |
+
if (isComplete) {
|
| 356 |
+
nextTechniqueLabel = '완료';
|
| 357 |
+
} else if (conflicts.size > 0) {
|
| 358 |
+
nextTechniqueLabel = '충돌 정리 필요';
|
| 359 |
+
} else if (wrongCells.length > 0) {
|
| 360 |
+
nextTechniqueLabel = '오답 수정 필요';
|
| 361 |
+
} else {
|
| 362 |
+
const nextStep = findNextLogicalStep(state.board);
|
| 363 |
+
nextTechniqueLabel = nextStep ? TECHNIQUE_LABELS[nextStep.type] : '현재 규칙으로 찾지 못함';
|
| 364 |
+
}
|
| 365 |
+
|
| 366 |
+
dom.puzzleMeta.textContent = `${state.gameCount}번째 퍼즐 · 랜덤 변형 학습 퍼즐`;
|
| 367 |
+
dom.difficultyBadge.textContent = state.analysis.difficulty;
|
| 368 |
+
dom.hintMessage.textContent = state.hintMessage;
|
| 369 |
+
dom.filledCount.textContent = String(filled);
|
| 370 |
+
dom.remainingCount.textContent = String(81 - filled);
|
| 371 |
+
dom.nextTechnique.textContent = nextTechniqueLabel;
|
| 372 |
+
dom.analysisSummary.textContent = describeAnalysis(state.analysis);
|
| 373 |
+
|
| 374 |
+
dom.logList.innerHTML = '';
|
| 375 |
+
const items = state.learningLog.length > 0 ? state.learningLog : [{ kind: '안내', message: '힌트 로그가 여기에 표시됩니다.' }];
|
| 376 |
+
items.slice(0, 8).forEach((item) => {
|
| 377 |
+
const li = document.createElement('li');
|
| 378 |
+
li.textContent = `${item.kind} · ${item.message}`;
|
| 379 |
+
dom.logList.appendChild(li);
|
| 380 |
+
});
|
| 381 |
+
}
|
| 382 |
+
|
| 383 |
+
function setStatus(message) {
|
| 384 |
+
dom.gameStatus.textContent = message;
|
| 385 |
+
}
|
| 386 |
+
|
| 387 |
+
function recordLog(kind, message) {
|
| 388 |
+
const latest = `${kind}:${message}`;
|
| 389 |
+
const exists = state.learningLog.some((entry) => `${entry.kind}:${entry.message}` === latest);
|
| 390 |
+
if (!exists) {
|
| 391 |
+
state.learningLog.unshift({ kind, message });
|
| 392 |
+
state.learningLog = state.learningLog.slice(0, 8);
|
| 393 |
+
}
|
| 394 |
+
}
|
| 395 |
+
|
| 396 |
+
function buildCellLabel(row, col, value, isGiven) {
|
| 397 |
+
if (isGiven) {
|
| 398 |
+
return `${row + 1}행 ${col + 1}열, 고정 숫자 ${value}`;
|
| 399 |
+
}
|
| 400 |
+
if (value === 0) {
|
| 401 |
+
return `${row + 1}행 ${col + 1}열, 빈칸`;
|
| 402 |
+
}
|
| 403 |
+
return `${row + 1}행 ${col + 1}열, 입력값 ${value}`;
|
| 404 |
+
}
|
| 405 |
+
|
| 406 |
+
function focusSelectedCell() {
|
| 407 |
+
if (!state.selected) {
|
| 408 |
+
return;
|
| 409 |
+
}
|
| 410 |
+
const cell = state.cells[state.selected.row][state.selected.col];
|
| 411 |
+
if (cell) {
|
| 412 |
+
cell.focus();
|
| 413 |
+
}
|
| 414 |
+
}
|
| 415 |
+
|
| 416 |
+
function analyzePuzzle(board) {
|
| 417 |
+
const solverResult = buildSolverLog(board);
|
| 418 |
+
const counts = solverResult.steps.reduce((acc, step) => {
|
| 419 |
+
acc[step.type] = (acc[step.type] || 0) + 1;
|
| 420 |
+
return acc;
|
| 421 |
+
}, {});
|
| 422 |
+
|
| 423 |
+
return {
|
| 424 |
+
steps: solverResult.steps,
|
| 425 |
+
solved: solverResult.solved,
|
| 426 |
+
counts,
|
| 427 |
+
difficulty: classifyDifficulty(solverResult.solved, counts)
|
| 428 |
+
};
|
| 429 |
+
}
|
| 430 |
+
|
| 431 |
+
function describeAnalysis(analysis) {
|
| 432 |
+
const countSummary = formatTechniqueCounts(analysis.counts);
|
| 433 |
+
if (analysis.solved) {
|
| 434 |
+
return `${analysis.steps.length}단계 논리 풀이 · ${countSummary}`;
|
| 435 |
+
}
|
| 436 |
+
return `일부만 논리 풀이 가능 · ${countSummary}`;
|
| 437 |
+
}
|
| 438 |
+
|
| 439 |
+
function formatTechniqueCounts(counts) {
|
| 440 |
+
const parts = [];
|
| 441 |
+
if (counts['naked-single']) {
|
| 442 |
+
parts.push(`단일 후보 ${counts['naked-single']}회`);
|
| 443 |
+
}
|
| 444 |
+
if (counts['hidden-single-row']) {
|
| 445 |
+
parts.push(`행 숨은 싱글 ${counts['hidden-single-row']}회`);
|
| 446 |
+
}
|
| 447 |
+
if (counts['hidden-single-col']) {
|
| 448 |
+
parts.push(`열 숨은 싱글 ${counts['hidden-single-col']}회`);
|
| 449 |
+
}
|
| 450 |
+
if (counts['hidden-single-box']) {
|
| 451 |
+
parts.push(`박스 숨은 싱글 ${counts['hidden-single-box']}회`);
|
| 452 |
+
}
|
| 453 |
+
return parts.length > 0 ? parts.join(', ') : '기본 규칙 분석 없음';
|
| 454 |
+
}
|
| 455 |
+
|
| 456 |
+
function classifyDifficulty(solved, counts) {
|
| 457 |
+
const hiddenCount = (counts['hidden-single-row'] || 0) + (counts['hidden-single-col'] || 0) + (counts['hidden-single-box'] || 0);
|
| 458 |
+
if (!solved) {
|
| 459 |
+
return '연습';
|
| 460 |
+
}
|
| 461 |
+
if (hiddenCount === 0) {
|
| 462 |
+
return '입문';
|
| 463 |
+
}
|
| 464 |
+
if (hiddenCount <= 8) {
|
| 465 |
+
return '초급';
|
| 466 |
+
}
|
| 467 |
+
return '중급';
|
| 468 |
+
}
|
| 469 |
+
|
| 470 |
+
function buildSolverLog(board) {
|
| 471 |
+
const working = cloneBoard(board);
|
| 472 |
+
const steps = [];
|
| 473 |
+
let guard = 0;
|
| 474 |
+
|
| 475 |
+
while (guard < 100) {
|
| 476 |
+
if (isSolved(working)) {
|
| 477 |
+
return { solved: true, steps };
|
| 478 |
+
}
|
| 479 |
+
|
| 480 |
+
const next = findNextLogicalStep(working);
|
| 481 |
+
if (!next) {
|
| 482 |
+
break;
|
| 483 |
+
}
|
| 484 |
+
|
| 485 |
+
working[next.row][next.col] = next.value;
|
| 486 |
+
steps.push(next);
|
| 487 |
+
guard += 1;
|
| 488 |
+
}
|
| 489 |
+
|
| 490 |
+
return { solved: isSolved(working), steps };
|
| 491 |
+
}
|
| 492 |
+
|
| 493 |
+
function findNextLogicalStep(board) {
|
| 494 |
+
const candidates = [];
|
| 495 |
+
|
| 496 |
+
for (let row = 0; row < 9; row += 1) {
|
| 497 |
+
candidates[row] = [];
|
| 498 |
+
for (let col = 0; col < 9; col += 1) {
|
| 499 |
+
candidates[row][col] = board[row][col] === 0 ? getCandidates(board, row, col) : [];
|
| 500 |
+
}
|
| 501 |
+
}
|
| 502 |
+
|
| 503 |
+
for (let row = 0; row < 9; row += 1) {
|
| 504 |
+
for (let col = 0; col < 9; col += 1) {
|
| 505 |
+
if (board[row][col] !== 0) {
|
| 506 |
+
continue;
|
| 507 |
+
}
|
| 508 |
+
if (candidates[row][col].length === 1) {
|
| 509 |
+
const value = candidates[row][col][0];
|
| 510 |
+
return {
|
| 511 |
+
type: 'naked-single',
|
| 512 |
+
row,
|
| 513 |
+
col,
|
| 514 |
+
value,
|
| 515 |
+
detail: `${cellRef(row, col)}의 후보는 ${value} 하나뿐입니다. 행, 열, 박스에서 다른 숫자가 모두 제외됩니다.`
|
| 516 |
+
};
|
| 517 |
+
}
|
| 518 |
+
}
|
| 519 |
+
}
|
| 520 |
+
|
| 521 |
+
for (let row = 0; row < 9; row += 1) {
|
| 522 |
+
for (let value = 1; value <= 9; value += 1) {
|
| 523 |
+
const spots = [];
|
| 524 |
+
for (let col = 0; col < 9; col += 1) {
|
| 525 |
+
if (candidates[row][col].includes(value)) {
|
| 526 |
+
spots.push({ row, col });
|
| 527 |
+
}
|
| 528 |
+
}
|
| 529 |
+
if (spots.length === 1) {
|
| 530 |
+
return {
|
| 531 |
+
type: 'hidden-single-row',
|
| 532 |
+
row: spots[0].row,
|
| 533 |
+
col: spots[0].col,
|
| 534 |
+
value,
|
| 535 |
+
detail: `${row + 1}행에서 숫자 ${value}가 들어갈 수 있는 칸은 ${cellRef(spots[0].row, spots[0].col)} 하나뿐입니다.`
|
| 536 |
+
};
|
| 537 |
+
}
|
| 538 |
+
}
|
| 539 |
+
}
|
| 540 |
+
|
| 541 |
+
for (let col = 0; col < 9; col += 1) {
|
| 542 |
+
for (let value = 1; value <= 9; value += 1) {
|
| 543 |
+
const spots = [];
|
| 544 |
+
for (let row = 0; row < 9; row += 1) {
|
| 545 |
+
if (candidates[row][col].includes(value)) {
|
| 546 |
+
spots.push({ row, col });
|
| 547 |
+
}
|
| 548 |
+
}
|
| 549 |
+
if (spots.length === 1) {
|
| 550 |
+
return {
|
| 551 |
+
type: 'hidden-single-col',
|
| 552 |
+
row: spots[0].row,
|
| 553 |
+
col: spots[0].col,
|
| 554 |
+
value,
|
| 555 |
+
detail: `${col + 1}열에서 숫자 ${value}가 들어갈 수 있는 칸은 ${cellRef(spots[0].row, spots[0].col)} 하나뿐입니다.`
|
| 556 |
+
};
|
| 557 |
+
}
|
| 558 |
+
}
|
| 559 |
+
}
|
| 560 |
+
|
| 561 |
+
for (let box = 0; box < 9; box += 1) {
|
| 562 |
+
const startRow = Math.floor(box / 3) * 3;
|
| 563 |
+
const startCol = (box % 3) * 3;
|
| 564 |
+
for (let value = 1; value <= 9; value += 1) {
|
| 565 |
+
const spots = [];
|
| 566 |
+
for (let row = startRow; row < startRow + 3; row += 1) {
|
| 567 |
+
for (let col = startCol; col < startCol + 3; col += 1) {
|
| 568 |
+
if (candidates[row][col].includes(value)) {
|
| 569 |
+
spots.push({ row, col });
|
| 570 |
+
}
|
| 571 |
+
}
|
| 572 |
+
}
|
| 573 |
+
if (spots.length === 1) {
|
| 574 |
+
return {
|
| 575 |
+
type: 'hidden-single-box',
|
| 576 |
+
row: spots[0].row,
|
| 577 |
+
col: spots[0].col,
|
| 578 |
+
value,
|
| 579 |
+
detail: `${box + 1}번 박스에서 숫자 ${value}가 들어갈 수 있는 칸은 ${cellRef(spots[0].row, spots[0].col)} 하나뿐입니다.`
|
| 580 |
+
};
|
| 581 |
+
}
|
| 582 |
+
}
|
| 583 |
+
}
|
| 584 |
+
|
| 585 |
+
return null;
|
| 586 |
+
}
|
| 587 |
+
|
| 588 |
+
function generatePuzzle() {
|
| 589 |
+
let board = parseBoard(BASE_PUZZLE);
|
| 590 |
+
board = remapDigits(board);
|
| 591 |
+
board = shuffleRows(board);
|
| 592 |
+
board = shuffleCols(board);
|
| 593 |
+
if (Math.random() > 0.5) {
|
| 594 |
+
board = transpose(board);
|
| 595 |
+
}
|
| 596 |
+
return board;
|
| 597 |
+
}
|
| 598 |
+
|
| 599 |
+
function remapDigits(board) {
|
| 600 |
+
const digits = shuffle([1, 2, 3, 4, 5, 6, 7, 8, 9]);
|
| 601 |
+
const map = new Map();
|
| 602 |
+
for (let i = 1; i <= 9; i += 1) {
|
| 603 |
+
map.set(i, digits[i - 1]);
|
| 604 |
+
}
|
| 605 |
+
return board.map((row) => row.map((value) => (value === 0 ? 0 : map.get(value))));
|
| 606 |
+
}
|
| 607 |
+
|
| 608 |
+
function shuffleRows(board) {
|
| 609 |
+
const bands = shuffle([0, 1, 2]);
|
| 610 |
+
const order = [];
|
| 611 |
+
bands.forEach((band) => {
|
| 612 |
+
shuffle([0, 1, 2]).forEach((offset) => order.push(band * 3 + offset));
|
| 613 |
+
});
|
| 614 |
+
return order.map((rowIndex) => board[rowIndex].slice());
|
| 615 |
+
}
|
| 616 |
+
|
| 617 |
+
function shuffleCols(board) {
|
| 618 |
+
const stacks = shuffle([0, 1, 2]);
|
| 619 |
+
const order = [];
|
| 620 |
+
stacks.forEach((stack) => {
|
| 621 |
+
shuffle([0, 1, 2]).forEach((offset) => order.push(stack * 3 + offset));
|
| 622 |
+
});
|
| 623 |
+
return board.map((row) => order.map((colIndex) => row[colIndex]));
|
| 624 |
+
}
|
| 625 |
+
|
| 626 |
+
function transpose(board) {
|
| 627 |
+
return board[0].map((_, col) => board.map((row) => row[col]));
|
| 628 |
+
}
|
| 629 |
+
|
| 630 |
+
function shuffle(values) {
|
| 631 |
+
const copy = values.slice();
|
| 632 |
+
for (let i = copy.length - 1; i > 0; i -= 1) {
|
| 633 |
+
const j = Math.floor(Math.random() * (i + 1));
|
| 634 |
+
[copy[i], copy[j]] = [copy[j], copy[i]];
|
| 635 |
+
}
|
| 636 |
+
return copy;
|
| 637 |
+
}
|
| 638 |
+
|
| 639 |
+
function solveBoard(board) {
|
| 640 |
+
const working = cloneBoard(board);
|
| 641 |
+
return solveRecursive(working) ? working : null;
|
| 642 |
+
}
|
| 643 |
+
|
| 644 |
+
function solveRecursive(board) {
|
| 645 |
+
let bestCell = null;
|
| 646 |
+
let bestCandidates = null;
|
| 647 |
+
|
| 648 |
+
for (let row = 0; row < 9; row += 1) {
|
| 649 |
+
for (let col = 0; col < 9; col += 1) {
|
| 650 |
+
if (board[row][col] !== 0) {
|
| 651 |
+
continue;
|
| 652 |
+
}
|
| 653 |
+
const candidates = getCandidates(board, row, col);
|
| 654 |
+
if (candidates.length === 0) {
|
| 655 |
+
return false;
|
| 656 |
+
}
|
| 657 |
+
if (!bestCandidates || candidates.length < bestCandidates.length) {
|
| 658 |
+
bestCell = { row, col };
|
| 659 |
+
bestCandidates = candidates;
|
| 660 |
+
}
|
| 661 |
+
}
|
| 662 |
+
}
|
| 663 |
+
|
| 664 |
+
if (!bestCell) {
|
| 665 |
+
return true;
|
| 666 |
+
}
|
| 667 |
+
|
| 668 |
+
for (const value of bestCandidates) {
|
| 669 |
+
board[bestCell.row][bestCell.col] = value;
|
| 670 |
+
if (solveRecursive(board)) {
|
| 671 |
+
return true;
|
| 672 |
+
}
|
| 673 |
+
}
|
| 674 |
+
|
| 675 |
+
board[bestCell.row][bestCell.col] = 0;
|
| 676 |
+
return false;
|
| 677 |
+
}
|
| 678 |
+
|
| 679 |
+
function getCandidates(board, row, col) {
|
| 680 |
+
if (board[row][col] !== 0) {
|
| 681 |
+
return [];
|
| 682 |
+
}
|
| 683 |
+
const candidates = [];
|
| 684 |
+
for (let value = 1; value <= 9; value += 1) {
|
| 685 |
+
if (isValidPlacement(board, row, col, value)) {
|
| 686 |
+
candidates.push(value);
|
| 687 |
+
}
|
| 688 |
+
}
|
| 689 |
+
return candidates;
|
| 690 |
+
}
|
| 691 |
+
|
| 692 |
+
function isValidPlacement(board, row, col, value) {
|
| 693 |
+
for (let index = 0; index < 9; index += 1) {
|
| 694 |
+
if (board[row][index] === value) {
|
| 695 |
+
return false;
|
| 696 |
+
}
|
| 697 |
+
if (board[index][col] === value) {
|
| 698 |
+
return false;
|
| 699 |
+
}
|
| 700 |
+
}
|
| 701 |
+
|
| 702 |
+
const startRow = Math.floor(row / 3) * 3;
|
| 703 |
+
const startCol = Math.floor(col / 3) * 3;
|
| 704 |
+
for (let r = startRow; r < startRow + 3; r += 1) {
|
| 705 |
+
for (let c = startCol; c < startCol + 3; c += 1) {
|
| 706 |
+
if (board[r][c] === value) {
|
| 707 |
+
return false;
|
| 708 |
+
}
|
| 709 |
+
}
|
| 710 |
+
}
|
| 711 |
+
return true;
|
| 712 |
+
}
|
| 713 |
+
|
| 714 |
+
function collectConflicts(board) {
|
| 715 |
+
const conflicts = new Set();
|
| 716 |
+
|
| 717 |
+
for (let row = 0; row < 9; row += 1) {
|
| 718 |
+
markHouseConflicts(conflicts, Array.from({ length: 9 }, (_, col) => ({ row, col, value: board[row][col] })));
|
| 719 |
+
}
|
| 720 |
+
for (let col = 0; col < 9; col += 1) {
|
| 721 |
+
markHouseConflicts(conflicts, Array.from({ length: 9 }, (_, row) => ({ row, col, value: board[row][col] })));
|
| 722 |
+
}
|
| 723 |
+
for (let box = 0; box < 9; box += 1) {
|
| 724 |
+
const startRow = Math.floor(box / 3) * 3;
|
| 725 |
+
const startCol = (box % 3) * 3;
|
| 726 |
+
const cells = [];
|
| 727 |
+
for (let row = startRow; row < startRow + 3; row += 1) {
|
| 728 |
+
for (let col = startCol; col < startCol + 3; col += 1) {
|
| 729 |
+
cells.push({ row, col, value: board[row][col] });
|
| 730 |
+
}
|
| 731 |
+
}
|
| 732 |
+
markHouseConflicts(conflicts, cells);
|
| 733 |
+
}
|
| 734 |
+
|
| 735 |
+
return conflicts;
|
| 736 |
+
}
|
| 737 |
+
|
| 738 |
+
function markHouseConflicts(conflicts, cells) {
|
| 739 |
+
const buckets = new Map();
|
| 740 |
+
cells.forEach((cell) => {
|
| 741 |
+
if (cell.value === 0) {
|
| 742 |
+
return;
|
| 743 |
+
}
|
| 744 |
+
if (!buckets.has(cell.value)) {
|
| 745 |
+
buckets.set(cell.value, []);
|
| 746 |
+
}
|
| 747 |
+
buckets.get(cell.value).push(cell);
|
| 748 |
+
});
|
| 749 |
+
|
| 750 |
+
buckets.forEach((items) => {
|
| 751 |
+
if (items.length > 1) {
|
| 752 |
+
items.forEach((cell) => conflicts.add(toKey(cell.row, cell.col)));
|
| 753 |
+
}
|
| 754 |
+
});
|
| 755 |
+
}
|
| 756 |
+
|
| 757 |
+
function collectWrongCells(board, solution) {
|
| 758 |
+
const wrong = [];
|
| 759 |
+
for (let row = 0; row < 9; row += 1) {
|
| 760 |
+
for (let col = 0; col < 9; col += 1) {
|
| 761 |
+
if (board[row][col] !== 0 && board[row][col] !== solution[row][col]) {
|
| 762 |
+
wrong.push(toKey(row, col));
|
| 763 |
+
}
|
| 764 |
+
}
|
| 765 |
+
}
|
| 766 |
+
return wrong;
|
| 767 |
+
}
|
| 768 |
+
|
| 769 |
+
function findFirstEmpty(board) {
|
| 770 |
+
for (let row = 0; row < 9; row += 1) {
|
| 771 |
+
for (let col = 0; col < 9; col += 1) {
|
| 772 |
+
if (board[row][col] === 0) {
|
| 773 |
+
return { row, col };
|
| 774 |
+
}
|
| 775 |
+
}
|
| 776 |
+
}
|
| 777 |
+
return { row: 0, col: 0 };
|
| 778 |
+
}
|
| 779 |
+
|
| 780 |
+
function isSolved(board) {
|
| 781 |
+
return board.every((row) => row.every((value) => value !== 0));
|
| 782 |
+
}
|
| 783 |
+
|
| 784 |
+
function countFilled(board) {
|
| 785 |
+
return board.flat().filter((value) => value !== 0).length;
|
| 786 |
+
}
|
| 787 |
+
|
| 788 |
+
function boardsEqual(a, b) {
|
| 789 |
+
for (let row = 0; row < 9; row += 1) {
|
| 790 |
+
for (let col = 0; col < 9; col += 1) {
|
| 791 |
+
if (a[row][col] !== b[row][col]) {
|
| 792 |
+
return false;
|
| 793 |
+
}
|
| 794 |
+
}
|
| 795 |
+
}
|
| 796 |
+
return true;
|
| 797 |
+
}
|
| 798 |
+
|
| 799 |
+
function cloneBoard(board) {
|
| 800 |
+
return board.map((row) => row.slice());
|
| 801 |
+
}
|
| 802 |
+
|
| 803 |
+
function parseBoard(serialized) {
|
| 804 |
+
const board = [];
|
| 805 |
+
for (let row = 0; row < 9; row += 1) {
|
| 806 |
+
const currentRow = [];
|
| 807 |
+
for (let col = 0; col < 9; col += 1) {
|
| 808 |
+
currentRow.push(Number(serialized[row * 9 + col]));
|
| 809 |
+
}
|
| 810 |
+
board.push(currentRow);
|
| 811 |
+
}
|
| 812 |
+
return board;
|
| 813 |
+
}
|
| 814 |
+
|
| 815 |
+
function toKey(row, col) {
|
| 816 |
+
return `${row}-${col}`;
|
| 817 |
+
}
|
| 818 |
+
|
| 819 |
+
function cellRef(row, col) {
|
| 820 |
+
return `${row + 1}행 ${col + 1}열`;
|
| 821 |
+
}
|
| 822 |
+
});
|