@my-games/sudoku-core 0.0.0 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,19 +1,792 @@
1
- // src/index.ts
1
+ // src/board.ts
2
2
  /**
3
- * @my-games/sudoku-core —— `0.0.0` 占位实现。
3
+ * 纯函数棋盘工具:只用 `Grid`(`Cell[][]`,0 = 空),不涉及对局可变态。
4
4
  *
5
- * 这是一个「空包」骨架:目的仅是把 monorepo 的
6
- * `build → verify:dist → typecheck → test` 门禁与 npm Trusted Publisher 链路跑通,
7
- * 好让首版(可信发布者无法发某包的第一个版本)能先手工种进 registry。
5
+ * 设计原则:所有「产出新棋盘」的函数都返回新数组,不原地修改输入。
6
+ * solver / generator / Sudoku 类都建立在这套底层原语上。
8
7
  *
9
- * 真实数独引擎 —— 棋盘校验、回溯求解器、唯一解题生成、`Sudoku` 状态机
10
- * (选格/填数/擦除/笔记/提示/撤销/计时)、注入式存档与最佳用时 —— 已在占位包
11
- * 发布前按同一三层结构写好并暂存,待 `0.0.0` 上线后作为后续提交补回。
8
+ * 严格模式(noUncheckedIndexedAccess):读取一律走 `cellAt`(越界/空返回 0),
9
+ * 派生 9×9 结构时用 `makeMask` / `setAt` 保证行存在后再写下标。
12
10
  */
13
- /** 包标识占位。 */
14
- var SUDOKU_PACKAGE = "sudoku-core";
15
- /** 占位版本,与 package.json 的 version 对齐,便于确认发的是 stage 包。 */
16
- var SUDOKU_STAGE = "0.0.0";
17
- export { SUDOKU_PACKAGE, SUDOKU_STAGE };
11
+ /** 数独边长(标准 9×9)。 */
12
+ var SIZE = 9;
13
+ /** 宫的边长(3×3 宫,共 9 宫)。 */
14
+ var BOX = 3;
15
+ /** 数字 1..9 常量表(升序)。 */
16
+ var DIGITS = [
17
+ 1,
18
+ 2,
19
+ 3,
20
+ 4,
21
+ 5,
22
+ 6,
23
+ 7,
24
+ 8,
25
+ 9
26
+ ];
27
+ /** 生成 SIZE×SIZE 全 0 空棋盘。 */
28
+ function createEmptyGrid() {
29
+ return Array.from({ length: 9 }, () => Array(9).fill(0));
30
+ }
31
+ /** 深拷贝棋盘。 */
32
+ function cloneGrid(grid) {
33
+ return grid.map((row) => row.slice());
34
+ }
35
+ /** 生成 SIZE×SIZE 掩码,初值由 `fill` 决定。 */
36
+ function makeMask(fill) {
37
+ return Array.from({ length: 9 }, () => Array(9).fill(fill));
38
+ }
39
+ /** 安全读取:越界返回 0。 */
40
+ function cellAt(grid, row, col) {
41
+ return grid[row]?.[col] ?? 0;
42
+ }
43
+ /** 安全写入:仅在行存在时写入(返回同一引用,供链式)。 */
44
+ function setCell(grid, row, col, value) {
45
+ const line = grid[row];
46
+ if (line) line[col] = value;
47
+ return grid;
48
+ }
49
+ /** 返回把 (row,col) 设为 value 后的新棋盘(不改动入参)。 */
50
+ function withCell(grid, row, col, value) {
51
+ return setCell(cloneGrid(grid), row, col, value);
52
+ }
53
+ /** (row,col) 所属宫的左上角坐标。 */
54
+ function boxOrigin(row, col) {
55
+ return {
56
+ r: Math.floor(row / 3) * 3,
57
+ c: Math.floor(col / 3) * 3
58
+ };
59
+ }
60
+ /** 与 (row,col) 同处一行、一列、一宫(不含自身)的所有坐标,去重后共 20 个。 */
61
+ function peers(row, col) {
62
+ const seen = /* @__PURE__ */ new Set();
63
+ const out = [];
64
+ const push = (r, c) => {
65
+ if (r === row && c === col) return;
66
+ const key = r * 9 + c;
67
+ if (seen.has(key)) return;
68
+ seen.add(key);
69
+ out.push([r, c]);
70
+ };
71
+ for (let i = 0; i < 9; i++) {
72
+ push(row, i);
73
+ push(i, col);
74
+ }
75
+ const { r, c } = boxOrigin(row, col);
76
+ for (let dr = 0; dr < 3; dr++) for (let dc = 0; dc < 3; dc++) push(r + dr, c + dc);
77
+ return out;
78
+ }
79
+ /**
80
+ * 数字 digit 能否放到 (row,col):检查所有 peer 是否已占用同值。
81
+ * 不关心 (row,col) 当前值(用于「换个值是否合法」判断)。
82
+ */
83
+ function isSafe(grid, row, col, digit) {
84
+ for (const [pr, pc] of peers(row, col)) if (cellAt(grid, pr, pc) === digit) return false;
85
+ return true;
86
+ }
87
+ /** 空格 (row,col) 的所有合法候选数字;非空格返回 []。 */
88
+ function candidates(grid, row, col) {
89
+ if (cellAt(grid, row, col) !== 0) return [];
90
+ const out = [];
91
+ for (const d of DIGITS) if (isSafe(grid, row, col, d)) out.push(d);
92
+ return out;
93
+ }
94
+ /** 所有空格坐标(行优先)。 */
95
+ function emptyCells(grid) {
96
+ const out = [];
97
+ for (let r = 0; r < 9; r++) for (let c = 0; c < 9; c++) if (cellAt(grid, r, c) === 0) out.push([r, c]);
98
+ return out;
99
+ }
100
+ /** 是否填满(无 0)。 */
101
+ function isFull(grid) {
102
+ for (let r = 0; r < 9; r++) for (let c = 0; c < 9; c++) if (cellAt(grid, r, c) === 0) return false;
103
+ return true;
104
+ }
105
+ /**
106
+ * 计算冲突掩码:某格为非 0 且与任一 peer 同值 → true。
107
+ * 供 UI 标红「当前矛盾」的格,允许存在空格(部分填写)。
108
+ */
109
+ function findConflicts(grid) {
110
+ const mask = makeMask(false);
111
+ for (let r = 0; r < 9; r++) for (let c = 0; c < 9; c++) {
112
+ const v = cellAt(grid, r, c);
113
+ if (v === 0) continue;
114
+ for (const [pr, pc] of peers(r, c)) if (cellAt(grid, pr, pc) === v) {
115
+ const line = mask[r];
116
+ if (line) line[c] = true;
117
+ break;
118
+ }
119
+ }
120
+ return mask;
121
+ }
122
+ /** 是否存在任意冲突(有重复)。 */
123
+ function hasConflict(grid) {
124
+ for (const row of findConflicts(grid)) for (const v of row) if (v) return true;
125
+ return false;
126
+ }
127
+ /**
128
+ * 是否为「合法完成盘」:填满且无冲突。
129
+ * 等价于每行/列/宫都是 1..9 的一个排列。
130
+ */
131
+ function isSolved(grid) {
132
+ return isFull(grid) && !hasConflict(grid);
133
+ }
134
+ /** 棋盘相等(逐格比较)。 */
135
+ function gridsEqual(a, b) {
136
+ for (let r = 0; r < 9; r++) for (let c = 0; c < 9; c++) if (cellAt(a, r, c) !== cellAt(b, r, c)) return false;
137
+ return true;
138
+ }
139
+ /** 数非空格数量(题面线索数)。 */
140
+ function countFilled(grid) {
141
+ let n = 0;
142
+ for (let r = 0; r < 9; r++) for (let c = 0; c < 9; c++) if (cellAt(grid, r, c) !== 0) n++;
143
+ return n;
144
+ }
145
+ // src/solver.ts
146
+ /**
147
+ * 数独回溯求解器(MRV 启发式)+ 解个数统计。
148
+ *
149
+ * - `solve`:给定(可能不完整的)合法棋盘,返回一个完整解的副本;无解返回 null。
150
+ * - `countSolutions(grid, limit)`:统计该棋盘有多少个不同完整解,最多数到 `limit`
151
+ * 就早停。生成器用 `limit = 2` 判定「唯一解」(返回 1 → 唯一;≥2 → 多解)。
152
+ *
153
+ * MRV(Minimum Remaining Values):每步优先填「候选数最少」的空格,
154
+ * 一旦某空格候选为 0 立即回溯。标准 9×9 题在毫秒级完成,纯同步、无副作用。
155
+ *
156
+ * 内部对「工作副本」做原地 try/untry(backtracking 需要),但对外一律
157
+ * 先 `cloneGrid`,绝不改动调用者传入的棋盘。
158
+ */
159
+ /** 在 grid 上找候选最少的空格;若填满返回 null;若发现死格(0 候选)返回 { dead:true }。 */
160
+ function pickCell(grid) {
161
+ let best = null;
162
+ for (let r = 0; r < grid.length; r++) for (let c = 0; c < (grid[r]?.length ?? 0); c++) {
163
+ if (cellAt(grid, r, c) !== 0) continue;
164
+ const cands = candidates(grid, r, c);
165
+ if (cands.length === 0) return null;
166
+ if (!best || cands.length < best.cands.length) {
167
+ best = {
168
+ row: r,
169
+ col: c,
170
+ cands
171
+ };
172
+ if (cands.length === 1) return best;
173
+ }
174
+ }
175
+ return best;
176
+ }
177
+ /** 是否已无空格。 */
178
+ function isComplete(grid) {
179
+ for (const row of grid) for (const v of row) if (v === 0) return false;
180
+ return true;
181
+ }
182
+ /** 原地回溯填充工作副本;成功返回 true(grid 被填满),失败返回 false 并复原。 */
183
+ function fillInPlace(grid) {
184
+ if (isComplete(grid)) return true;
185
+ const cell = pickCell(grid);
186
+ if (!cell) return false;
187
+ for (const d of cell.cands) {
188
+ setCell(grid, cell.row, cell.col, d);
189
+ if (fillInPlace(grid)) return true;
190
+ setCell(grid, cell.row, cell.col, 0);
191
+ }
192
+ return false;
193
+ }
194
+ /**
195
+ * 求解:返回完整解的新棋盘;若初始棋盘非法(已冲突)/无解返回 null。
196
+ * 不修改入参。
197
+ */
198
+ function solve(grid) {
199
+ if (hasConflict(grid)) return null;
200
+ const work = cloneGrid(grid);
201
+ return fillInPlace(work) ? work : null;
202
+ }
203
+ /**
204
+ * 统计解个数,最多数到 `limit` 即早停(默认 2)。
205
+ * 用于唯一性判定:`countSolutions(p) === 1` 表示唯一解。
206
+ * 不修改入参。`limit < 1` 视为 1;题面已冲突则返回 0。
207
+ */
208
+ function countSolutions(grid, limit = 2) {
209
+ if (hasConflict(grid)) return 0;
210
+ const cap = Math.max(1, limit);
211
+ const work = cloneGrid(grid);
212
+ let solutions = 0;
213
+ const rec = () => {
214
+ if (solutions >= cap) return;
215
+ if (isComplete(work)) {
216
+ solutions++;
217
+ return;
218
+ }
219
+ const cell = pickCell(work);
220
+ if (!cell) return;
221
+ for (const d of cell.cands) {
222
+ setCell(work, cell.row, cell.col, d);
223
+ rec();
224
+ setCell(work, cell.row, cell.col, 0);
225
+ if (solutions >= cap) return;
226
+ }
227
+ };
228
+ rec();
229
+ return solutions;
230
+ }
231
+ // src/generator.ts
232
+ /**
233
+ * 数独生成器:产出「有唯一解」的题目。
234
+ *
235
+ * 流程:
236
+ * 1. `generateFullGrid` —— 随机回溯填满一张合法终盘(作为 solution)。
237
+ * 2. `digHoles` —— 以随机顺序尝试把格子挖空;每挖一格前用 `countSolutions(limit=2)`
238
+ * 校验:挖掉后仍「唯一解」才保留,否则回填。挖到目标线索数或无格可挖为止。
239
+ *
240
+ * 这样产出的 puzzle 保证唯一解,且 puzzle ⊂ solution(挖掉的都是解里可推出来的格)。
241
+ * rng 注入保证单测可复现;默认 `Math.random`。
242
+ */
243
+ /** 各难度目标线索数(题面给定数字个数)。实际可能因唯一性约束略多。 */
244
+ var TARGET_CLUES = {
245
+ easy: 45,
246
+ medium: 35,
247
+ hard: 27
248
+ };
249
+ /** Fisher–Yates 洗牌(返回新数组,用注入 rng)。 */
250
+ function shuffle(input, rng) {
251
+ const arr = input.slice();
252
+ for (let i = arr.length - 1; i > 0; i--) {
253
+ const j = Math.floor(rng() * (i + 1));
254
+ const tmp = arr[i];
255
+ const a = arr[j];
256
+ if (tmp !== void 0 && a !== void 0) {
257
+ arr[i] = a;
258
+ arr[j] = tmp;
259
+ }
260
+ }
261
+ return arr;
262
+ }
263
+ /** 从 pos 起原地回溯填满 grid(每格用随机顺序的合法数字)。 */
264
+ function fillFully(grid, pos, rng) {
265
+ if (pos >= grid.length * grid.length) return true;
266
+ const row = Math.floor(pos / grid.length);
267
+ const col = pos % grid.length;
268
+ if (cellAt(grid, row, col) !== 0) return fillFully(grid, pos + 1, rng);
269
+ for (const d of shuffle(DIGITS, rng)) if (isSafe(grid, row, col, d)) {
270
+ setCell(grid, row, col, d);
271
+ if (fillFully(grid, pos + 1, rng)) return true;
272
+ setCell(grid, row, col, 0);
273
+ }
274
+ return false;
275
+ }
276
+ /** 生成一张随机合法终盘(9×9 全填、无冲突)。 */
277
+ function generateFullGrid(rng = Math.random) {
278
+ const grid = createEmptyGrid();
279
+ fillFully(grid, 0, rng);
280
+ return grid;
281
+ }
282
+ /**
283
+ * 在终盘副本上随机挖洞到目标线索数,保持唯一解。
284
+ * 返回挖洞后的题面(会修改并返回传入的 puzzle 副本,入参 solution 不动)。
285
+ */
286
+ function digHoles(solution, targetClues, rng) {
287
+ const puzzle = solution.map((row) => row.slice());
288
+ const total = puzzle.length * puzzle.length;
289
+ const order = [];
290
+ for (let i = 0; i < total; i++) order.push(i);
291
+ const shuffled = shuffle(order, rng);
292
+ let clues = total;
293
+ for (const idx of shuffled) {
294
+ if (clues <= targetClues) break;
295
+ const r = Math.floor(idx / puzzle.length);
296
+ const c = idx % puzzle.length;
297
+ const backup = cellAt(puzzle, r, c);
298
+ if (backup === 0) continue;
299
+ setCell(puzzle, r, c, 0);
300
+ if (countSolutions(puzzle, 2) === 1) clues--;
301
+ else setCell(puzzle, r, c, backup);
302
+ }
303
+ return puzzle;
304
+ }
305
+ /** 生成一道指定难度、唯一解的数独题。 */
306
+ function generatePuzzle(difficulty = "easy", rng = Math.random) {
307
+ const solution = generateFullGrid(rng);
308
+ const puzzle = digHoles(solution, TARGET_CLUES[difficulty] ?? TARGET_CLUES.easy, rng);
309
+ let clues = 0;
310
+ for (const row of puzzle) for (const v of row) if (v !== 0) clues++;
311
+ return {
312
+ puzzle,
313
+ solution,
314
+ difficulty,
315
+ clues
316
+ };
317
+ }
318
+ // src/storage.ts
319
+ /**
320
+ * 存档与最佳用时持久化。
321
+ *
322
+ * 版本策略:读到的 version ≠ SAVE_VERSION 时,`isSerializedSave` 守卫失败 →
323
+ * 上层丢弃并开新局。目前仅 v1。
324
+ *
325
+ * best 存的是 `BestTimes`(每个难度历史最佳用时秒数,null = 未通关),
326
+ * 以 JSON 落在单个 bestKey 下,读回时补全缺失难度为 null。
327
+ */
328
+ var SAVE_VERSION = 1;
329
+ var DEFAULT_SAVE_KEY = "game-sudoku:save";
330
+ var DEFAULT_BEST_KEY = "game-sudoku:best";
331
+ var DIFFICULTIES = [
332
+ "easy",
333
+ "medium",
334
+ "hard"
335
+ ];
336
+ function isDifficulty(x) {
337
+ return typeof x === "string" && DIFFICULTIES.includes(x);
338
+ }
339
+ /** 9×9 的二维数组守卫(浅校验行数与每行长度)。 */
340
+ function isGridLike(x) {
341
+ return Array.isArray(x) && x.length === 9 && x.every((row) => Array.isArray(row) && row.length === 9);
342
+ }
343
+ /** 粗粒度结构守卫:只保证关键字段形状,深层值由 Sudoku 载入时再防御。 */
344
+ function isSerializedSave(x) {
345
+ if (typeof x !== "object" || x === null) return false;
346
+ const o = x;
347
+ return o["version"] === 1 && isDifficulty(o["difficulty"]) && isGridLike(o["puzzle"]) && isGridLike(o["solution"]) && isGridLike(o["grid"]) && (o["status"] === "playing" || o["status"] === "won");
348
+ }
349
+ function loadSave(storage, key) {
350
+ if (!storage) return null;
351
+ const raw = storage.getItem(key);
352
+ if (!raw) return null;
353
+ try {
354
+ const parsed = JSON.parse(raw);
355
+ return isSerializedSave(parsed) ? parsed : null;
356
+ } catch {
357
+ return null;
358
+ }
359
+ }
360
+ function writeSave(storage, key, save) {
361
+ if (!storage) return;
362
+ try {
363
+ storage.setItem(key, JSON.stringify(save));
364
+ } catch {}
365
+ }
366
+ function clearSave(storage, key) {
367
+ if (!storage) return;
368
+ try {
369
+ storage.removeItem(key);
370
+ } catch {}
371
+ }
372
+ /** 空的 BestTimes(全 null)。 */
373
+ function emptyBestTimes() {
374
+ return {
375
+ easy: null,
376
+ medium: null,
377
+ hard: null
378
+ };
379
+ }
380
+ /** 读回 BestTimes;损坏/缺失字段回落为 null。 */
381
+ function loadBest(storage, key) {
382
+ const result = emptyBestTimes();
383
+ if (!storage) return result;
384
+ const raw = storage.getItem(key);
385
+ if (!raw) return result;
386
+ try {
387
+ const parsed = JSON.parse(raw);
388
+ if (typeof parsed === "object" && parsed !== null) {
389
+ const o = parsed;
390
+ for (const d of DIFFICULTIES) {
391
+ const v = o[d];
392
+ if (typeof v === "number" && Number.isFinite(v) && v >= 0) result[d] = Math.floor(v);
393
+ }
394
+ }
395
+ } catch {}
396
+ return result;
397
+ }
398
+ function writeBest(storage, key, best) {
399
+ if (!storage) return;
400
+ try {
401
+ storage.setItem(key, JSON.stringify(best));
402
+ } catch {}
403
+ }
404
+ /** 浏览器默认 Storage;无 DOM 环境返回 null,让 core 在 Node 也能跑。 */
405
+ function tryGetGlobalStorage() {
406
+ if (typeof globalThis === "undefined") return null;
407
+ return globalThis.localStorage ?? null;
408
+ }
409
+ // src/game.ts
410
+ var DEFAULT_DIFFICULTY = "easy";
411
+ var DEFAULT_UNDO_LIMIT = 50;
412
+ /** 深拷贝笔记网格。 */
413
+ function cloneNotes(notes) {
414
+ return notes.map((row) => row.map((cell) => cell.slice()));
415
+ }
416
+ /** 初始化 9×9 全空笔记。 */
417
+ function emptyNotes() {
418
+ return Array.from({ length: 9 }, () => Array.from({ length: 9 }, () => []));
419
+ }
420
+ /** 由题面推导「给定掩码」。 */
421
+ function maskFromPuzzle(puzzle) {
422
+ return puzzle.map((row) => row.map((v) => v !== 0));
423
+ }
424
+ /** 升序去重写回某格笔记。 */
425
+ function sortedUnique(digits) {
426
+ return Array.from(new Set(digits)).sort((a, b) => a - b);
427
+ }
428
+ /**
429
+ * Sudoku —— 数独状态机与规则实现,与 UI 完全解耦。
430
+ *
431
+ * - 通过 `subscribe(fn)` 推完整 `GameState` 快照(不推 diff);注册即收到一次。
432
+ * - 每次改变局面的公开操作(input/erase/toggleNote/hint/newGame/undo)成功后
433
+ * 走一次 `commit()`:写存档 + 通知订阅。`select()` 只更新选中并通知,不落盘。
434
+ * - 谜题来源:默认按 `difficulty` 现场生成唯一解题;也可注入 `puzzle`(+可选 solution)
435
+ * 用作固定题/测试。`newGame()` 总是重新随机生成一道。
436
+ * - 计时:注入 `clock`(默认 Date.now);`elapsedMs = baseMs + (clock()-origin)`,
437
+ * 通关瞬间冻结为 `finalMs`。undo 回未到通关的快照会按当时累计值续表。
438
+ * - 存档:注入 storage(默认尝试 globalThis.localStorage);`best` 是分难度最佳用时。
439
+ */
440
+ var Sudoku = class {
441
+ constructor(opts = {}) {
442
+ this.selected = null;
443
+ this.status = "playing";
444
+ this.hintCount = 0;
445
+ this.mistakeCount = 0;
446
+ this.moveCount = 0;
447
+ this.history = [];
448
+ this.started = false;
449
+ this.baseMs = 0;
450
+ this.origin = 0;
451
+ this.finalMs = 0;
452
+ this.listeners = /* @__PURE__ */ new Set();
453
+ this.notifying = false;
454
+ this.rng = opts.rng ?? Math.random;
455
+ this.clock = opts.clock ?? Date.now;
456
+ this.storage = opts.storage === void 0 ? tryGetGlobalStorage() : opts.storage;
457
+ this.saveKey = opts.saveKey ?? "game-sudoku:save";
458
+ this.bestKey = opts.bestKey ?? "game-sudoku:best";
459
+ this.undoLimit = opts.undoLimit ?? DEFAULT_UNDO_LIMIT;
460
+ this.difficulty = opts.difficulty ?? DEFAULT_DIFFICULTY;
461
+ this.best = loadBest(this.storage, this.bestKey);
462
+ this.fixedPuzzle = opts.puzzle ? cloneGrid(opts.puzzle) : null;
463
+ this.fixedSolution = opts.solution ? cloneGrid(opts.solution) : null;
464
+ this.puzzle = this.fixedPuzzle ?? cloneGrid(generatePuzzle(this.difficulty, this.rng).puzzle);
465
+ this.solution = this.deriveSolution(this.puzzle);
466
+ const saved = opts.freshStart ? null : loadSave(this.storage, this.saveKey);
467
+ if (saved && this.canReserve(saved)) this.loadFromSave(saved);
468
+ else this.startNewGame(this.difficulty);
469
+ }
470
+ /** 订阅状态变化,返回退订函数;注册后立刻收到一次当前快照。 */
471
+ subscribe(fn) {
472
+ this.listeners.add(fn);
473
+ try {
474
+ fn(this.getState());
475
+ } catch (err) {
476
+ console.error("[game-sudoku] listener threw on subscribe:", err);
477
+ }
478
+ return () => {
479
+ this.listeners.delete(fn);
480
+ };
481
+ }
482
+ getState() {
483
+ return {
484
+ difficulty: this.difficulty,
485
+ grid: cloneGrid(this.grid),
486
+ givenMask: this.givenMask.map((row) => row.slice()),
487
+ notes: cloneNotes(this.notes),
488
+ selected: this.selected ? { ...this.selected } : null,
489
+ conflicts: findConflicts(this.grid),
490
+ status: this.status,
491
+ elapsedSeconds: Math.floor(this.elapsedMs() / 1e3),
492
+ bestSeconds: this.best[this.difficulty],
493
+ hintCount: this.hintCount,
494
+ mistakeCount: this.mistakeCount,
495
+ moveCount: this.moveCount,
496
+ historySize: this.history.length
497
+ };
498
+ }
499
+ getGrid() {
500
+ return cloneGrid(this.grid);
501
+ }
502
+ getPuzzle() {
503
+ return cloneGrid(this.puzzle);
504
+ }
505
+ getSolution() {
506
+ return cloneGrid(this.solution);
507
+ }
508
+ canUndo() {
509
+ return this.history.length > 0;
510
+ }
511
+ /** 选中某格(越界则忽略)。仅更新选中并通知订阅,不写存档、不入 undo 栈。 */
512
+ select(row, col) {
513
+ if (row < 0 || row >= 9 || col < 0 || col >= 9) return;
514
+ if (this.selected?.row === row && this.selected?.col === col) return;
515
+ this.selected = {
516
+ row,
517
+ col
518
+ };
519
+ this.notify();
520
+ }
521
+ /**
522
+ * 在选中格填数字。题面给定格 / 已通关时忽略。
523
+ * 返回 true = 棋盘确有变化。填了与盘面冲突的值会计一次 mistake(仍允许填)。
524
+ */
525
+ input(digit) {
526
+ if (this.status === "won") return false;
527
+ const sel = this.selected;
528
+ if (!sel) return false;
529
+ if (this.givenMask[sel.row]?.[sel.col]) return false;
530
+ if (cellAt(this.grid, sel.row, sel.col) === digit) return false;
531
+ this.pushHistory();
532
+ this.ensureStarted();
533
+ setCell(this.grid, sel.row, sel.col, digit);
534
+ const notesRow = this.notes[sel.row];
535
+ if (notesRow) notesRow[sel.col] = [];
536
+ this.moveCount++;
537
+ if (!isSafe(this.grid, sel.row, sel.col, digit)) this.mistakeCount++;
538
+ this.checkWin();
539
+ this.commit();
540
+ return true;
541
+ }
542
+ /** 擦除选中格(值与笔记一起清)。题面给定 / 已通关忽略。返回是否有变化。 */
543
+ erase() {
544
+ if (this.status === "won") return false;
545
+ const sel = this.selected;
546
+ if (!sel) return false;
547
+ if (this.givenMask[sel.row]?.[sel.col]) return false;
548
+ const hadValue = cellAt(this.grid, sel.row, sel.col) !== 0;
549
+ const notesRow = this.notes[sel.row];
550
+ const hadNotes = !!notesRow?.[sel.col]?.length;
551
+ if (!hadValue && !hadNotes) return false;
552
+ this.pushHistory();
553
+ setCell(this.grid, sel.row, sel.col, 0);
554
+ if (notesRow) notesRow[sel.col] = [];
555
+ if (hadValue) this.moveCount++;
556
+ this.commit();
557
+ return true;
558
+ }
559
+ /**
560
+ * 切换选中空格的某条铅笔笔记。仅对「非给定且当前为空」的格生效。
561
+ * 返回 true = 笔记集合确有变化。
562
+ */
563
+ toggleNote(digit) {
564
+ if (this.status === "won") return false;
565
+ const sel = this.selected;
566
+ if (!sel) return false;
567
+ if (this.givenMask[sel.row]?.[sel.col]) return false;
568
+ if (cellAt(this.grid, sel.row, sel.col) !== 0) return false;
569
+ const notesRow = this.notes[sel.row];
570
+ if (!notesRow) return false;
571
+ const current = notesRow[sel.col] ?? [];
572
+ const has = current.includes(digit);
573
+ this.pushHistory();
574
+ notesRow[sel.col] = has ? sortedUnique(current.filter((d) => d !== digit)) : sortedUnique([...current, digit]);
575
+ this.commit();
576
+ return true;
577
+ }
578
+ /**
579
+ * 提示:优先填「选中的空格」,否则填第一个空格;写入解中的正确值并清该格笔记。
580
+ * 返回 true = 确有一格被填。计一次 hint(不单独计 mistake)。
581
+ */
582
+ hint() {
583
+ if (this.status === "won") return false;
584
+ const target = this.pickHintTarget();
585
+ if (!target) return false;
586
+ const answer = cellAt(this.solution, target.row, target.col);
587
+ if (answer === 0) return false;
588
+ this.pushHistory();
589
+ this.ensureStarted();
590
+ setCell(this.grid, target.row, target.col, answer);
591
+ const notesRow = this.notes[target.row];
592
+ if (notesRow) notesRow[target.col] = [];
593
+ this.selected = target;
594
+ this.hintCount++;
595
+ this.moveCount++;
596
+ this.checkWin();
597
+ this.commit();
598
+ return true;
599
+ }
600
+ /**
601
+ * 开启新局:随机生成指定难度(默认沿用当前难度)的唯一解题,清空局面与历史,
602
+ * 计时归零,best 保留。会清除旧存档并落一份新局存档。
603
+ */
604
+ newGame(difficulty = this.difficulty) {
605
+ this.startNewGame(difficulty);
606
+ this.commit();
607
+ }
608
+ /** 撤销一步;返回是否成功。 */
609
+ undo() {
610
+ const s = this.history.pop();
611
+ if (!s) return false;
612
+ this.restoreSnapshot(s);
613
+ this.commit();
614
+ return true;
615
+ }
616
+ /** 清除当前存档(不影响 best)。 */
617
+ clearSaved() {
618
+ clearSave(this.storage, this.saveKey);
619
+ }
620
+ /** 由(部分)题面推导完整解:优先用注入的 solution,否则用 solve。 */
621
+ deriveSolution(puzzle) {
622
+ if (this.fixedSolution && !hasConflict(puzzle) && gridsCompatible(puzzle, this.fixedSolution)) return cloneGrid(this.fixedSolution);
623
+ return solve(puzzle) ?? cloneGrid(puzzle);
624
+ }
625
+ /** 存档是否可续用:同难度、同题面(防切难度/换题后串档)。 */
626
+ canReserve(save) {
627
+ if (save.difficulty !== this.difficulty) return false;
628
+ if (this.fixedPuzzle) return gridsCompatible(this.fixedPuzzle, save.puzzle);
629
+ return true;
630
+ }
631
+ startNewGame(difficulty) {
632
+ this.difficulty = difficulty;
633
+ if (this.fixedPuzzle) {
634
+ this.puzzle = cloneGrid(this.fixedPuzzle);
635
+ this.solution = this.deriveSolution(this.puzzle);
636
+ } else {
637
+ const gen = generatePuzzle(difficulty, this.rng);
638
+ this.puzzle = gen.puzzle;
639
+ this.solution = gen.solution;
640
+ }
641
+ this.givenMask = maskFromPuzzle(this.puzzle);
642
+ this.grid = cloneGrid(this.puzzle);
643
+ this.notes = emptyNotes();
644
+ this.selected = firstEmpty(this.grid);
645
+ this.status = "playing";
646
+ this.hintCount = 0;
647
+ this.mistakeCount = 0;
648
+ this.moveCount = 0;
649
+ this.history = [];
650
+ this.started = false;
651
+ this.baseMs = 0;
652
+ this.finalMs = 0;
653
+ this.origin = this.clock();
654
+ this.clearSaved();
655
+ }
656
+ /** 首次填充(input/hint)时懒起表;未起表前 elapsedMs 恒 0。 */
657
+ ensureStarted() {
658
+ if (this.started) return;
659
+ this.started = true;
660
+ this.baseMs = 0;
661
+ this.origin = this.clock();
662
+ }
663
+ elapsedMs() {
664
+ if (this.status === "won") return this.finalMs;
665
+ if (!this.started) return 0;
666
+ return this.baseMs + (this.clock() - this.origin);
667
+ }
668
+ checkWin() {
669
+ if (this.status !== "playing") return;
670
+ for (let r = 0; r < 9; r++) for (let c = 0; c < 9; c++) if (cellAt(this.grid, r, c) === 0) return;
671
+ for (const row of findConflicts(this.grid)) for (const v of row) if (v) return;
672
+ this.finalMs = this.baseMs + (this.clock() - this.origin);
673
+ this.status = "won";
674
+ const secs = Math.floor(this.finalMs / 1e3);
675
+ const cur = this.best[this.difficulty];
676
+ if (cur === null || secs < cur) {
677
+ this.best[this.difficulty] = secs;
678
+ writeBest(this.storage, this.bestKey, this.best);
679
+ }
680
+ }
681
+ pickHintTarget() {
682
+ const sel = this.selected;
683
+ if (sel && cellAt(this.grid, sel.row, sel.col) === 0 && !this.givenMask[sel.row]?.[sel.col]) return { ...sel };
684
+ return firstEmpty(this.grid);
685
+ }
686
+ pushHistory() {
687
+ const snap = {
688
+ grid: cloneGrid(this.grid),
689
+ notes: cloneNotes(this.notes),
690
+ selected: this.selected ? { ...this.selected } : null,
691
+ status: this.status,
692
+ hintCount: this.hintCount,
693
+ mistakeCount: this.mistakeCount,
694
+ moveCount: this.moveCount,
695
+ elapsedMs: this.elapsedMs(),
696
+ started: this.started
697
+ };
698
+ this.history.push(snap);
699
+ if (this.history.length > this.undoLimit) this.history.shift();
700
+ }
701
+ restoreSnapshot(s) {
702
+ this.grid = cloneGrid(s.grid);
703
+ this.notes = cloneNotes(s.notes);
704
+ this.selected = s.selected ? { ...s.selected } : null;
705
+ this.status = s.status;
706
+ this.hintCount = s.hintCount;
707
+ this.mistakeCount = s.mistakeCount;
708
+ this.moveCount = s.moveCount;
709
+ this.started = s.started;
710
+ if (s.status === "won") this.finalMs = s.elapsedMs;
711
+ else {
712
+ this.baseMs = s.elapsedMs;
713
+ this.origin = this.clock();
714
+ }
715
+ }
716
+ loadFromSave(save) {
717
+ this.difficulty = save.difficulty;
718
+ this.puzzle = cloneGrid(save.puzzle);
719
+ this.solution = cloneGrid(save.solution);
720
+ this.givenMask = maskFromPuzzle(this.puzzle);
721
+ this.grid = cloneGrid(save.grid);
722
+ this.notes = save.notes ? cloneNotes(save.notes) : emptyNotes();
723
+ this.status = save.status === "won" ? "won" : "playing";
724
+ this.hintCount = numberOr(save.hintCount, 0);
725
+ this.mistakeCount = numberOr(save.mistakeCount, 0);
726
+ this.moveCount = numberOr(save.moveCount, 0);
727
+ this.history = [];
728
+ this.selected = firstEmpty(this.grid);
729
+ const elapsed = numberOr(save.elapsedMs, 0);
730
+ this.started = this.status === "won" || this.moveCount > 0 || elapsed > 0;
731
+ if (this.status === "won") {
732
+ this.finalMs = elapsed;
733
+ this.baseMs = elapsed;
734
+ this.origin = this.clock();
735
+ } else {
736
+ this.baseMs = elapsed;
737
+ this.finalMs = 0;
738
+ this.origin = this.clock();
739
+ }
740
+ }
741
+ commit() {
742
+ writeSave(this.storage, this.saveKey, {
743
+ version: 1,
744
+ difficulty: this.difficulty,
745
+ puzzle: cloneGrid(this.puzzle),
746
+ solution: cloneGrid(this.solution),
747
+ grid: cloneGrid(this.grid),
748
+ notes: cloneNotes(this.notes),
749
+ status: this.status,
750
+ hintCount: this.hintCount,
751
+ mistakeCount: this.mistakeCount,
752
+ moveCount: this.moveCount,
753
+ elapsedMs: this.elapsedMs()
754
+ });
755
+ this.notify();
756
+ }
757
+ notify() {
758
+ if (this.notifying) return;
759
+ this.notifying = true;
760
+ try {
761
+ const snapshot = this.getState();
762
+ for (const fn of this.listeners) try {
763
+ fn(snapshot);
764
+ } catch (err) {
765
+ console.error("[game-sudoku] listener threw:", err);
766
+ }
767
+ } finally {
768
+ this.notifying = false;
769
+ }
770
+ }
771
+ };
772
+ /** puzzle ⊂ solution:puzzle 非 0 处 solution 必须同值。 */
773
+ function gridsCompatible(puzzle, solution) {
774
+ for (let r = 0; r < puzzle.length; r++) for (let c = 0; c < (puzzle[r]?.length ?? 0); c++) {
775
+ const p = cellAt(puzzle, r, c);
776
+ if (p !== 0 && cellAt(solution, r, c) !== p) return false;
777
+ }
778
+ return true;
779
+ }
780
+ function firstEmpty(grid) {
781
+ for (let r = 0; r < 9; r++) for (let c = 0; c < 9; c++) if (cellAt(grid, r, c) === 0) return {
782
+ row: r,
783
+ col: c
784
+ };
785
+ return null;
786
+ }
787
+ function numberOr(x, fallback) {
788
+ return typeof x === "number" && Number.isFinite(x) ? x : fallback;
789
+ }
790
+ export { BOX, DEFAULT_BEST_KEY, DEFAULT_SAVE_KEY, DIGITS, SAVE_VERSION, SIZE, Sudoku, TARGET_CLUES, candidates, cellAt, clearSave, cloneGrid, countFilled, countSolutions, createEmptyGrid, emptyBestTimes, emptyCells, findConflicts, generateFullGrid, generatePuzzle, gridsEqual, hasConflict, isFull, isSafe, isSerializedSave, isSolved, loadBest, loadSave, makeMask, peers, setCell, solve, tryGetGlobalStorage, withCell, writeBest, writeSave };
18
791
 
19
792
  //# sourceMappingURL=index.js.map