Files
NumberSwipe_New/modes/01-number-stroke/tests/clue-hint.test.ts
T
cabinw 8026e4628d 初始提交:三玩法 Web 原型与设计文档
- 玩法一 数字一笔:规则库、求解器、26 关、竖屏触摸 Web 原型、测试
- 玩法二 连连气球:连消畅玩版核心、Web 原型、Cocos 球槽组件与导出脚本
- 玩法三 六边一笔:规则库、求解器、10 关 + 转墙实验包、Web 原型、测试
- 产品文档、UI 设计规范、各玩法 GDD / HANDOFF
- 文档移除全部外部参考作引用;删除玩法二 v0 旧稿
2026-09-16 14:30:59 +08:00

147 lines
6.3 KiB
TypeScript

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);
}
}
});