@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/src/game.ts ADDED
@@ -0,0 +1,510 @@
1
+ import { SIZE, cellAt, cloneGrid, findConflicts, hasConflict, isSafe, setCell } from './board';
2
+ import { generatePuzzle } from './generator';
3
+ import { solve } from './solver';
4
+ import {
5
+ DEFAULT_BEST_KEY,
6
+ DEFAULT_SAVE_KEY,
7
+ clearSave,
8
+ loadBest,
9
+ loadSave,
10
+ tryGetGlobalStorage,
11
+ writeBest,
12
+ writeSave
13
+ } from './storage';
14
+ import type {
15
+ BestTimes,
16
+ Difficulty,
17
+ Digit,
18
+ GameOptions,
19
+ GameState,
20
+ GameStatus,
21
+ GivenMask,
22
+ Grid,
23
+ NoteGrid,
24
+ Position,
25
+ RandomSource,
26
+ Clock,
27
+ SaveStorage,
28
+ SerializedSave,
29
+ Snapshot
30
+ } from './types';
31
+
32
+ export type { GameOptions } from './types';
33
+
34
+ const DEFAULT_DIFFICULTY: Difficulty = 'easy';
35
+ const DEFAULT_UNDO_LIMIT = 50;
36
+
37
+ /** 深拷贝笔记网格。 */
38
+ function cloneNotes(notes: NoteGrid): NoteGrid {
39
+ return notes.map((row) => row.map((cell) => cell.slice()));
40
+ }
41
+
42
+ /** 初始化 9×9 全空笔记。 */
43
+ function emptyNotes(): NoteGrid {
44
+ return Array.from({ length: SIZE }, () =>
45
+ Array.from({ length: SIZE }, () => [] as Digit[])
46
+ );
47
+ }
48
+
49
+ /** 由题面推导「给定掩码」。 */
50
+ function maskFromPuzzle(puzzle: Grid): GivenMask {
51
+ return puzzle.map((row) => row.map((v) => v !== 0));
52
+ }
53
+
54
+ /** 升序去重写回某格笔记。 */
55
+ function sortedUnique(digits: Digit[]): Digit[] {
56
+ return Array.from(new Set(digits)).sort((a, b) => a - b);
57
+ }
58
+
59
+ /**
60
+ * Sudoku —— 数独状态机与规则实现,与 UI 完全解耦。
61
+ *
62
+ * - 通过 `subscribe(fn)` 推完整 `GameState` 快照(不推 diff);注册即收到一次。
63
+ * - 每次改变局面的公开操作(input/erase/toggleNote/hint/newGame/undo)成功后
64
+ * 走一次 `commit()`:写存档 + 通知订阅。`select()` 只更新选中并通知,不落盘。
65
+ * - 谜题来源:默认按 `difficulty` 现场生成唯一解题;也可注入 `puzzle`(+可选 solution)
66
+ * 用作固定题/测试。`newGame()` 总是重新随机生成一道。
67
+ * - 计时:注入 `clock`(默认 Date.now);`elapsedMs = baseMs + (clock()-origin)`,
68
+ * 通关瞬间冻结为 `finalMs`。undo 回未到通关的快照会按当时累计值续表。
69
+ * - 存档:注入 storage(默认尝试 globalThis.localStorage);`best` 是分难度最佳用时。
70
+ */
71
+ export class Sudoku {
72
+ private readonly rng: RandomSource;
73
+ private readonly clock: Clock;
74
+ private readonly storage: SaveStorage | null;
75
+ private readonly saveKey: string;
76
+ private readonly bestKey: string;
77
+ private readonly undoLimit: number;
78
+ private readonly fixedPuzzle: Grid | null;
79
+ private readonly fixedSolution: Grid | null;
80
+
81
+ private difficulty: Difficulty;
82
+ private puzzle: Grid;
83
+ private solution: Grid;
84
+ // givenMask/grid/notes 由构造函数分派的 startNewGame/loadFromSave 必赋值;
85
+ // selected 默认无选中。
86
+ private givenMask!: GivenMask;
87
+ private grid!: Grid;
88
+ private notes!: NoteGrid;
89
+ private selected: Position | null = null;
90
+ private status: GameStatus = 'playing';
91
+ private hintCount = 0;
92
+ private mistakeCount = 0;
93
+ private moveCount = 0;
94
+ private history: Snapshot[] = [];
95
+
96
+ // 计时三段量:started=false 时 elapsedMs 恒 0,首次填充(input/hint)才起算。
97
+ private started = false;
98
+ private baseMs = 0;
99
+ private origin = 0;
100
+ private finalMs = 0;
101
+
102
+ private best: BestTimes;
103
+
104
+ private listeners = new Set<(state: GameState) => void>();
105
+ private notifying = false;
106
+
107
+ constructor(opts: GameOptions = {}) {
108
+ this.rng = opts.rng ?? Math.random;
109
+ this.clock = opts.clock ?? Date.now;
110
+ this.storage = opts.storage === undefined ? tryGetGlobalStorage() : opts.storage;
111
+ this.saveKey = opts.saveKey ?? DEFAULT_SAVE_KEY;
112
+ this.bestKey = opts.bestKey ?? DEFAULT_BEST_KEY;
113
+ this.undoLimit = opts.undoLimit ?? DEFAULT_UNDO_LIMIT;
114
+ this.difficulty = opts.difficulty ?? DEFAULT_DIFFICULTY;
115
+ this.best = loadBest(this.storage, this.bestKey);
116
+
117
+ this.fixedPuzzle = opts.puzzle ? cloneGrid(opts.puzzle) : null;
118
+ this.fixedSolution = opts.solution ? cloneGrid(opts.solution) : null;
119
+
120
+ // 先给一个合法初值(构造分支会覆盖)。
121
+ this.puzzle = this.fixedPuzzle ?? cloneGrid(generatePuzzle(this.difficulty, this.rng).puzzle);
122
+ this.solution = this.deriveSolution(this.puzzle);
123
+
124
+ const saved = opts.freshStart ? null : loadSave(this.storage, this.saveKey);
125
+ if (saved && this.canReserve(saved)) {
126
+ this.loadFromSave(saved);
127
+ } else {
128
+ this.startNewGame(this.difficulty);
129
+ }
130
+ }
131
+
132
+ // ---------- 公共 API ----------
133
+
134
+ /** 订阅状态变化,返回退订函数;注册后立刻收到一次当前快照。 */
135
+ subscribe(fn: (state: GameState) => void): () => void {
136
+ this.listeners.add(fn);
137
+ try {
138
+ fn(this.getState());
139
+ } catch (err) {
140
+ // eslint-disable-next-line no-console
141
+ console.error('[game-sudoku] listener threw on subscribe:', err);
142
+ }
143
+ return () => {
144
+ this.listeners.delete(fn);
145
+ };
146
+ }
147
+
148
+ getState(): GameState {
149
+ return {
150
+ difficulty: this.difficulty,
151
+ grid: cloneGrid(this.grid),
152
+ givenMask: this.givenMask.map((row) => row.slice()),
153
+ notes: cloneNotes(this.notes),
154
+ selected: this.selected ? { ...this.selected } : null,
155
+ conflicts: findConflicts(this.grid),
156
+ status: this.status,
157
+ elapsedSeconds: Math.floor(this.elapsedMs() / 1000),
158
+ bestSeconds: this.best[this.difficulty],
159
+ hintCount: this.hintCount,
160
+ mistakeCount: this.mistakeCount,
161
+ moveCount: this.moveCount,
162
+ historySize: this.history.length
163
+ };
164
+ }
165
+
166
+ getGrid(): Grid {
167
+ return cloneGrid(this.grid);
168
+ }
169
+
170
+ getPuzzle(): Grid {
171
+ return cloneGrid(this.puzzle);
172
+ }
173
+
174
+ getSolution(): Grid {
175
+ return cloneGrid(this.solution);
176
+ }
177
+
178
+ canUndo(): boolean {
179
+ return this.history.length > 0;
180
+ }
181
+
182
+ /** 选中某格(越界则忽略)。仅更新选中并通知订阅,不写存档、不入 undo 栈。 */
183
+ select(row: number, col: number): void {
184
+ if (row < 0 || row >= SIZE || col < 0 || col >= SIZE) return;
185
+ if (this.selected?.row === row && this.selected?.col === col) return;
186
+ this.selected = { row, col };
187
+ this.notify();
188
+ }
189
+
190
+ /**
191
+ * 在选中格填数字。题面给定格 / 已通关时忽略。
192
+ * 返回 true = 棋盘确有变化。填了与盘面冲突的值会计一次 mistake(仍允许填)。
193
+ */
194
+ input(digit: Digit): boolean {
195
+ if (this.status === 'won') return false;
196
+ const sel = this.selected;
197
+ if (!sel) return false;
198
+ if (this.givenMask[sel.row]?.[sel.col]) return false; // 题面给定,禁改
199
+ if (cellAt(this.grid, sel.row, sel.col) === digit) return false; // 同值,无变化
200
+
201
+ this.pushHistory();
202
+ this.ensureStarted(); // 首次填充起表
203
+ setCell(this.grid, sel.row, sel.col, digit);
204
+ const notesRow = this.notes[sel.row];
205
+ if (notesRow) notesRow[sel.col] = []; // 有值即清空该格笔记
206
+ this.moveCount++;
207
+ if (!isSafe(this.grid, sel.row, sel.col, digit)) this.mistakeCount++;
208
+ this.checkWin();
209
+ this.commit();
210
+ return true;
211
+ }
212
+
213
+ /** 擦除选中格(值与笔记一起清)。题面给定 / 已通关忽略。返回是否有变化。 */
214
+ erase(): boolean {
215
+ if (this.status === 'won') return false;
216
+ const sel = this.selected;
217
+ if (!sel) return false;
218
+ if (this.givenMask[sel.row]?.[sel.col]) return false;
219
+ const hadValue = cellAt(this.grid, sel.row, sel.col) !== 0;
220
+ const notesRow = this.notes[sel.row];
221
+ const hadNotes = !!notesRow?.[sel.col]?.length;
222
+ if (!hadValue && !hadNotes) return false; // 本来就空,无变化
223
+
224
+ this.pushHistory();
225
+ setCell(this.grid, sel.row, sel.col, 0);
226
+ if (notesRow) notesRow[sel.col] = [];
227
+ if (hadValue) this.moveCount++;
228
+ this.commit();
229
+ return true;
230
+ }
231
+
232
+ /**
233
+ * 切换选中空格的某条铅笔笔记。仅对「非给定且当前为空」的格生效。
234
+ * 返回 true = 笔记集合确有变化。
235
+ */
236
+ toggleNote(digit: Digit): boolean {
237
+ if (this.status === 'won') return false;
238
+ const sel = this.selected;
239
+ if (!sel) return false;
240
+ if (this.givenMask[sel.row]?.[sel.col]) return false;
241
+ if (cellAt(this.grid, sel.row, sel.col) !== 0) return false; // 有值不谈笔记
242
+
243
+ const notesRow = this.notes[sel.row];
244
+ if (!notesRow) return false;
245
+ const current = notesRow[sel.col] ?? [];
246
+ const has = current.includes(digit);
247
+ this.pushHistory();
248
+ notesRow[sel.col] = has
249
+ ? sortedUnique(current.filter((d) => d !== digit))
250
+ : sortedUnique([...current, digit]);
251
+ this.commit();
252
+ return true;
253
+ }
254
+
255
+ /**
256
+ * 提示:优先填「选中的空格」,否则填第一个空格;写入解中的正确值并清该格笔记。
257
+ * 返回 true = 确有一格被填。计一次 hint(不单独计 mistake)。
258
+ */
259
+ hint(): boolean {
260
+ if (this.status === 'won') return false;
261
+ const target = this.pickHintTarget();
262
+ if (!target) return false;
263
+ const answer = cellAt(this.solution, target.row, target.col);
264
+ if (answer === 0) return false;
265
+
266
+ this.pushHistory();
267
+ this.ensureStarted(); // 提示也算开始作答,起表
268
+ setCell(this.grid, target.row, target.col, answer);
269
+ const notesRow = this.notes[target.row];
270
+ if (notesRow) notesRow[target.col] = [];
271
+ this.selected = target;
272
+ this.hintCount++;
273
+ this.moveCount++;
274
+ this.checkWin();
275
+ this.commit();
276
+ return true;
277
+ }
278
+
279
+ /**
280
+ * 开启新局:随机生成指定难度(默认沿用当前难度)的唯一解题,清空局面与历史,
281
+ * 计时归零,best 保留。会清除旧存档并落一份新局存档。
282
+ */
283
+ newGame(difficulty: Difficulty = this.difficulty): void {
284
+ this.startNewGame(difficulty);
285
+ this.commit();
286
+ }
287
+
288
+ /** 撤销一步;返回是否成功。 */
289
+ undo(): boolean {
290
+ const s = this.history.pop();
291
+ if (!s) return false;
292
+ this.restoreSnapshot(s);
293
+ this.commit();
294
+ return true;
295
+ }
296
+
297
+ /** 清除当前存档(不影响 best)。 */
298
+ clearSaved(): void {
299
+ clearSave(this.storage, this.saveKey);
300
+ }
301
+
302
+ // ---------- 内部 ----------
303
+
304
+ /** 由(部分)题面推导完整解:优先用注入的 solution,否则用 solve。 */
305
+ private deriveSolution(puzzle: Grid): Grid {
306
+ if (this.fixedSolution && !hasConflict(puzzle) && gridsCompatible(puzzle, this.fixedSolution)) {
307
+ return cloneGrid(this.fixedSolution);
308
+ }
309
+ const solved = solve(puzzle);
310
+ return solved ?? cloneGrid(puzzle);
311
+ }
312
+
313
+ /** 存档是否可续用:同难度、同题面(防切难度/换题后串档)。 */
314
+ private canReserve(save: SerializedSave): boolean {
315
+ if (save.difficulty !== this.difficulty) return false;
316
+ // 有固定题面时只认同一道题;无固定题面时,允许续任何同难度存档。
317
+ if (this.fixedPuzzle) return gridsCompatible(this.fixedPuzzle, save.puzzle);
318
+ return true;
319
+ }
320
+
321
+ private startNewGame(difficulty: Difficulty): void {
322
+ this.difficulty = difficulty;
323
+ if (this.fixedPuzzle) {
324
+ this.puzzle = cloneGrid(this.fixedPuzzle);
325
+ this.solution = this.deriveSolution(this.puzzle);
326
+ } else {
327
+ const gen = generatePuzzle(difficulty, this.rng);
328
+ this.puzzle = gen.puzzle;
329
+ this.solution = gen.solution;
330
+ }
331
+ this.givenMask = maskFromPuzzle(this.puzzle);
332
+ this.grid = cloneGrid(this.puzzle);
333
+ this.notes = emptyNotes();
334
+ this.selected = firstEmpty(this.grid);
335
+ this.status = 'playing';
336
+ this.hintCount = 0;
337
+ this.mistakeCount = 0;
338
+ this.moveCount = 0;
339
+ this.history = [];
340
+ this.started = false;
341
+ this.baseMs = 0;
342
+ this.finalMs = 0;
343
+ this.origin = this.clock();
344
+ this.clearSaved();
345
+ }
346
+
347
+ /** 首次填充(input/hint)时懒起表;未起表前 elapsedMs 恒 0。 */
348
+ private ensureStarted(): void {
349
+ if (this.started) return;
350
+ this.started = true;
351
+ this.baseMs = 0;
352
+ this.origin = this.clock();
353
+ }
354
+
355
+ private elapsedMs(): number {
356
+ if (this.status === 'won') return this.finalMs;
357
+ if (!this.started) return 0;
358
+ return this.baseMs + (this.clock() - this.origin);
359
+ }
360
+
361
+ private checkWin(): void {
362
+ if (this.status !== 'playing') return;
363
+ for (let r = 0; r < SIZE; r++) {
364
+ for (let c = 0; c < SIZE; c++) {
365
+ if (cellAt(this.grid, r, c) === 0) return; // 仍有空格
366
+ }
367
+ }
368
+ // 填满后判冲突;无冲突即通关。
369
+ for (const row of findConflicts(this.grid)) {
370
+ for (const v of row) if (v) return; // 填满但有冲突 → 不算通关
371
+ }
372
+ this.finalMs = this.baseMs + (this.clock() - this.origin);
373
+ this.status = 'won';
374
+ const secs = Math.floor(this.finalMs / 1000);
375
+ const cur = this.best[this.difficulty];
376
+ if (cur === null || secs < cur) {
377
+ this.best[this.difficulty] = secs;
378
+ writeBest(this.storage, this.bestKey, this.best);
379
+ }
380
+ }
381
+
382
+ private pickHintTarget(): Position | null {
383
+ const sel = this.selected;
384
+ if (sel && cellAt(this.grid, sel.row, sel.col) === 0 && !this.givenMask[sel.row]?.[sel.col]) {
385
+ return { ...sel };
386
+ }
387
+ return firstEmpty(this.grid);
388
+ }
389
+
390
+ private pushHistory(): void {
391
+ const snap: Snapshot = {
392
+ grid: cloneGrid(this.grid),
393
+ notes: cloneNotes(this.notes),
394
+ selected: this.selected ? { ...this.selected } : null,
395
+ status: this.status,
396
+ hintCount: this.hintCount,
397
+ mistakeCount: this.mistakeCount,
398
+ moveCount: this.moveCount,
399
+ elapsedMs: this.elapsedMs(),
400
+ started: this.started
401
+ };
402
+ this.history.push(snap);
403
+ if (this.history.length > this.undoLimit) this.history.shift();
404
+ }
405
+
406
+ private restoreSnapshot(s: Snapshot): void {
407
+ this.grid = cloneGrid(s.grid);
408
+ this.notes = cloneNotes(s.notes);
409
+ this.selected = s.selected ? { ...s.selected } : null;
410
+ this.status = s.status;
411
+ this.hintCount = s.hintCount;
412
+ this.mistakeCount = s.mistakeCount;
413
+ this.moveCount = s.moveCount;
414
+ this.started = s.started;
415
+ if (s.status === 'won') {
416
+ this.finalMs = s.elapsedMs;
417
+ } else {
418
+ this.baseMs = s.elapsedMs;
419
+ this.origin = this.clock();
420
+ }
421
+ }
422
+
423
+ private loadFromSave(save: SerializedSave): void {
424
+ this.difficulty = save.difficulty;
425
+ this.puzzle = cloneGrid(save.puzzle);
426
+ this.solution = cloneGrid(save.solution);
427
+ this.givenMask = maskFromPuzzle(this.puzzle);
428
+ this.grid = cloneGrid(save.grid);
429
+ this.notes = save.notes ? cloneNotes(save.notes) : emptyNotes();
430
+ this.status = save.status === 'won' ? 'won' : 'playing';
431
+ this.hintCount = numberOr(save.hintCount, 0);
432
+ this.mistakeCount = numberOr(save.mistakeCount, 0);
433
+ this.moveCount = numberOr(save.moveCount, 0);
434
+ this.history = [];
435
+ this.selected = firstEmpty(this.grid);
436
+ const elapsed = numberOr(save.elapsedMs, 0);
437
+ // 续档时按局面推定是否已起表:通关或已有操作/用时 → 已起表;否则等到首次填充才走。
438
+ this.started = this.status === 'won' || this.moveCount > 0 || elapsed > 0;
439
+ if (this.status === 'won') {
440
+ this.finalMs = elapsed;
441
+ this.baseMs = elapsed;
442
+ this.origin = this.clock();
443
+ } else {
444
+ this.baseMs = elapsed;
445
+ this.finalMs = 0;
446
+ this.origin = this.clock();
447
+ }
448
+ }
449
+
450
+ private commit(): void {
451
+ writeSave(this.storage, this.saveKey, {
452
+ version: 1,
453
+ difficulty: this.difficulty,
454
+ puzzle: cloneGrid(this.puzzle),
455
+ solution: cloneGrid(this.solution),
456
+ grid: cloneGrid(this.grid),
457
+ notes: cloneNotes(this.notes),
458
+ status: this.status,
459
+ hintCount: this.hintCount,
460
+ mistakeCount: this.mistakeCount,
461
+ moveCount: this.moveCount,
462
+ elapsedMs: this.elapsedMs()
463
+ });
464
+ this.notify();
465
+ }
466
+
467
+ private notify(): void {
468
+ if (this.notifying) return;
469
+ this.notifying = true;
470
+ try {
471
+ const snapshot = this.getState();
472
+ for (const fn of this.listeners) {
473
+ try {
474
+ fn(snapshot);
475
+ } catch (err) {
476
+ // eslint-disable-next-line no-console
477
+ console.error('[game-sudoku] listener threw:', err);
478
+ }
479
+ }
480
+ } finally {
481
+ this.notifying = false;
482
+ }
483
+ }
484
+ }
485
+
486
+ // ---------- 局部小工具(避免把棋盘遍历细节散进主流程) ----------
487
+
488
+ /** puzzle ⊂ solution:puzzle 非 0 处 solution 必须同值。 */
489
+ function gridsCompatible(puzzle: Grid, solution: Grid): boolean {
490
+ for (let r = 0; r < puzzle.length; r++) {
491
+ for (let c = 0; c < (puzzle[r]?.length ?? 0); c++) {
492
+ const p = cellAt(puzzle, r, c);
493
+ if (p !== 0 && cellAt(solution, r, c) !== p) return false;
494
+ }
495
+ }
496
+ return true;
497
+ }
498
+
499
+ function firstEmpty(grid: Grid): Position | null {
500
+ for (let r = 0; r < SIZE; r++) {
501
+ for (let c = 0; c < SIZE; c++) {
502
+ if (cellAt(grid, r, c) === 0) return { row: r, col: c };
503
+ }
504
+ }
505
+ return null;
506
+ }
507
+
508
+ function numberOr(x: unknown, fallback: number): number {
509
+ return typeof x === 'number' && Number.isFinite(x) ? x : fallback;
510
+ }
@@ -0,0 +1,113 @@
1
+ import { DIGITS, cellAt, createEmptyGrid, isSafe, setCell } from './board';
2
+ import { countSolutions } from './solver';
3
+ import type { Difficulty, Grid, RandomSource } from './types';
4
+
5
+ /**
6
+ * 数独生成器:产出「有唯一解」的题目。
7
+ *
8
+ * 流程:
9
+ * 1. `generateFullGrid` —— 随机回溯填满一张合法终盘(作为 solution)。
10
+ * 2. `digHoles` —— 以随机顺序尝试把格子挖空;每挖一格前用 `countSolutions(limit=2)`
11
+ * 校验:挖掉后仍「唯一解」才保留,否则回填。挖到目标线索数或无格可挖为止。
12
+ *
13
+ * 这样产出的 puzzle 保证唯一解,且 puzzle ⊂ solution(挖掉的都是解里可推出来的格)。
14
+ * rng 注入保证单测可复现;默认 `Math.random`。
15
+ */
16
+
17
+ /** 各难度目标线索数(题面给定数字个数)。实际可能因唯一性约束略多。 */
18
+ export const TARGET_CLUES: Record<Difficulty, number> = {
19
+ easy: 45,
20
+ medium: 35,
21
+ hard: 27
22
+ };
23
+
24
+ /** Fisher–Yates 洗牌(返回新数组,用注入 rng)。 */
25
+ function shuffle<T>(input: readonly T[], rng: RandomSource): T[] {
26
+ const arr = input.slice();
27
+ for (let i = arr.length - 1; i > 0; i--) {
28
+ const j = Math.floor(rng() * (i + 1));
29
+ const tmp = arr[i];
30
+ const a = arr[j];
31
+ if (tmp !== undefined && a !== undefined) {
32
+ arr[i] = a;
33
+ arr[j] = tmp;
34
+ }
35
+ }
36
+ return arr;
37
+ }
38
+
39
+ /** 从 pos 起原地回溯填满 grid(每格用随机顺序的合法数字)。 */
40
+ function fillFully(grid: Grid, pos: number, rng: RandomSource): boolean {
41
+ if (pos >= grid.length * grid.length) return true;
42
+ const row = Math.floor(pos / grid.length);
43
+ const col = pos % grid.length;
44
+ if (cellAt(grid, row, col) !== 0) return fillFully(grid, pos + 1, rng);
45
+ for (const d of shuffle(DIGITS, rng)) {
46
+ if (isSafe(grid, row, col, d)) {
47
+ setCell(grid, row, col, d);
48
+ if (fillFully(grid, pos + 1, rng)) return true;
49
+ setCell(grid, row, col, 0);
50
+ }
51
+ }
52
+ return false;
53
+ }
54
+
55
+ /** 生成一张随机合法终盘(9×9 全填、无冲突)。 */
56
+ export function generateFullGrid(rng: RandomSource = Math.random): Grid {
57
+ const grid = createEmptyGrid();
58
+ // 空盘的回溯填满总有解(数独解空间非空),fillFully 必返回 true。
59
+ fillFully(grid, 0, rng);
60
+ return grid;
61
+ }
62
+
63
+ /**
64
+ * 在终盘副本上随机挖洞到目标线索数,保持唯一解。
65
+ * 返回挖洞后的题面(会修改并返回传入的 puzzle 副本,入参 solution 不动)。
66
+ */
67
+ function digHoles(solution: Grid, targetClues: number, rng: RandomSource): Grid {
68
+ const puzzle = solution.map((row) => row.slice());
69
+ const total = puzzle.length * puzzle.length;
70
+ // 全部坐标乱序,作为挖除尝试顺序。
71
+ const order: number[] = [];
72
+ for (let i = 0; i < total; i++) order.push(i);
73
+ const shuffled = shuffle(order, rng);
74
+
75
+ let clues = total;
76
+ for (const idx of shuffled) {
77
+ if (clues <= targetClues) break;
78
+ const r = Math.floor(idx / puzzle.length);
79
+ const c = idx % puzzle.length;
80
+ const backup = cellAt(puzzle, r, c);
81
+ if (backup === 0) continue; // 已挖空
82
+ setCell(puzzle, r, c, 0);
83
+ // 挖掉后是否仍唯一解?不是就回填。
84
+ if (countSolutions(puzzle, 2) === 1) {
85
+ clues--;
86
+ } else {
87
+ setCell(puzzle, r, c, backup);
88
+ }
89
+ }
90
+ return puzzle;
91
+ }
92
+
93
+ export interface GeneratedPuzzle {
94
+ /** 题面(含 0 空格),有唯一解。 */
95
+ puzzle: Grid;
96
+ /** 与题面一致的完整解(题面 ⊂ 解)。 */
97
+ solution: Grid;
98
+ difficulty: Difficulty;
99
+ /** 实际线索数(题面给定个数)。 */
100
+ clues: number;
101
+ }
102
+
103
+ /** 生成一道指定难度、唯一解的数独题。 */
104
+ export function generatePuzzle(
105
+ difficulty: Difficulty = 'easy',
106
+ rng: RandomSource = Math.random
107
+ ): GeneratedPuzzle {
108
+ const solution = generateFullGrid(rng);
109
+ const puzzle = digHoles(solution, TARGET_CLUES[difficulty] ?? TARGET_CLUES.easy, rng);
110
+ let clues = 0;
111
+ for (const row of puzzle) for (const v of row) if (v !== 0) clues++;
112
+ return { puzzle, solution, difficulty, clues };
113
+ }
package/src/index.ts CHANGED
@@ -1,17 +1,73 @@
1
1
  /**
2
- * @my-games/sudoku-core —— `0.0.0` 占位实现。
2
+ * @my-games/sudoku-core
3
3
  *
4
- * 这是一个「空包」骨架:目的仅是把 monorepo 的
5
- * `build → verify:dist → typecheck → test` 门禁与 npm Trusted Publisher 链路跑通,
6
- * 好让首版(可信发布者无法发某包的第一个版本)能先手工种进 registry。
4
+ * 无框架依赖的数独引擎。入口分三类:
5
+ * - `Sudoku` 类:状态机 + 订阅 + 存档 + undo + 计时(注入 clock)。
6
+ * - `solve` / `countSolutions` / `generatePuzzle` / `generateFullGrid`:回溯求解与唯一解题生成。
7
+ * - 纯函数原语 `isSafe` / `candidates` / `findConflicts` / `isSolved` ... 与存储工具。
7
8
  *
8
- * 真实数独引擎 —— 棋盘校验、回溯求解器、唯一解题生成、`Sudoku` 状态机
9
- * (选格/填数/擦除/笔记/提示/撤销/计时)、注入式存档与最佳用时 —— 已在占位包
10
- * 发布前按同一三层结构写好并暂存,待 `0.0.0` 上线后作为后续提交补回。
9
+ * IIFE / UMD 全局名 `SudokuCore` 上挂的就是下面这些导出,与 ESM 侧完全一致。
11
10
  */
12
11
 
13
- /** 包标识占位。 */
14
- export const SUDOKU_PACKAGE = 'sudoku-core' as const;
12
+ export { Sudoku } from './game';
13
+ export type { GameOptions } from './game';
15
14
 
16
- /** 占位版本,与 package.json 的 version 对齐,便于确认发的是 stage 包。 */
17
- export const SUDOKU_STAGE = '0.0.0' as const;
15
+ export { solve, countSolutions } from './solver';
16
+
17
+ export { generatePuzzle, generateFullGrid, TARGET_CLUES } from './generator';
18
+ export type { GeneratedPuzzle } from './generator';
19
+
20
+ export {
21
+ BOX,
22
+ DIGITS,
23
+ SIZE,
24
+ candidates,
25
+ cellAt,
26
+ cloneGrid,
27
+ countFilled,
28
+ createEmptyGrid,
29
+ emptyCells,
30
+ findConflicts,
31
+ gridsEqual,
32
+ hasConflict,
33
+ isFull,
34
+ isSafe,
35
+ isSolved,
36
+ makeMask,
37
+ peers,
38
+ setCell,
39
+ withCell
40
+ } from './board';
41
+
42
+ export {
43
+ DEFAULT_BEST_KEY,
44
+ DEFAULT_SAVE_KEY,
45
+ SAVE_VERSION,
46
+ clearSave,
47
+ emptyBestTimes,
48
+ isSerializedSave,
49
+ loadBest,
50
+ loadSave,
51
+ tryGetGlobalStorage,
52
+ writeBest,
53
+ writeSave
54
+ } from './storage';
55
+
56
+ export type {
57
+ BestTimes,
58
+ Cell,
59
+ Clock,
60
+ ConflictMask,
61
+ Difficulty,
62
+ Digit,
63
+ GameState,
64
+ GameStatus,
65
+ GivenMask,
66
+ Grid,
67
+ NoteGrid,
68
+ Position,
69
+ RandomSource,
70
+ SaveStorage,
71
+ SerializedSave,
72
+ Snapshot
73
+ } from './types';