初始提交:三玩法 Web 原型与设计文档
- 玩法一 数字一笔:规则库、求解器、26 关、竖屏触摸 Web 原型、测试 - 玩法二 连连气球:连消畅玩版核心、Web 原型、Cocos 球槽组件与导出脚本 - 玩法三 六边一笔:规则库、求解器、10 关 + 转墙实验包、Web 原型、测试 - 产品文档、UI 设计规范、各玩法 GDD / HANDOFF - 文档移除全部外部参考作引用;删除玩法二 v0 旧稿
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user