nayohan commited on
Commit
596f7cb
·
1 Parent(s): ed7c9b4

Update space

Browse files
Files changed (2) hide show
  1. metadata.json +46 -0
  2. 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
+ });