import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { test } from 'node:test'; import { Board } from '../src/board'; import { pickClueHint } from '../src/clue-hint'; import { Game } from '../src/game'; import { fitBoard } from '../src/layout'; import { loadPack, parsePack } from '../src/levels'; import type { Painter } from '../src/painter'; import { uniqueSolution } from '../src/solver'; import { THEME } from '../src/theme'; import { BoardView } from '../src/view'; function line() { return new Game(Board.fromCells(Array.from({ length: 9 }, (_, i) => ({ id: String(i), x: i, y: 0, w: 1, h: 1, clue: i === 0 || i === 8 ? i + 1 : undefined, })))); } const solution = [0, 1, 2, 3, 4, 5, 6, 7, 8]; test('1~9 补 5,再补 3;只增加线索,不代画路线', () => { const game = line(); const h = pickClueHint(game, solution)!; assert.deepEqual(h, { cell: 4, step: 5 }); assert.deepEqual(game.apply({ type: 'reveal', ...h }), [{ type: 'clueRevealed', ...h }]); assert.deepEqual(pickClueHint(game, solution), { cell: 2, step: 3 }); assert.deepEqual(game.path, []); assert.equal(game.status, 'empty'); assert.equal(game.remaining, 9); }); test('按已解出的前缀推进区间,跳过已经走对的数字', () => { const game = line(); for (const cell of solution.slice(0, 4)) game.apply({ type: game.path.length ? 'append' : 'start', cell }); assert.deepEqual(pickClueHint(game, solution), { cell: 5, step: 6 }); game.apply({ type: 'reveal', cell: 4, step: 5 }); game.apply({ type: 'append', cell: 4 }); assert.deepEqual(pickClueHint(game, solution), { cell: 6, step: 7 }); }); test('新数字独立于错误路线,并立即约束已走错的格;回退后冲突消失', () => { const board = Board.fromCells([ { id: 'a', x: 0, y: 0, w: 1, h: 1, clue: 1 }, { id: 'b', x: 1, y: 0, w: 1, h: 1 }, { id: 'c', x: 1, y: 1, w: 1, h: 1 }, { id: 'd', x: 0, y: 1, w: 1, h: 1, clue: 4 }, ]); const game = new Game(board); for (const cell of [0, 3, 2]) game.apply({ type: game.path.length ? 'append' : 'start', cell }); assert.deepEqual(pickClueHint(game, [0, 1, 2, 3]), { cell: 1, step: 2 }); game.apply({ type: 'append', cell: 1 }); game.apply({ type: 'reveal', cell: 1, step: 2 }); assert.equal(game.isConflict(1), true); assert.equal(game.isCleared, false); game.apply({ type: 'rewind', cell: 0 }); assert.equal(game.hasConflict(), false); assert.equal(game.clueAt(1).clue, 2); }); test('重开保留补出的数字,原始关卡和新对局不变', () => { const game = line(); game.apply({ type: 'reveal', cell: 4, step: 5 }); game.apply({ type: 'start', cell: 0 }); game.apply({ type: 'reset' }); assert.equal(game.clueAt(4).clue, 5); assert.equal(game.board.cells[4].clue, null); assert.equal(new Game(game.board).clueAt(4).clue, null); assert.deepEqual(game.path, []); }); test('补出的 1 成为起手约束,奇偶要求同时保留', () => { const game = new Game(Board.fromCells([ { id: 'a', x: 0, y: 0, w: 1, h: 1, parity: 'odd' }, { id: 'b', x: 1, y: 0, w: 1, h: 1, clue: 2 }, ])); assert.deepEqual(pickClueHint(game, [0, 1]), { cell: 0, step: 1 }); game.apply({ type: 'reveal', cell: 0, step: 1 }); assert.deepEqual(game.clueAt(0), { clue: 1, parity: 'odd' }); assert.deepEqual(game.apply({ type: 'start', cell: 1 }), [{ type: 'rejectStart', cell: 1, clueCell: 0 }]); game.apply({ type: 'start', cell: 0 }); game.apply({ type: 'append', cell: 1 }); assert.equal(game.isCleared, true); assert.equal(pickClueHint(game, [0, 1]), null); }); test('不覆盖原数字、不重复、不接受越界或奇偶不符的补充数字', () => { const game = line(); for (const [cell, step] of [[0, 2], [1, 1], [1, 9], [1, 0], [1, 10], [-1, 2], [9, 2], [1.5, 2], [1, 2.5]]) { assert.deepEqual(game.apply({ type: 'reveal', cell, step }), []); } game.apply({ type: 'reveal', cell: 4, step: 5 }); assert.deepEqual(game.apply({ type: 'reveal', cell: 4, step: 6 }), []); assert.deepEqual(game.apply({ type: 'reveal', cell: 3, step: 5 }), []); assert.equal(pickClueHint(game, []), null); const parity = new Game(Board.fromCells([ { id: 'a', x: 0, y: 0, w: 1, h: 1, parity: 'even' }, { id: 'b', x: 1, y: 0, w: 1, h: 1 }, ])); assert.deepEqual(parity.apply({ type: 'reveal', cell: 0, step: 1 }), []); }); test('补数字的渲染:数字与原奇偶同在,走错时显示要求并变红', () => { const game = new Game(Board.fromCells([ { id: 'a', x: 0, y: 0, w: 1, h: 1 }, { id: 'b', x: 1, y: 0, w: 1, h: 1, parity: 'even' }, ])); const view = new BoardView(game, { reducedMotion: true }); view.layout = fitBoard(game.board, 320, 400); const labels: string[] = []; const fills: string[] = []; const painter: Painter = { text: s => { labels.push(s); }, fillRoundRect: (_r, _radius, color) => { fills.push(color); }, strokeRoundRect: () => {}, fillCircle: () => {}, }; game.apply({ type: 'reveal', cell: 1, step: 2 }).forEach(e => view.onEvent(e)); view.draw(painter); assert.ok(labels.includes('2') && labels.includes('偶')); labels.length = 0; game.apply({ type: 'start', cell: 1 }).forEach(e => view.onEvent(e)); view.draw(painter); assert.ok(labels.includes('1') && labels.includes('2')); assert.ok(fills.includes(THEME.conflict)); }); test('26 关反复补数最终用尽且每个数符合唯一解,重开仍可正常通关', () => { for (const file of ['pack-1-tutorial.json', 'pack-2-advanced.json', 'pack-3-parity.json']) { const levels = loadPack(parsePack(JSON.parse(readFileSync(new URL(`../levels/${file}`, import.meta.url), 'utf8')))); for (const level of levels) { const game = new Game(level.board); const path = uniqueSolution(level.board)!; for (let i = 0; i <= game.n; i++) { const h = pickClueHint(game, path); if (!h) break; assert.ok(i < game.n, `${level.def.id} 提示应有界`); assert.equal(path[h.step - 1], h.cell); assert.equal(game.apply({ type: 'reveal', ...h }).length, 1); } assert.equal(pickClueHint(game, path), null); assert.deepEqual(game.path, []); game.apply({ type: 'start', cell: path[0] }); game.apply({ type: 'reset' }); path.forEach((cell, i) => game.apply({ type: i === 0 ? 'start' : 'append', cell })); assert.equal(game.isCleared, true, level.def.id); } } });