初始提交:三玩法 Web 原型与设计文档

- 玩法一 数字一笔:规则库、求解器、26 关、竖屏触摸 Web 原型、测试
- 玩法二 连连气球:连消畅玩版核心、Web 原型、Cocos 球槽组件与导出脚本
- 玩法三 六边一笔:规则库、求解器、10 关 + 转墙实验包、Web 原型、测试
- 产品文档、UI 设计规范、各玩法 GDD / HANDOFF
- 文档移除全部外部参考作引用;删除玩法二 v0 旧稿
This commit is contained in:
cabinw
2026-09-16 14:30:59 +08:00
commit 8026e4628d
169 changed files with 22162 additions and 0 deletions
+140
View File
@@ -0,0 +1,140 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { Board } from '../src/board';
import type { CellDef } from '../src/types';
const c = (id: string, x: number, y: number, w = 1, h = 1, clue?: number): CellDef => ({ id, x, y, w, h, clue });
test('只认四向邻接,角对角不算', () => {
const b = Board.fromCells([c('a', 0, 0), c('b', 1, 1), c('c', 1, 0)]);
assert.equal(b.areAdjacent(0, 1), false);
assert.equal(b.areAdjacent(0, 2), true);
assert.equal(b.areAdjacent(2, 1), true);
});
test('2×2 可以同时贴多个 1×1;只碰角的不算', () => {
// 1-4 的盘:start(0,0) 与 big(1,1,2,2) 只碰角
const b = Board.fromCells([c('start', 0, 0, 1, 1, 1), c('a', 1, 0), c('b', 2, 0), c('big', 1, 1, 2, 2, 3)]);
const big = b.cellById('big').index;
assert.deepEqual([...b.adj[big]].sort(), [1, 2]);
assert.equal(b.areAdjacent(0, big), false);
});
test('横长条与其长边上的两个格都相邻', () => {
const b = Board.fromCells([c('H', 1, 1, 2, 1), c('p', 1, 0), c('q', 2, 0), c('r', 3, 1), c('s', 0, 2)]);
const H = b.cellById('H').index;
assert.equal(b.areAdjacent(H, b.cellById('p').index), true);
assert.equal(b.areAdjacent(H, b.cellById('q').index), true);
assert.equal(b.areAdjacent(H, b.cellById('r').index), true);
assert.equal(b.areAdjacent(H, b.cellById('s').index), false);
});
test('空洞返回 -1,坐标会平移到原点', () => {
const b = Board.fromCells([c('a', 2, 3), c('b', 3, 3), c('d', 3, 4)]);
assert.equal(b.cols, 2);
assert.equal(b.rows, 2);
assert.equal(b.cellAtUnit(0, 0), 0);
assert.equal(b.cellAtUnit(0, 1), -1);
assert.equal(b.cellAtUnit(5, 5), -1);
});
test('共享边线段', () => {
const b = Board.fromCells([c('B', 0, 0, 2, 2), c('x', 2, 1), c('y', 1, 2)]);
assert.deepEqual(b.sharedEdge(0, 1), { x1: 2, y1: 1, x2: 2, y2: 2, dir: 'v' });
assert.deepEqual(b.sharedEdge(0, 2), { x1: 1, y1: 2, x2: 2, y2: 2, dir: 'h' });
assert.equal(b.sharedEdge(1, 2), null);
});
test('3×3 大连块:合法、长边邻接、共享边合并', () => {
const b = Board.fromCells([c('O', 0, 0, 3, 3), c('a', 3, 1), c('d', 1, 3), c('e', 3, 3)]);
const O = b.cellById('O');
assert.equal(O.isRect, true);
assert.equal(b.areAdjacent(O.index, b.cellById('a').index), true);
assert.equal(b.areAdjacent(O.index, b.cellById('d').index), true);
assert.equal(b.areAdjacent(O.index, b.cellById('e').index), false, '角对角不算');
assert.equal(b.cellAtUnit(2, 2), O.index);
assert.deepEqual(b.sharedEdge(O.index, b.cellById('a').index), { x1: 3, y1: 1, x2: 3, y2: 2, dir: 'v' });
assert.deepEqual(b.sharedEdge(O.index, b.cellById('d').index), { x1: 1, y1: 3, x2: 2, y2: 3, dir: 'h' });
});
test('非法盘面直接抛错', () => {
assert.throws(() => Board.fromCells([c('a', 0, 0), c('b', 0, 0)]), /重叠/);
assert.throws(() => Board.fromCells([c('a', 0, 0, 3, 1)]), /尺寸/);
assert.throws(() => Board.fromCells([c('a', 0, 0, 1, 1, 5)]), /线索/);
assert.throws(() => Board.fromCells([c('a', 0, 0, 1, 1, 1), c('b', 1, 0, 1, 1, 1)]), /重复/);
});
// ---------- 异形格(俄罗斯方块拼块) ----------
const s = (id: string, x: number, y: number, w: number, h: number, shape: Array<[number, number]>, clue?: number): CellDef => ({
id,
x,
y,
w,
h,
shape,
clue,
});
test('异形格:L4 展开成单位集合,缺口是空洞', () => {
const b = Board.fromCells([s('L', 1, 1, 2, 3, [[0, 0], [0, 1], [0, 2], [1, 2]]), c('a', 0, 0)]);
const L = b.cellById('L');
assert.equal(L.isRect, false);
assert.deepEqual([...L.units].sort(), [[1, 1], [1, 2], [1, 3], [2, 3]].sort());
assert.equal(b.cellAtUnit(2, 1), -1, 'bbox 里缺口 (2,1) 是空洞');
assert.equal(b.cellAtUnit(2, 3), L.index);
assert.equal(b.cellById('a').isRect, true);
});
test('异形格:旋转与镜像都在目录内', () => {
const variants: Array<Array<[number, number]>> = [
[[0, 0], [0, 1], [0, 2], [1, 2]], // L
[[0, 0], [1, 0], [2, 0], [0, 1]], // J(L 的镜像)
[[0, 0], [1, 0], [1, 1], [2, 1]], // L 旋转 90°
[[1, 0], [2, 0], [0, 1], [1, 1]], // S
[[0, 0], [0, 1], [1, 1], [1, 2]], // Z(S 的镜像)
[[0, 0], [1, 0], [2, 0], [1, 1]], // T
[[0, 0], [0, 1], [1, 0]], // L3
];
for (const shape of variants) {
const w = Math.max(...shape.map(([x]) => x)) + 1;
const h = Math.max(...shape.map(([, y]) => y)) + 1;
assert.doesNotThrow(() => Board.fromCells([s('P', 0, 1, w, h, shape), c('a', 0, 0)]), JSON.stringify(shape));
}
});
test('异形格:非法 shape 抛错', () => {
assert.throws(() => Board.fromCells([s('L', 0, 0, 2, 2, [[0, 0], [1, 1]])]), /不连通/);
assert.throws(() => Board.fromCells([s('L', 0, 0, 2, 2, [[0, 0], [0, 0]])]), /重复/);
assert.throws(() => Board.fromCells([s('L', 0, 0, 2, 2, [[0, 0], [-1, 0]])]), /为负/);
assert.throws(() => Board.fromCells([s('L', 0, 0, 2, 2, [[0, 0], [0, 1], [0, 2]])]), /不符/);
assert.throws(() => Board.fromCells([s('L', 0, 0, 3, 3, [[0, 0], [1, 0], [2, 0], [2, 1], [0, 1]])]), /单位数/);
});
test('异形格邻接:认每一条共享单位边', () => {
// L 占 (1,1)(1,2)(1,3)(2,3),缺口在 (2,1)(2,2)
const b = Board.fromCells([
s('L', 1, 1, 2, 3, [[0, 0], [0, 1], [0, 2], [1, 2]]),
c('a', 2, 1), // 缺口里的格:与 (1,1) 共边 → 相邻
c('d', 2, 2), // 与 (1,2) 共边 → 相邻
c('e', 3, 1), // 只挨到缺口,不与任何 L 单位共边 → 不相邻
]);
const L = b.cellById('L').index;
assert.equal(b.areAdjacent(L, b.cellById('a').index), true);
assert.equal(b.areAdjacent(L, b.cellById('d').index), true);
assert.equal(b.areAdjacent(L, b.cellById('e').index), false);
});
test('异形格共享边:共线单位边合并成一整段;自身返回 null', () => {
const b = Board.fromCells([c('B', 0, 0, 2, 2), c('V', 2, 0, 1, 2)]);
assert.deepEqual(b.sharedEdge(0, 1), { x1: 2, y1: 0, x2: 2, y2: 2, dir: 'v' }, '2×2 与竖条的两条单位边合并');
assert.equal(b.sharedEdge(0, 0), null);
});
test('异形格锚点在形内', () => {
const b = Board.fromCells([s('L', 1, 1, 2, 3, [[0, 0], [0, 1], [0, 2], [1, 2]]), c('a', 0, 0)]);
const [ux, uy] = b.anchorUnit(b.cellById('L').index);
assert.ok(b.cellById('L').units.some(([x, y]) => x === ux && y === uy), `锚点 (${ux},${uy}) 必须在 L 内`);
const [cx, cy] = b.anchorCenter(b.cellById('a').index);
assert.deepEqual([cx, cy], [0.5, 0.5], '矩形格锚点 = bbox 中心');
});
@@ -0,0 +1,146 @@
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);
}
}
});
+99
View File
@@ -0,0 +1,99 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { Board } from '../src/board';
import { Game } from '../src/game';
import type { CellDef } from '../src/types';
const c = (id: string, x: number, y: number, w = 1, h = 1, clue?: number): CellDef => ({ id, x, y, w, h, clue });
// 1-3:start(1) a b(3) / d c
const level13 = () =>
Board.fromCells([c('start', 0, 0, 1, 1, 1), c('a', 1, 0), c('b', 2, 0, 1, 1, 3), c('d', 1, 1), c('c', 2, 1)]);
test('有线索 1 时只能从它起手', () => {
const g = new Game(level13());
const ev = g.apply({ type: 'start', cell: 1 });
assert.deepEqual(ev, [{ type: 'rejectStart', cell: 1, clueCell: 0 }]);
assert.equal(g.status, 'empty');
assert.equal(g.apply({ type: 'start', cell: 0 })[0].type, 'step');
assert.equal(g.status, 'drawing');
});
test('无线索 1 时任意格可起手', () => {
const g = new Game(Board.fromCells([c('a', 0, 0), c('b', 1, 0, 1, 1, 2)]));
assert.equal(g.apply({ type: 'start', cell: 0 })[0].type, 'step');
});
test('追加只能到相邻未走格;冲突不打断;撤销恢复', () => {
const b = level13();
const g = new Game(b);
const id = (s: string) => b.cellById(s).index;
g.apply({ type: 'start', cell: id('start') });
assert.deepEqual(g.apply({ type: 'append', cell: id('c') }), []); // 不相邻
g.apply({ type: 'append', cell: id('a') });
const ev = g.apply({ type: 'append', cell: id('d') }); // 第 3 步没落到预定的 b,立即冲突
assert.equal(ev[0].type, 'step');
assert.ok(ev.some(e => e.type === 'step' && e.conflict && !e.clueHit));
assert.equal(g.isConflict(id('d')), true);
assert.equal(g.stepOf(id('b')), 0, '无需先走到线索格,就能判断该步位置错误');
g.apply({ type: 'append', cell: id('c') });
const conflict = g.apply({ type: 'append', cell: id('b') });
assert.equal(conflict[0].type, 'step');
assert.equal((conflict[0] as { conflict: boolean }).conflict, true);
assert.equal(g.isConflict(id('b')), true);
assert.equal(g.path.length, 5);
assert.equal(g.status, 'drawing', '填满但有冲突不算过关');
// 走到头了但没过关 → deadEnd 事件
assert.ok(conflict.some((e) => e.type === 'deadEnd'));
const un = g.apply({ type: 'undo' });
assert.deepEqual(un, [{ type: 'unstep', cell: id('b'), step: 5 }]);
assert.equal(g.isConflict(id('b')), false);
assert.equal(g.stepOf(id('b')), 0);
});
test('全走完且线索全对 → cleared,之后锁住', () => {
const b = level13();
const g = new Game(b);
const id = (s: string) => b.cellById(s).index;
for (const s of ['start', 'a', 'b', 'c']) g.apply(g.path.length ? { type: 'append', cell: id(s) } : { type: 'start', cell: id(s) });
const ev = g.apply({ type: 'append', cell: id('d') });
assert.ok(ev.some((e) => e.type === 'cleared'));
assert.equal(g.status, 'cleared');
assert.deepEqual(g.apply({ type: 'undo' }), []);
assert.equal(g.path.length, 5);
});
test('release / resume / reset 状态流转', () => {
const g = new Game(level13());
g.apply({ type: 'start', cell: 0 });
g.apply({ type: 'release' });
assert.equal(g.status, 'idle');
g.apply({ type: 'resume' });
assert.equal(g.status, 'drawing');
assert.deepEqual(g.apply({ type: 'reset' }), [{ type: 'reset' }]);
assert.equal(g.status, 'empty');
assert.equal(g.remaining, 5);
assert.deepEqual(g.apply({ type: 'reset' }), [], '空盘重开不产生事件');
});
test('撤销到空盘回到 empty', () => {
const g = new Game(level13());
g.apply({ type: 'start', cell: 0 });
g.apply({ type: 'undo' });
assert.equal(g.status, 'empty');
assert.equal(g.last, -1);
});
test('直接回退移除后缀冲突,保留前缀;非法目标、末端和过关后均忽略', () => {
const g = new Game(level13());
for (const cell of [0, 1, 3, 4, 2]) g.apply({ type: g.path.length ? 'append' : 'start', cell });
assert.equal(g.hasConflict(), true);
g.apply({ type: 'rewind', cell: 1 });
assert.deepEqual(g.path, [0, 1]);
assert.equal(g.hasConflict(), false);
for (const cell of [-1, 4, 1]) assert.deepEqual(g.apply({ type: 'rewind', cell }), []);
for (const cell of [2, 4, 3]) g.apply({ type: 'append', cell });
assert.equal(g.isCleared, true);
assert.deepEqual(g.apply({ type: 'rewind', cell: 0 }), []);
assert.equal(g.path.length, 5);
});
+140
View File
@@ -0,0 +1,140 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { Board } from '../src/board';
import { Game } from '../src/game';
import { PointerInput } from '../src/input';
import type { CellDef } from '../src/types';
const c = (id: string, x: number, y: number, w = 1, h = 1, clue?: number): CellDef => ({ id, x, y, w, h, clue });
// 一横四格:a(1) b c d
function setup() {
const b = Board.fromCells([c('a', 0, 0, 1, 1, 1), c('b', 1, 0), c('c', 2, 0), c('d', 3, 0)]);
const g = new Game(b);
return { b, g, input: new PointerInput(g) };
}
test('按下起点后滑动追加;滑回上一格撤销;松手保留路径', () => {
const { g, input } = setup();
input.down(0);
assert.equal(g.path.length, 1);
input.move(1);
input.move(2);
assert.deepEqual([...g.path], [0, 1, 2]);
input.move(1); // 滑回上一格 = 撤销
assert.deepEqual([...g.path], [0, 1]);
input.up();
assert.equal(g.status, 'idle');
assert.deepEqual([...g.path], [0, 1]);
});
test('松手后从末端按下可继续;按不相邻的空格不生效', () => {
const { g, input } = setup();
input.down(0);
input.move(1);
input.up();
input.down(3); // 不相邻的空格
assert.equal(input.isDrawing, false);
input.move(2);
assert.equal(g.path.length, 2);
input.up();
input.down(1); // 末端
assert.equal(input.isDrawing, true);
input.move(2);
assert.deepEqual([...g.path], [0, 1, 2]);
});
test('点末端的前一格 = 撤销一步,且可以接着滑', () => {
const { g, input } = setup();
input.down(0);
input.move(1);
input.move(2);
input.up();
input.down(1);
assert.deepEqual([...g.path], [0, 1]);
assert.equal(input.isDrawing, true);
input.move(0);
assert.deepEqual([...g.path], [0]);
});
test('点末端旁边的空格直接追加(点着走)', () => {
const { g, input } = setup();
input.down(0);
input.up();
input.down(1);
assert.deepEqual([...g.path], [0, 1]);
});
test('有线索 1 时点别的格起手无效并产生 rejectStart', () => {
const { g, input } = setup();
const ev = input.down(2);
assert.equal(ev[0].type, 'rejectStart');
assert.equal(g.path.length, 0);
assert.equal(input.isDrawing, false);
assert.deepEqual(input.move(1), []);
});
test('滑到不相邻格忽略;空白 -1 不影响;同一格不重复触发', () => {
const { g, input } = setup();
input.down(0);
input.move(-1);
input.move(2); // 不相邻
assert.deepEqual([...g.path], [0]);
input.move(1);
input.move(1);
assert.deepEqual([...g.path], [0, 1]);
});
test('过关后停止响应', () => {
const { g, input } = setup();
input.down(0);
input.move(1);
input.move(2);
const ev = input.move(3);
assert.ok(ev.some((e) => e.type === 'cleared'));
assert.equal(input.isDrawing, false);
assert.deepEqual(input.down(3), []);
assert.deepEqual(input.move(2), []);
assert.equal(g.path.length, 4);
});
test('点更早的历史格一次撤掉多步,不松手即可沿新分支继续', () => {
const b = Board.fromCells([
c('a', 0, 0, 1, 1, 1), c('b', 1, 0), c('c', 2, 0),
c('d', 2, 1), c('e', 1, 1), c('f', 0, 1),
]);
const g = new Game(b);
const input = new PointerInput(g);
input.down(0);
[1, 2, 3, 4].forEach(cell => input.move(cell));
input.up();
const events = input.down(1);
assert.deepEqual(g.path, [0, 1]);
assert.deepEqual(events, [
{ type: 'unstep', cell: 4, step: 5 },
{ type: 'unstep', cell: 3, step: 4 },
{ type: 'unstep', cell: 2, step: 3 },
{ type: 'rewound', cell: 1, removed: 3 },
]);
assert.equal(input.isDrawing, true);
assert.equal(g.status, 'drawing');
input.move(4); // 从第 2 格直接转入之前的第 5 格
input.move(5);
input.up();
assert.deepEqual(g.path, [0, 1, 4, 5]);
assert.equal(g.stepOf(4), 3);
assert.equal(g.stepOf(2), 0);
assert.equal(g.status, 'idle');
});
test('不松手滑到不相邻历史格可直接回退;同格抖动不会重复撤销', () => {
const { g, input } = setup();
input.down(0);
input.move(1);
input.move(2);
input.move(0); // 与末端不相邻,仍能一次回到起点
assert.deepEqual(g.path, [0]);
assert.deepEqual(input.move(0), []);
input.move(1);
assert.deepEqual(g.path, [0, 1]);
});
@@ -0,0 +1,76 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { Board } from '../src/board';
import { bridgeRect, cellRect, cellShapeRects, fitBoard, hitTest } from '../src/layout';
import type { CellDef } from '../src/types';
const c = (id: string, x: number, y: number, w = 1, h = 1, clue?: number): CellDef => ({ id, x, y, w, h, clue });
test('按短边缩放并居中,受 maxUnit 限制', () => {
const b = Board.fromCells([c('a', 0, 0), c('b', 1, 0), c('c', 2, 0)]);
const L = fitBoard(b, 390, 600, { padding: 10, maxUnit: 96 });
assert.equal(L.unit, 96);
assert.equal(L.width, 288);
assert.equal(L.originX, (390 - 288) / 2);
const tall = Board.fromCells([c('a', 0, 0), c('b', 0, 1), c('c', 0, 2), c('d', 0, 3), c('e', 0, 4), c('f', 0, 5)]);
const L2 = fitBoard(tall, 390, 300, { padding: 0, maxUnit: 200 });
assert.equal(L2.unit, 50);
});
test('命中测试:格中心命中、缝与死区不命中、空洞不命中', () => {
const b = Board.fromCells([c('a', 0, 0), c('B', 1, 0, 2, 2), c('h', 0, 2)]);
const L = fitBoard(b, 300, 300, { padding: 0, maxUnit: 100 });
const ra = cellRect(b, 0, L);
assert.equal(hitTest(b, L, ra.x + ra.w / 2, ra.y + ra.h / 2), 0);
assert.equal(hitTest(b, L, L.originX + 2, L.originY + 2), -1, '角落死区');
assert.equal(hitTest(b, L, L.originX + 150, L.originY + 150), 1, '2×2 中心');
assert.equal(hitTest(b, L, L.originX + 50, L.originY + 150), -1, '空洞 (0,1)');
assert.equal(hitTest(b, L, -5, -5), -1);
});
test('桥矩形横跨缝并落在共享边中点', () => {
const b = Board.fromCells([c('a', 0, 0), c('B', 1, 0, 2, 2)]);
const L = fitBoard(b, 300, 200, { padding: 0, maxUnit: 100 });
const br = bridgeRect(b, 0, 1, L)!;
assert.ok(br);
assert.ok(Math.abs(br.x + br.w / 2 - (L.originX + 100)) < 1e-6, '中心在 x=1 的边上');
assert.ok(Math.abs(br.y + br.h / 2 - (L.originY + 50)) < 1e-6, '共享边 y∈[0,1] 的中点');
assert.equal(bridgeRect(b, 1, 1, L), null);
});
test('异形格绘制矩形:单位 + 接缝', () => {
// L4 占 (1,1)(1,2)(1,3)(2,3):4 个单位矩形 + 3 条内部缝(两竖一横)
// 补一个 (0,0) 的格钉住原点,免得 L 被平移
const b = Board.fromCells([
{ id: 'L', x: 1, y: 1, w: 2, h: 3, shape: [[0, 0], [0, 1], [0, 2], [1, 2]] },
c('z', 0, 0),
]);
const L = fitBoard(b, 300, 300, { padding: 0, maxUnit: 100 });
const { units, seams } = cellShapeRects(b, 0, L);
assert.equal(units.length, 4);
assert.equal(seams.length, 3);
const half = L.gap / 2;
// 横缝:(1,3)→(2,3),跨过 x=2 的竖缝,向两侧单位内各延长一个圆角半径(盖住圆角缺口)
assert.deepEqual(
seams.find((r) => r.w === L.gap + 2 * L.radius),
{
x: L.originX + 2 * L.unit - half - L.radius,
y: L.originY + 3 * L.unit + half,
w: L.gap + 2 * L.radius,
h: L.unit - L.gap,
},
);
// 竖缝:(1,1)→(1,2),跨过 y=2 的横缝,同样上下延长
assert.deepEqual(
seams.find((r) => r.h === L.gap + 2 * L.radius && r.y === L.originY + 2 * L.unit - half - L.radius),
{
x: L.originX + L.unit + half,
y: L.originY + 2 * L.unit - half - L.radius,
w: L.unit - L.gap,
h: L.gap + 2 * L.radius,
},
);
// 单位矩形与 cellRect 对单个 1×1 的算法一致
const u0 = units[0];
assert.deepEqual(u0, { x: L.originX + L.unit + half, y: L.originY + L.unit + half, w: L.unit - L.gap, h: L.unit - L.gap });
});
+160
View File
@@ -0,0 +1,160 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { test } from 'node:test';
import { Board } from '../src/board';
import { Game } from '../src/game';
import { PointerInput } from '../src/input';
import { fitBoard } from '../src/layout';
import { loadPack, parsePack } from '../src/levels';
import type { Painter } from '../src/painter';
import { hint, solve } from '../src/solver';
import { THEME } from '../src/theme';
import type { CellDef } from '../src/types';
import { BoardView } from '../src/view';
const pack = parsePack(JSON.parse(readFileSync(new URL('../levels/pack-3-parity.json', import.meta.url), 'utf8')));
const levels = loadPack(pack);
const c = (id: string, x: number, parity?: 'odd' | 'even'): CellDef => ({ id, x, y: 0, w: 1, h: 1, parity });
test('奇偶字段校验:仅接受 odd/even,不能同时填数字线索', () => {
for (const invalid of ['奇', 'ODD', 1, null]) {
assert.throws(() => Board.fromCells([{ ...c('a', 0), parity: invalid } as CellDef]), /parity 必须/);
}
assert.throws(() => Board.fromCells([{ ...c('a', 0, 'odd'), clue: 1 }]), /不能同时填写/);
assert.equal(Board.fromCells([c('a', 0)]).cells[0].parity, null);
});
test('无线索 1:偶格也允许试起手,但第 1 步必定冲突;撤销后清除', () => {
const b = Board.fromCells([c('a', 0, 'even'), c('b', 1, 'odd')]);
const game = new Game(b);
const events = game.apply({ type: 'start', cell: 0 });
assert.ok(events.some(e => e.type === 'step' && e.conflict && !e.clueHit));
assert.equal(game.isConflict(0), true);
game.apply({ type: 'undo' });
assert.equal(game.hasConflict(), false);
assert.equal(game.status, 'empty');
assert.deepEqual(solve(b), [[1, 0]], '求解器只给满足奇偶的起点');
assert.deepEqual(solve(b, { starts: [0] }), []);
assert.equal(solve(b, { ignoreClues: true }).length, 2);
});
test('奇偶步号独立校验:与完整路径逐条过滤结果一致', () => {
// 三角邻接来自大块,防止测试仅覆盖奇偶天然交替的小方格。
const geometry = pack.levels[0].cells.map(({ clue, parity, ...cell }) => cell);
const free = solve(Board.fromCells(geometry), { ignoreClues: true });
// 穷举每格 空/奇/偶 的 3^4 组条件,加一个固定数字交叉验证。
for (let mask = 0; mask < 3 ** geometry.length; mask++) {
let value = mask;
const defs = geometry.map(cell => {
const digit = value % 3;
value = Math.floor(value / 3);
return { ...cell, ...(digit ? { parity: digit === 1 ? 'odd' as const : 'even' as const } : {}) };
});
for (const fixed of [false, true]) {
const cells: CellDef[] = defs.map((cell, i) => fixed && i === 0 ? { ...geometry[i], clue: 1 } : cell);
const expected = free.filter(path => cells.every((cell, i) => {
const step = path.indexOf(i) + 1;
return (cell.clue === undefined || step === cell.clue) &&
(cell.parity === undefined || step % 2 === (cell.parity === 'odd' ? 1 : 0));
}));
const keys = (paths: number[][]) => paths.map(p => p.join(',')).sort();
assert.deepEqual(keys(solve(Board.fromCells(cells))), keys(expected), `${mask}/${fixed}`);
}
}
});
test('体验关:每条奇偶线索都必要,唯一解能经真实规则过关', () => {
for (const lv of levels) {
const [path] = solve(lv.board, { limit: 2 });
const game = new Game(lv.board);
const parityCells = lv.def.cells.filter(cell => cell.parity);
assert.ok(parityCells.length > 0);
for (const cell of parityCells) {
const defs = lv.def.cells.map(({ parity, ...rest }) => rest.id === cell.id ? rest : { ...rest, parity });
assert.equal(solve(Board.fromCells(defs), { limit: 2 }).length, 2, `${lv.def.id}/${cell.id} 必须参与唯一性`);
}
path.forEach((cell, i) => {
const events = game.apply({ type: i === 0 ? 'start' : 'append', cell });
if (lv.board.cells[cell].parity !== null) {
assert.ok(events.some(e => e.type === 'step' && e.clueHit && !e.conflict));
}
});
assert.equal(game.isCleared, true, lv.def.id);
}
});
test('走错偶格不会过关;滑回撤销、提示退回分叉后能重走通关', () => {
const b = levels[0].board;
const game = new Game(b);
const input = new PointerInput(game);
const idx = (id: string) => b.cellById(id).index;
input.down(idx('start'));
input.move(idx('a'));
const events = input.move(idx('b')); // 第 3 步踩偶
assert.ok(events.some(e => e.type === 'step' && e.conflict));
assert.equal(game.isConflict(idx('b')), true);
const solution = solve(b, { limit: 1 })[0];
assert.deepEqual(hint(solution, game.path), { kind: 'backtrack', cell: idx('a'), undoCount: 1 });
input.move(idx('big')); // 填满仍有奇偶冲突
assert.equal(game.remaining, 0);
assert.equal(game.isCleared, false);
input.move(idx('b'));
input.move(idx('a'));
assert.equal(game.hasConflict(), false);
assert.deepEqual(hint(solution, game.path), { kind: 'next', cell: idx('big') });
input.move(idx('big'));
input.move(idx('b'));
assert.equal(game.isCleared, true);
});
function drawing(game: Game) {
const view = new BoardView(game);
view.layout = fitBoard(game.board, 350, 400);
const labels: { text: string; x: number; y: number; size: number }[] = [];
const fills: string[] = [];
const painter: Painter = {
fillRoundRect: (_r, _radius, color) => { fills.push(color); },
strokeRoundRect: () => {},
fillCircle: () => {},
text: (text, x, y, size) => { labels.push({ text, x, y, size }); },
};
view.update(0.05);
view.draw(painter);
assert.ok(labels.every(l => [l.x, l.y, l.size].every(Number.isFinite)));
return { labels: labels.map(l => l.text), fills };
}
test('渲染:奇偶未走显示文字、走对保留标记、撤销恢复;冲突标明要求', () => {
const b = levels[0].board;
const g = new Game(b);
assert.ok(drawing(g).labels.includes('偶'));
for (const id of ['start', 'a', 'b']) g.apply({ type: g.path.length ? 'append' : 'start', cell: b.cellById(id).index });
const conflict = drawing(g);
assert.ok(conflict.labels.includes('3') && conflict.labels.includes('偶'));
assert.ok(conflict.fills.includes(THEME.conflict));
assert.ok(!conflict.labels.includes('null'));
g.apply({ type: 'undo' });
assert.ok(drawing(g).labels.includes('偶'));
g.apply({ type: 'append', cell: b.cellById('big').index });
g.apply({ type: 'append', cell: b.cellById('b').index });
assert.ok(drawing(g).labels.includes('偶'));
assert.ok(!drawing(g).fills.includes(THEME.conflict));
});
test('3×3 大连块与异形:奇偶冲突角标在形内,绘制不崩溃', () => {
for (const cell of [
{ id: 'big', x: 0, y: 0, w: 3, h: 3, parity: 'even' as const },
{ id: 'poly', x: 0, y: 0, w: 2, h: 2, shape: [[0, 0], [0, 1], [1, 1]] as [number, number][], parity: 'even' as const },
]) {
const game = new Game(Board.fromCells([cell]));
game.apply({ type: 'start', cell: 0 });
assert.equal(game.isConflict(0), true);
assert.equal(game.stepOf(0), 1, '计步与占用面积无关');
const result = drawing(game);
assert.ok(result.labels.includes('1') && result.labels.includes('偶'));
assert.ok(result.fills.includes(THEME.conflict));
game.apply({ type: 'reset' });
assert.equal(game.hasConflict(), false);
assert.ok(drawing(game).labels.includes('偶'));
}
});
@@ -0,0 +1,51 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { MemoryStore, Progress, STORAGE_KEY } from '../src/progress';
const ids = ['1-1', '1-2', '1-3'];
test('线性解锁与首个未通关', () => {
const p = new Progress(new MemoryStore());
assert.equal(p.isUnlocked(ids, 0), true);
assert.equal(p.isUnlocked(ids, 1), false);
assert.equal(p.firstUncleared(ids), '1-1');
p.markCleared('1-1');
assert.equal(p.isUnlocked(ids, 1), true);
assert.equal(p.isUnlocked(ids, 2), false);
assert.equal(p.firstUncleared(ids), '1-2');
p.markCleared('1-2');
p.markCleared('1-3');
assert.equal(p.firstUncleared(ids), '1-3');
});
test('存档落在本玩法的桶里并可恢复', () => {
const store = new MemoryStore();
const p = new Progress(store);
p.markCleared('1-1');
p.setCurrent('1-2');
p.setPrefs({ sound: false });
assert.ok(store.get(STORAGE_KEY));
const q = new Progress(store);
assert.equal(q.isCleared('1-1'), true);
assert.equal(q.currentId, '1-2');
assert.equal(q.prefs.sound, false);
assert.equal(q.prefs.haptics, true);
q.resetAll();
assert.equal(new Progress(store).clearedCount(), 0);
});
test('跨包链式解锁:2-1 要先过 1-10', () => {
const chained = ['1-1', '1-2', '2-1', '2-2'];
const p = new Progress(new MemoryStore());
assert.equal(p.isUnlocked(chained, 2), false);
p.markCleared('1-1');
p.markCleared('1-2');
assert.equal(p.isUnlocked(chained, 2), true);
assert.equal(p.isUnlocked(chained, 3), false);
});
test('损坏的存档不炸', () => {
const store = new MemoryStore();
store.set(STORAGE_KEY, '{not json');
assert.doesNotThrow(() => new Progress(store));
});
@@ -0,0 +1,159 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { Board } from '../src/board';
import { Game } from '../src/game';
import { cellRect, fitBoard, type Rect } from '../src/layout';
import type { Painter } from '../src/painter';
import { THEME } from '../src/theme';
import type { CellDef } from '../src/types';
import { BoardView } from '../src/view';
// a(1) b c(3)
// d e f
// 正解可为 a b c f e d;错误分支 a d e 在第 3 步使用了 c 预定的数字。
function setup(withNumber = true, parity?: 'odd' | 'even') {
const cells: CellDef[] = ['a', 'b', 'c', 'd', 'e', 'f'].map((id, i) => ({
id, x: i % 3, y: Math.floor(i / 3), w: 1, h: 1,
clue: i === 0 ? 1 : withNumber && i === 2 ? 3 : undefined,
parity: i === 4 ? parity : undefined,
}));
return new Game(Board.fromCells(cells));
}
function startWrongBranch(game: Game) {
game.apply({ type: 'start', cell: 0 });
game.apply({ type: 'append', cell: 3 });
return game.apply({ type: 'append', cell: 4 });
}
test('固定数字出现于普通格时立即标错;未走的固定数字格保持未走状态', () => {
const game = setup();
const events = startWrongBranch(game);
assert.ok(events.some(e => e.type === 'step' && e.conflict && !e.clueHit));
assert.equal(game.isConflict(4), true);
assert.equal(game.hasConflict(), true);
assert.equal(game.isConflict(3), false, '第 2 步没有固定位置,不按隐藏答案标错');
assert.equal(game.stepOf(2), 0);
assert.equal(game.isConflict(2), false);
assert.equal(game.isCleared, false);
});
test('数字位置错误优先于奇偶命中,不播放踩对线索反馈', () => {
const game = setup(true, 'odd');
const events = startWrongBranch(game);
assert.equal(game.clueAt(4).parity, 'odd');
assert.equal(game.stepOf(4), 3);
assert.ok(events.some(e => e.type === 'step' && e.conflict && !e.clueHit));
assert.equal(game.isConflict(4), true);
});
test('撤销或多步回退后清除占号冲突,可改走正确数字格通关', () => {
for (const action of ['undo', 'rewind'] as const) {
const game = setup();
startWrongBranch(game);
game.apply(action === 'undo' ? { type: 'undo' } : { type: 'rewind', cell: 0 });
assert.equal(game.hasConflict(), false);
game.apply({ type: 'rewind', cell: 0 });
for (const cell of [1, 2, 5, 4, 3]) {
const events = game.apply({ type: 'append', cell });
assert.ok(events.every(e => e.type !== 'step' || !e.conflict));
}
assert.equal(game.isCleared, true);
}
});
test('补数字会立即检查已有路线,并在重开后继续约束该步的位置', () => {
const game = setup(false);
startWrongBranch(game);
assert.equal(game.hasConflict(), false);
game.apply({ type: 'reveal', cell: 2, step: 3 });
assert.equal(game.stepOf(2), 0);
assert.equal(game.isConflict(4), true, '补出的 3 尚未被踩到,别处的第 3 步已应判错');
game.apply({ type: 'reset' });
assert.equal(game.hasConflict(), false);
assert.ok(startWrongBranch(game).some(e => e.type === 'step' && e.conflict));
game.apply({ type: 'reset' });
for (const cell of [0, 1, 2, 5, 4, 3]) game.apply({ type: game.path.length ? 'append' : 'start', cell });
assert.equal(game.isCleared, true);
assert.equal(new Game(game.board).clueAt(2).clue, null);
});
test('非固定数字的奇偶要求不占用所有同类步号', () => {
const game = setup(false, 'odd');
game.apply({ type: 'start', cell: 0 });
game.apply({ type: 'append', cell: 1 });
game.apply({ type: 'append', cell: 2 });
assert.equal(game.hasConflict(), false, '另一个普通格上出现奇数 3 是允许的');
});
test('冲突只标红格,进出连线保持紫色,不显示 ×;本格线索不符仍显示原要求', () => {
for (const parity of [undefined, 'odd', 'even'] as const) {
const game = setup(true, parity);
const view = new BoardView(game, { reducedMotion: true });
view.layout = fitBoard(game.board, 350, 400);
for (const cell of [0, 3, 4, 5]) game.apply({ type: game.path.length ? 'append' : 'start', cell }).forEach(e => view.onEvent(e));
const texts: string[] = [];
const fills: string[] = [];
const bridges: string[] = [];
let badges = 0;
const painter: Painter = {
fillRoundRect: (_r, radius, color) => { fills.push(color); if (radius === 0) bridges.push(color); },
strokeRoundRect: () => {}, fillCircle: () => { badges++; }, text: text => { texts.push(text); },
};
view.draw(painter);
assert.ok(!texts.includes('×'));
if (parity === 'even') assert.ok(texts.includes('偶'));
assert.equal(badges, parity === 'even' ? 1 : 0);
assert.ok(!texts.includes(''), '新增冲突不能留下空角标');
assert.ok(fills.includes(THEME.conflict));
assert.deepEqual(bridges, [THEME.visited, THEME.visited, THEME.visited], '错误格进出两侧连线均保持紫色');
}
});
function frame(view: BoardView): Rect[] {
const cells: Rect[] = [];
view.draw({
fillRoundRect: rect => { cells.push(rect); },
strokeRoundRect: () => {}, fillCircle: () => {}, text: () => {},
});
return cells.slice(0, view.game.n); // 底板首先按格序绘制
}
function settle(view: BoardView): void {
for (let i = 0; i < 120; i++) view.update(1 / 60);
}
test('落错时只晃动出错格,动画结束复位;撤销和重开立即停止晃动', () => {
for (const cancel of ['undo', 'reset', 'finish'] as const) {
const game = setup();
const view = new BoardView(game);
view.layout = fitBoard(game.board, 350, 400);
for (const cell of [0, 3]) game.apply({ type: game.path.length ? 'append' : 'start', cell }).forEach(e => view.onEvent(e));
settle(view);
const before = frame(view);
game.apply({ type: 'append', cell: 4 }).forEach(e => view.onEvent(e));
view.update(1 / 60);
const shaken = frame(view);
assert.ok(Math.abs(shaken[4].x - before[4].x) > 0.1, '错误格应横向晃动');
assert.equal(shaken[4].w, before[4].w, '错误反馈只晃动,不叠加落子缩放');
for (const cell of [0, 1, 2, 3, 5]) assert.deepEqual(shaken[cell], before[cell], `其他格 ${cell} 不晃动`);
if (cancel === 'finish') settle(view);
else game.apply({ type: cancel }).forEach(e => view.onEvent(e));
assert.deepEqual(frame(view)[4], before[4], `${cancel} 后立即恢复原位置`);
}
});
test('补数字发现旧路径的错误也会晃动;减弱动态时保留红色但不晃动', () => {
for (const reducedMotion of [false, true]) {
const game = setup(false);
const view = new BoardView(game, { reducedMotion });
view.layout = fitBoard(game.board, 350, 400);
for (const cell of [0, 3, 4]) game.apply({ type: game.path.length ? 'append' : 'start', cell }).forEach(e => view.onEvent(e));
settle(view);
game.apply({ type: 'reveal', cell: 2, step: 3 }).forEach(e => view.onEvent(e));
view.update(1 / 60);
const original = cellRect(game.board, 4, view.layout);
assert.equal(game.isConflict(4), true);
if (reducedMotion) assert.deepEqual(frame(view)[4], original);
else assert.ok(Math.abs(frame(view)[4].x - original.x) > 0.1);
}
});
@@ -0,0 +1,76 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { test } from 'node:test';
import { Board } from '../src/board';
import { loadPack, parsePack } from '../src/levels';
import { hint, solve, uniqueSolution } from '../src/solver';
import type { CellDef } from '../src/types';
const c = (id: string, x: number, y: number, w = 1, h = 1, clue?: number): CellDef => ({ id, x, y, w, h, clue });
test('关卡包每一关恰好一个解', () => {
for (const file of ['pack-1-tutorial.json', 'pack-2-advanced.json', 'pack-3-parity.json']) {
const pack = parsePack(JSON.parse(readFileSync(new URL(`../levels/${file}`, import.meta.url), 'utf8')));
const levels = loadPack(pack);
assert.equal(levels.length, file === 'pack-3-parity.json' ? 6 : 10, `${file} 关数不符`);
for (const lv of levels) {
const paths = solve(lv.board, { limit: 3 });
assert.equal(paths.length, 1, `${lv.def.id} 应唯一解,实际 ${paths.length}`);
assert.equal(paths[0].length, lv.board.n);
}
}
});
test('GDD §7.3 样例盘:15 格、两块 2×2、线索落在大格上,唯一解且 B 是第 6 步', () => {
// A A a b c / A A . d e / f g h B B / . i j B B / 1 l m . .
const b = Board.fromCells([
c('A', 0, 0, 2, 2),
c('a', 2, 0),
c('b', 3, 0),
c('c', 4, 0),
c('d', 3, 1),
c('e', 4, 1),
c('f', 0, 2),
c('g', 1, 2),
c('h', 2, 2),
c('B', 3, 2, 2, 2, 6),
c('i', 1, 3),
c('j', 2, 3),
c('one', 0, 4, 1, 1, 1),
c('l', 1, 4),
c('m', 2, 4),
]);
assert.equal(b.n, 15);
const paths = solve(b);
assert.equal(paths.length, 1, '样例盘恰好唯一解');
const p = paths[0];
assert.deepEqual(
p.map((i) => b.cells[i].id),
['one', 'l', 'm', 'j', 'h', 'B', 'd', 'e', 'c', 'b', 'a', 'A', 'f', 'g', 'i'],
);
// 从 j 往左绕 i 会让 B 最早第 8 步才到(红 8),所以第 5 步必须是 h、第 6 步进 B
assert.equal(b.cells[p[4]].id, 'h');
assert.equal(b.cells[p[5]].id, 'B');
});
test('无线索 1 的盘:任意起点都会被枚举', () => {
const b = Board.fromCells([c('a', 0, 0), c('b', 1, 0), c('d', 1, 1, 1, 1, 3)]);
const paths = solve(b);
assert.equal(paths.length, 1);
assert.equal(b.cells[paths[0][0]].id, 'a');
});
test('多解与无解', () => {
assert.equal(solve(Board.fromCells([c('a', 0, 0), c('b', 1, 0), c('c', 2, 0)])).length, 2);
assert.equal(uniqueSolution(Board.fromCells([c('a', 0, 0), c('b', 1, 0), c('c', 2, 0)])), null);
assert.equal(solve(Board.fromCells([c('a', 0, 0, 1, 1, 1), c('b', 1, 0), c('c', 2, 0, 1, 1, 2)])).length, 0);
});
test('提示:起点 / 下一步 / 退回分叉 / 起手错', () => {
const sol = [0, 1, 2, 3, 4];
assert.deepEqual(hint(sol, []), { kind: 'start', cell: 0 });
assert.deepEqual(hint(sol, [0, 1]), { kind: 'next', cell: 2 });
assert.deepEqual(hint(sol, [0, 1, 7, 8]), { kind: 'backtrack', cell: 1, undoCount: 2 });
assert.deepEqual(hint(sol, [3, 2]), { kind: 'restart', cell: 0 });
assert.deepEqual(hint(sol, sol), { kind: 'none' });
});
@@ -0,0 +1,65 @@
// 设计令牌单一来源守门:web/style.css 的 :root 与 src/theme.ts、web/canvas-painter.ts 必须一致。
// 改任一处而不同步另一处,这里会红。规格文档见仓库 docs/design.md。
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { test } from 'node:test';
import { fileURLToPath } from 'node:url';
import { MOTION, THEME } from '../src/theme';
import { BOARD_FONT_FAMILY } from '../web/canvas-painter';
const here = dirname(fileURLToPath(import.meta.url));
const css = readFileSync(resolve(here, '../web/style.css'), 'utf8');
function rootTokens(source: string): Map<string, string> {
const m = /:root\s*\{([\s\S]*?)\n\}/.exec(source);
assert.ok(m, 'style.css 缺少 :root 块');
const out = new Map<string, string>();
for (const line of m[1].split('\n')) {
const t = /^\s*(--[\w-]+)\s*:\s*(.+?);\s*$/.exec(line);
if (t) out.set(t[1], t[2]);
}
return out;
}
const tokens = rootTokens(css);
const hex = (s: string | undefined): string => (s ?? '').trim().toUpperCase();
test('颜色令牌:style.css :root 与 THEME 一致', () => {
const pairs: Array<[string, string]> = [
['--bg', THEME.bg],
['--cell', THEME.cell],
['--ink', THEME.clueInk],
['--clue-ring', THEME.clueRing],
['--primary', THEME.visited],
['--primary-bright', THEME.visitedBright],
['--on-primary', THEME.visitedInk],
['--danger', THEME.conflict],
['--hint', THEME.hint],
];
for (const [name, value] of pairs) {
assert.equal(hex(tokens.get(name)), hex(value), `${name} 与 theme.ts 不一致`);
}
});
test('动效时长令牌:--t-* 与 MOTION 一致', () => {
const pairs: Array<[string, number]> = [
['--t-fast', MOTION.fast],
['--t-base', MOTION.base],
['--t-slow', MOTION.slow],
['--t-hold', MOTION.hold],
];
for (const [name, seconds] of pairs) {
assert.equal(tokens.get(name), `${Math.round(seconds * 1000)}ms`, `${name} 与 theme.ts MOTION 不一致`);
}
assert.equal(MOTION.numberFade, MOTION.fast, '步号淡入应等于 fast 档');
assert.equal(MOTION.bridgeGrow, MOTION.fast, '桥生长应等于 fast 档');
assert.equal(MOTION.flashTime, MOTION.slow, '闪白应等于 slow 档');
assert.equal(MOTION.clearedSweep, MOTION.slow, '过关扫光应等于 slow 档');
assert.equal(MOTION.clearedHold, MOTION.hold, '过关停留应等于 hold 档');
assert.equal(MOTION.boardEnter, MOTION.base, '进场应等于 base 档');
});
test('字体栈:canvas 与 CSS --font 完全一致', () => {
assert.equal(tokens.get('--font'), BOARD_FONT_FAMILY);
});