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