初始提交:三玩法 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
+57
View File
@@ -0,0 +1,57 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { HexBoard } from '../src/board';
import type { HexCellDef } from '../src/types';
const c = (id: string, q: number, r: number, clue?: number): HexCellDef => ({ id, q, r, clue });
test('六邻接:中心格有 6 个邻居,角落只有共边的', () => {
const cells: HexCellDef[] = [c('o', 0, 0)];
const dirs: Array<[number, number]> = [[1, 0], [1, -1], [0, -1], [-1, 0], [-1, 1], [0, 1]];
dirs.forEach(([q, r], i) => cells.push(c(`n${i}`, q, r)));
const b = HexBoard.fromCells(cells);
assert.equal(b.adj[0].length, 6);
// 外圈每格:中心 + 两个相邻外圈格
for (let i = 1; i <= 6; i++) assert.equal(b.adj[i].length, 3);
// (1,1) 不是 (0,0) 的邻居
const b2 = HexBoard.fromCells([c('a', 0, 0), c('b', 1, 1)]);
assert.equal(b2.areAdjacent(0, 1), false);
assert.equal(b2.isConnected(), false);
});
test('墙去掉一条邻接,几何邻接不变', () => {
const b = HexBoard.fromCells([c('a', 0, 0), c('b', 1, 0), c('c', 0, 1)], [['a', 'b']]);
assert.equal(b.areAdjacent(0, 1), false);
assert.equal(b.isGeoNeighbor(0, 1), true);
assert.equal(b.isWalled(0, 1), true);
assert.equal(b.isWalled(1, 0), true);
assert.equal(b.areAdjacent(0, 2), true);
assert.equal(b.areAdjacent(1, 2), true);
assert.equal(b.wallBetween(1, 0)?.index, 0);
assert.equal(b.withoutWalls().areAdjacent(0, 1), true);
assert.equal(b.isConnected(), true);
});
test('墙把盘切开 → 不连通', () => {
const b = HexBoard.fromCells([c('a', 0, 0), c('b', 1, 0)], [['a', 'b']]);
assert.equal(b.isConnected(), false);
});
test('非法盘抛错:坐标重叠、id 重复、墙不在共边、墙重复、线索越界', () => {
assert.throws(() => HexBoard.fromCells([c('a', 0, 0), c('b', 0, 0)]), /重叠/);
assert.throws(() => HexBoard.fromCells([c('a', 0, 0), c('a', 1, 0)]), /重复/);
assert.throws(() => HexBoard.fromCells([c('a', 0, 0), c('b', 1, 1)], [['a', 'b']]), /共用边/);
assert.throws(() => HexBoard.fromCells([c('a', 0, 0), c('b', 1, 0)], [['a', 'b'], ['b', 'a']]), /重复/);
assert.throws(() => HexBoard.fromCells([c('a', 0, 0), c('b', 1, 0)], [['a', 'x']]), /不存在/);
assert.throws(() => HexBoard.fromCells([c('a', 0, 0, 3), c('b', 1, 0)]), /超出/);
assert.throws(() => HexBoard.fromCells([c('a', 0, 0, 1), c('b', 1, 0, 1)]), /线索重复/);
});
test('cellAt / cellById / 线索索引', () => {
const b = HexBoard.fromCells([c('a', -1, 2, 1), c('b', 0, 2, 2)]);
assert.equal(b.cellAt(-1, 2), 0);
assert.equal(b.cellAt(5, 5), -1);
assert.equal(b.cellById('b').index, 1);
assert.equal(b.startClueCell, 0);
assert.equal(b.clueCellOfStep.get(2), 1);
});
+114
View File
@@ -0,0 +1,114 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { HexBoard } from '../src/board';
import { Game } from '../src/game';
import type { HexCellDef } from '../src/types';
const c = (id: string, q: number, r: number, clue?: number): HexCellDef => ({ id, q, r, clue });
// 1-3:a(1) b / c d(4),墙 a|b
const level13 = () => HexBoard.fromCells([c('a', 0, 0, 1), c('b', 1, 0), c('c', 0, 1), c('d', 1, 1, 4)], [['a', 'b']]);
test('有线索 1 时只能从它起手', () => {
const g = new Game(level13());
assert.deepEqual(g.apply({ type: 'start', cell: 1 }), [{ 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(HexBoard.fromCells([c('a', 0, 0), c('b', 1, 0, 2)]));
assert.equal(g.apply({ type: 'start', cell: 0 })[0].type, 'step');
});
test('穿墙 → wallHit 事件,不追加', () => {
const b = level13();
const g = new Game(b);
g.apply({ type: 'start', cell: 0 });
const ev = g.apply({ type: 'append', cell: 1 });
assert.deepEqual(ev, [{ type: 'wallHit', from: 0, to: 1, wall: 0, spinner: -1 }]);
assert.equal(g.path.length, 1);
assert.deepEqual(g.wallTo(1), { wall: 0, spinner: -1 });
assert.equal(g.canAppend(1), false);
assert.equal(g.canAppend(2), true);
});
test('追加只能到相邻无墙未走格;冲突不打断;撤销恢复', () => {
const b = level13();
const g = new Game(b);
const id = (s: string) => b.cellById(s).index;
g.apply({ type: 'start', cell: id('a') });
assert.deepEqual(g.apply({ type: 'append', cell: id('d') }), []); // (1,1) 与 (0,0) 不相邻
g.apply({ type: 'append', cell: id('c') });
const ev = g.apply({ type: 'append', cell: id('d') }); // d=3,冲突
assert.equal(ev[0].type, 'step');
assert.equal((ev[0] as { conflict: boolean }).conflict, true);
assert.equal(g.isConflict(id('d')), true);
const last = g.apply({ type: 'append', cell: id('b') });
assert.equal(g.path.length, 4);
assert.equal(g.status, 'drawing', '填满但有冲突不算过关');
assert.ok(last.some((e) => e.type === 'deadEnd'));
g.apply({ type: 'undo' });
const un = g.apply({ type: 'undo' });
assert.deepEqual(un, [{ type: 'unstep', cell: id('d'), step: 3 }]);
assert.equal(g.isConflict(id('d')), false);
});
test('全走完且线索全对 → cleared,之后锁住', () => {
const b = level13();
const g = new Game(b);
const id = (s: string) => b.cellById(s).index;
g.apply({ type: 'start', cell: id('a') });
g.apply({ type: 'append', cell: id('c') });
g.apply({ type: 'append', cell: id('b') });
const ev = g.apply({ type: 'append', cell: id('d') });
assert.ok(ev.some((e) => e.type === 'cleared'));
assert.ok(ev.some((e) => e.type === 'step' && e.clueHit));
assert.equal(g.status, 'cleared');
assert.deepEqual(g.apply({ type: 'undo' }), []);
assert.deepEqual(g.apply({ type: 'append', cell: 0 }), []);
});
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.deepEqual(g.apply({ type: 'reset' }), [], '空盘重开不产生事件');
g.apply({ type: 'start', cell: 0 });
g.apply({ type: 'undo' });
assert.equal(g.status, 'empty');
});
test('转墙:每走一格转 120°,按已走格数判定', () => {
// 花形 7 格,轴心在中心格 o 的第 4 个角(顶部),周围三格 o / nw / ne
const cells = [c('o', 0, 0, 1), c('e', 1, 0), c('ne', 1, -1), c('nw', 0, -1), c('w', -1, 0), c('sw', -1, 1), c('se', 0, 1)];
const b = HexBoard.fromCells(cells, [], [{ cell: 'o', corner: 4, phase: 0 }]);
const sp = b.spinners[0];
const id = (s: string) => b.cellById(s).index;
// 角 4 夹在方向 240°(nw) 与 300°(ne) 之间:边 0 = nw-ne,边 1 = o-ne,边 2 = o-nw
assert.deepEqual(sp.edges[0], [Math.min(id('nw'), id('ne')), Math.max(id('nw'), id('ne'))]);
assert.deepEqual(sp.edges[1], [id('o'), id('ne')]);
assert.deepEqual(sp.edges[2], [id('o'), id('nw')]);
// t=0 挡 nw-ne;t=1 挡 o-ne;t=2 挡 o-nw
assert.equal(b.spinnerBlocking(id('nw'), id('ne'), 0), 0);
assert.equal(b.spinnerBlocking(id('o'), id('ne'), 1), 0);
assert.equal(b.spinnerBlocking(id('o'), id('nw'), 2), 0);
assert.equal(b.spinnerBlocking(id('o'), id('ne'), 0), -1);
const g = new Game(b);
g.apply({ type: 'start', cell: id('o') }); // 已走 1 格 → 墙挡 o-ne
const hit = g.apply({ type: 'append', cell: id('ne') });
assert.deepEqual(hit, [{ type: 'wallHit', from: id('o'), to: id('ne'), wall: -1, spinner: 0 }]);
assert.equal(g.apply({ type: 'append', cell: id('nw') })[0].type, 'step'); // t=1 时 o-nw 开着
// 现在已走 2 格 → 墙挡 o-nw(已走,无所谓);nw-ne 开着
assert.equal(g.apply({ type: 'append', cell: id('ne') })[0].type, 'step');
g.apply({ type: 'undo' });
g.apply({ type: 'undo' });
assert.equal(g.path.length, 1);
assert.deepEqual(g.apply({ type: 'append', cell: id('ne') })[0].type, 'wallHit', '撤销后墙转回去');
});
+87
View File
@@ -0,0 +1,87 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { HexBoard } from '../src/board';
import { Game } from '../src/game';
import { PointerInput } from '../src/input';
import type { HexCellDef } from '../src/types';
const c = (id: string, q: number, r: number, clue?: number): HexCellDef => ({ id, q, r, clue });
const level13 = () => HexBoard.fromCells([c('a', 0, 0, 1), c('b', 1, 0), c('c', 0, 1), c('d', 1, 1, 4)], [['a', 'b']]);
function setup() {
const b = level13();
const g = new Game(b);
const input = new PointerInput(g);
const id = (s: string) => b.cellById(s).index;
return { b, g, input, id };
}
test('按下起点、滑到邻格追加、滑回撤销、松手保留', () => {
const { g, input, id } = setup();
assert.equal(input.down(id('a'))[0].type, 'step');
assert.ok(input.isDrawing);
assert.equal(input.move(id('c'))[0].type, 'step');
assert.deepEqual(input.move(id('c')), [], '同一格不重复触发');
assert.equal(input.move(id('a'))[0].type, 'unstep');
assert.equal(g.path.length, 1);
input.move(id('c'));
input.up();
assert.equal(g.status, 'idle');
assert.equal(g.path.length, 2);
});
test('滑向有墙的邻格 → wallHit 一次,路径不变', () => {
const { g, input, id } = setup();
input.down(id('a'));
const ev = input.move(id('b'));
assert.equal(ev.length, 1);
assert.equal(ev[0].type, 'wallHit');
assert.deepEqual(input.move(id('b')), [], '停在同一格不重复报');
assert.equal(g.path.length, 1);
// 指针逻辑上仍在末端:继续滑到 c 可追加
assert.equal(input.move(id('c'))[0].type, 'step');
});
test('点末端继续、点前一格撤销、点旁边空格追加、点墙外邻格报撞墙', () => {
const { g, input, id } = setup();
input.down(id('a'));
input.move(id('c'));
input.up();
assert.equal(input.down(id('c')).length, 0);
assert.ok(input.isDrawing);
input.up();
assert.equal(input.down(id('a'))[0].type, 'unstep');
assert.equal(g.path.length, 1);
input.up();
assert.equal(input.down(id('c'))[0].type, 'step', '点着走');
input.up();
// 路径 a,c;c 的邻居 b 无墙 → 追加;再从 b 去 a 已走 → 忽略
assert.equal(input.down(id('b'))[0].type, 'step');
input.up();
// 从 a 起手时点 b:撞墙反馈
input.cancel();
g.apply({ type: 'reset' });
input.down(id('a'));
input.up();
assert.equal(input.down(id('b'))[0].type, 'wallHit');
});
test('非法起手与空白按下', () => {
const { input, id } = setup();
assert.equal(input.down(id('b'))[0].type, 'rejectStart');
assert.ok(!input.isDrawing);
assert.deepEqual(input.down(-1), []);
assert.deepEqual(input.move(id('a')), [], '没在画,move 无效');
});
test('过关后手势失效', () => {
const { g, input, id } = setup();
input.down(id('a'));
input.move(id('c'));
input.move(id('b'));
const ev = input.move(id('d'));
assert.ok(ev.some((e) => e.type === 'cleared'));
assert.ok(!input.isDrawing);
assert.equal(g.isCleared, true);
assert.deepEqual(input.down(id('d')), []);
});
+79
View File
@@ -0,0 +1,79 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { HexBoard } from '../src/board';
import { clipPolygon, roundPolygon, stripesInBand } from '../src/geom';
import { bridgePolygon, cellCenter, fitBoard, hitTest, pixelToAxial, sharedEdge, wallBand } from '../src/layout';
import type { HexCellDef } from '../src/types';
const c = (id: string, q: number, r: number, clue?: number): HexCellDef => ({ id, q, r, clue });
const flower = () =>
HexBoard.fromCells([c('o', 0, 0), c('e', 1, 0), c('ne', 1, -1), c('nw', 0, -1), c('w', -1, 0), c('sw', -1, 1), c('se', 0, 1)], [['o', 'e']]);
test('fitBoard 按短边缩放并居中,不超过 maxSize', () => {
const b = flower();
const L = fitBoard(b, 400, 600, { padding: 10, maxSize: 60 });
assert.ok(L.size <= 60);
// 花形宽 = 3 × √3 × size,应贴合 380 宽或受 maxSize 限制
assert.ok(L.width <= 380 + 1e-6);
const o = cellCenter(b, 0, L);
assert.ok(Math.abs(o.x - 200) < 1e-6);
assert.ok(Math.abs(o.y - 300) < 1e-6);
const small = fitBoard(b, 100, 100, { padding: 0 });
assert.ok(small.width <= 100 + 1e-6 && small.height <= 100 + 1e-6);
});
test('像素 ↔ 轴坐标往返;命中中心;死区与空洞返回 -1', () => {
const b = flower();
const L = fitBoard(b, 400, 600, { padding: 10, maxSize: 60 });
for (const cell of b.cells) {
const p = cellCenter(b, cell.index, L);
assert.deepEqual(pixelToAxial(L, p.x, p.y), { q: cell.q, r: cell.r });
assert.equal(hitTest(b, L, p.x, p.y), cell.index);
}
const o = cellCenter(b, 0, L);
const e = cellCenter(b, 1, L);
// 两格中点在缝里 → 死区
assert.equal(hitTest(b, L, (o.x + e.x) / 2, (o.y + e.y) / 2), -1);
// 盘外
assert.equal(hitTest(b, L, 5, 5), -1);
// (2,0) 不存在
const far = { x: o.x + Math.sqrt(3) * L.size * 2, y: o.y };
assert.equal(hitTest(b, L, far.x, far.y), -1);
});
test('共用边、桥、墙带的几何', () => {
const b = flower();
const L = fitBoard(b, 400, 600, { padding: 10, maxSize: 60 });
const e = sharedEdge(b, 0, 1, L)!;
const o = cellCenter(b, 0, L);
const ea = cellCenter(b, 1, L);
assert.ok(Math.abs(e.mid.x - (o.x + ea.x) / 2) < 1e-6);
assert.ok(Math.abs(e.n.x - 1) < 1e-6, '东邻的法向是 +x');
assert.ok(Math.abs(e.u.y - 1) < 1e-6, '共用边竖直');
assert.equal(sharedEdge(b, 1, 3, L), null, 'ne 与 w 不相邻');
const br = bridgePolygon(b, 0, 1, L)!;
assert.equal(br.length, 4);
const wb = wallBand(b, 0, L);
assert.ok(wb.thick > 0 && wb.len > 0);
});
test('几何工具:圆角多边形顶点数、凸裁剪、斜纹都在带内', () => {
const sq = [
{ x: 0, y: 0 },
{ x: 10, y: 0 },
{ x: 10, y: 10 },
{ x: 0, y: 10 },
];
assert.equal(roundPolygon(sq, 2, 3).length, 16);
const tri = [
{ x: -5, y: 5 },
{ x: 15, y: 5 },
{ x: 5, y: 20 },
];
const clipped = clipPolygon(tri, sq);
assert.ok(clipped.length >= 3);
for (const p of clipped) assert.ok(p.x >= -1e-6 && p.x <= 10 + 1e-6 && p.y >= -1e-6 && p.y <= 10 + 1e-6);
const stripes = stripesInBand({ x: 0, y: 0 }, { x: 1, y: 0 }, { x: 0, y: 1 }, 40, 8, 8, 4);
assert.ok(stripes.length >= 4);
for (const s of stripes) for (const p of s) assert.ok(Math.abs(p.x) <= 20 + 1e-6 && Math.abs(p.y) <= 4 + 1e-6);
});
+41
View File
@@ -0,0 +1,41 @@
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);
p.markCleared('1-1');
assert.equal(p.isUnlocked(ids, 1), true);
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.equal(STORAGE_KEY, 'mode:03-hex-walls');
assert.ok(store.get(STORAGE_KEY));
const p2 = new Progress(store);
assert.equal(p2.isCleared('1-1'), true);
assert.equal(p2.currentId, '1-2');
assert.equal(p2.prefs.sound, false);
assert.equal(p2.prefs.haptics, true);
p2.resetAll();
assert.equal(new Progress(store).clearedCount(), 0);
});
test('坏存档不炸', () => {
const store = new MemoryStore();
store.set(STORAGE_KEY, '{not json');
const p = new Progress(store);
assert.equal(p.clearedCount(), 0);
});
+97
View File
@@ -0,0 +1,97 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { test } from 'node:test';
import { HexBoard } from '../src/board';
import { loadPack, parsePack } from '../src/levels';
import { hint, redundantWalls, solve, spinnerReport, uniqueSolution, wallsMatter } from '../src/solver';
import type { HexCellDef } from '../src/types';
const c = (id: string, q: number, r: number, clue?: number): HexCellDef => ({ id, q, r, clue });
const pack = parsePack(JSON.parse(readFileSync(new URL('../levels/pack-1-tutorial.json', import.meta.url), 'utf8')));
const levels = loadPack(pack);
test('第一包恰好 10 关,每关连通且唯一解', () => {
assert.equal(levels.length, 10);
for (const lv of levels) assert.equal(lv.board.spinners.length, 0, '第一包不含转墙');
for (const lv of levels) {
assert.ok(lv.board.isConnected(), `${lv.def.id} 不连通`);
const paths = solve(lv.board, { limit: 2 });
assert.equal(paths.length, 1, `${lv.def.id} 解数 ${paths.length}`);
assert.equal(paths[0].length, lv.board.n);
// 解满足线索
paths[0].forEach((cell, i) => {
const clue = lv.board.cells[cell].clue;
if (clue !== null) assert.equal(clue, i + 1, `${lv.def.id} 线索不符`);
});
}
});
test('第 3 关起每关至少 1 堵墙,且墙参与唯一性、无冗余墙', () => {
for (const lv of levels) {
if (lv.index < 3) continue;
assert.ok(lv.board.walls.length >= 1, `${lv.def.id} 没有墙`);
assert.ok(wallsMatter(lv.board), `${lv.def.id} 去墙后仍唯一`);
assert.deepEqual(redundantWalls(lv.board), [], `${lv.def.id} 有冗余墙`);
}
});
test('前 8 关都有线索 1', () => {
for (const lv of levels) if (lv.index <= 8) assert.notEqual(lv.board.startClueCell, -1, lv.def.id);
});
test('墙改变解集合:同一盘有墙 1 解、无墙 2 解', () => {
const b = HexBoard.fromCells([c('a', 0, 0, 1), c('b', 1, 0), c('c', 0, 1), c('d', 1, 1, 4)], [['a', 'b']]);
assert.deepEqual(uniqueSolution(b), [0, 2, 1, 3]);
assert.equal(solve(b.withoutWalls()).length, 2);
});
test('无线索 1 时枚举全部起点;无解 / 多解返回 null', () => {
const b = HexBoard.fromCells([c('a', 0, 0), c('b', 1, 0), c('c', 2, 0)]);
assert.equal(solve(b).length, 2);
assert.equal(uniqueSolution(b), null);
const b2 = HexBoard.fromCells([c('a', 0, 0), c('b', 1, 0), c('c', 2, 0, 2)]);
assert.equal(solve(b2).length, 0);
});
test('随机模式也能找到解', () => {
const b = levels[9].board;
let seed = 1;
const rnd = () => (seed = (seed * 16807) % 2147483647) / 2147483647;
assert.equal(solve(b, { limit: 1, random: rnd }).length, 1);
});
test('提示四态', () => {
const sol = [0, 2, 1, 3];
assert.deepEqual(hint(sol, []), { kind: 'start', cell: 0 });
assert.deepEqual(hint(sol, [0, 2]), { kind: 'next', cell: 1 });
assert.deepEqual(hint(sol, [0, 1, 3]), { kind: 'backtrack', cell: 0, undoCount: 2 });
assert.deepEqual(hint(sol, [2, 0]), { kind: 'restart', cell: 0 });
assert.deepEqual(hint(sol, sol), { kind: 'none' });
});
const pack2 = parsePack(JSON.parse(readFileSync(new URL('../levels/pack-2-spinner.json', import.meta.url), 'utf8')));
const levels2 = loadPack(pack2);
test('第二包(转墙实验):每关唯一解,转墙不冗余且不能用静态墙替代', () => {
assert.ok(levels2.length >= 3);
for (const lv of levels2) {
assert.ok(lv.board.spinners.length >= 1, `${lv.def.id} 没有转墙`);
assert.equal(solve(lv.board, { limit: 2 }).length, 1, `${lv.def.id} 不唯一`);
assert.ok(wallsMatter(lv.board), `${lv.def.id} 去墙后仍唯一`);
for (const r of spinnerReport(lv.board)) {
assert.equal(r.removable, false, `${lv.def.id} 转墙 ${r.index} 冗余`);
assert.deepEqual(r.freezable, [], `${lv.def.id} 转墙 ${r.index} 可用静态墙替代`);
}
}
});
test('求解器按步号判定转墙:同一盘不同相位 2 解 / 1 解 / 0 解', () => {
// a(0,0)=1, b(1,0), d(1,-1);转墙轴在 b 的第 3 个角(左上),周围三格 a/b/d:边 0 = a|d,1 = b|d,2 = a|b
const mk = (phase: number) => HexBoard.fromCells([c('a', 0, 0, 1), c('b', 1, 0), c('d', 1, -1)], [], [{ cell: 'b', corner: 3, phase }]);
const b0 = mk(0);
assert.deepEqual(b0.spinners[0].edges, [[0, 2], [1, 2], [0, 1]]);
assert.equal(solve(b0).length, 2, '相位 0:a,b,d 与 a,d,b 都通');
assert.deepEqual(solve(mk(1)), [[0, 2, 1]], '相位 1:起手时 a|b 被挡,只能 a,d,b');
assert.equal(solve(mk(2)).length, 0, '相位 2:a→b 后 b|d 被挡,无解');
});