- 玩法一 数字一笔:规则库、求解器、26 关、竖屏触摸 Web 原型、测试 - 玩法二 连连气球:连消畅玩版核心、Web 原型、Cocos 球槽组件与导出脚本 - 玩法三 六边一笔:规则库、求解器、10 关 + 转墙实验包、Web 原型、测试 - 产品文档、UI 设计规范、各玩法 GDD / HANDOFF - 文档移除全部外部参考作引用;删除玩法二 v0 旧稿
147 lines
6.3 KiB
TypeScript
147 lines
6.3 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import { readFileSync } from 'node:fs';
|
|
import { test } from 'node:test';
|
|
import { Board } from '../src/board';
|
|
import { pickClueHint } from '../src/clue-hint';
|
|
import { Game } from '../src/game';
|
|
import { fitBoard } from '../src/layout';
|
|
import { loadPack, parsePack } from '../src/levels';
|
|
import type { Painter } from '../src/painter';
|
|
import { uniqueSolution } from '../src/solver';
|
|
import { THEME } from '../src/theme';
|
|
import { BoardView } from '../src/view';
|
|
|
|
function line() {
|
|
return new Game(Board.fromCells(Array.from({ length: 9 }, (_, i) => ({
|
|
id: String(i), x: i, y: 0, w: 1, h: 1, clue: i === 0 || i === 8 ? i + 1 : undefined,
|
|
}))));
|
|
}
|
|
const solution = [0, 1, 2, 3, 4, 5, 6, 7, 8];
|
|
|
|
test('1~9 补 5,再补 3;只增加线索,不代画路线', () => {
|
|
const game = line();
|
|
const h = pickClueHint(game, solution)!;
|
|
assert.deepEqual(h, { cell: 4, step: 5 });
|
|
assert.deepEqual(game.apply({ type: 'reveal', ...h }), [{ type: 'clueRevealed', ...h }]);
|
|
assert.deepEqual(pickClueHint(game, solution), { cell: 2, step: 3 });
|
|
assert.deepEqual(game.path, []);
|
|
assert.equal(game.status, 'empty');
|
|
assert.equal(game.remaining, 9);
|
|
});
|
|
|
|
test('按已解出的前缀推进区间,跳过已经走对的数字', () => {
|
|
const game = line();
|
|
for (const cell of solution.slice(0, 4)) game.apply({ type: game.path.length ? 'append' : 'start', cell });
|
|
assert.deepEqual(pickClueHint(game, solution), { cell: 5, step: 6 });
|
|
game.apply({ type: 'reveal', cell: 4, step: 5 });
|
|
game.apply({ type: 'append', cell: 4 });
|
|
assert.deepEqual(pickClueHint(game, solution), { cell: 6, step: 7 });
|
|
});
|
|
|
|
test('新数字独立于错误路线,并立即约束已走错的格;回退后冲突消失', () => {
|
|
const board = Board.fromCells([
|
|
{ id: 'a', x: 0, y: 0, w: 1, h: 1, clue: 1 },
|
|
{ id: 'b', x: 1, y: 0, w: 1, h: 1 },
|
|
{ id: 'c', x: 1, y: 1, w: 1, h: 1 },
|
|
{ id: 'd', x: 0, y: 1, w: 1, h: 1, clue: 4 },
|
|
]);
|
|
const game = new Game(board);
|
|
for (const cell of [0, 3, 2]) game.apply({ type: game.path.length ? 'append' : 'start', cell });
|
|
assert.deepEqual(pickClueHint(game, [0, 1, 2, 3]), { cell: 1, step: 2 });
|
|
game.apply({ type: 'append', cell: 1 });
|
|
game.apply({ type: 'reveal', cell: 1, step: 2 });
|
|
assert.equal(game.isConflict(1), true);
|
|
assert.equal(game.isCleared, false);
|
|
game.apply({ type: 'rewind', cell: 0 });
|
|
assert.equal(game.hasConflict(), false);
|
|
assert.equal(game.clueAt(1).clue, 2);
|
|
});
|
|
|
|
test('重开保留补出的数字,原始关卡和新对局不变', () => {
|
|
const game = line();
|
|
game.apply({ type: 'reveal', cell: 4, step: 5 });
|
|
game.apply({ type: 'start', cell: 0 });
|
|
game.apply({ type: 'reset' });
|
|
assert.equal(game.clueAt(4).clue, 5);
|
|
assert.equal(game.board.cells[4].clue, null);
|
|
assert.equal(new Game(game.board).clueAt(4).clue, null);
|
|
assert.deepEqual(game.path, []);
|
|
});
|
|
|
|
test('补出的 1 成为起手约束,奇偶要求同时保留', () => {
|
|
const game = new Game(Board.fromCells([
|
|
{ id: 'a', x: 0, y: 0, w: 1, h: 1, parity: 'odd' },
|
|
{ id: 'b', x: 1, y: 0, w: 1, h: 1, clue: 2 },
|
|
]));
|
|
assert.deepEqual(pickClueHint(game, [0, 1]), { cell: 0, step: 1 });
|
|
game.apply({ type: 'reveal', cell: 0, step: 1 });
|
|
assert.deepEqual(game.clueAt(0), { clue: 1, parity: 'odd' });
|
|
assert.deepEqual(game.apply({ type: 'start', cell: 1 }), [{ type: 'rejectStart', cell: 1, clueCell: 0 }]);
|
|
game.apply({ type: 'start', cell: 0 });
|
|
game.apply({ type: 'append', cell: 1 });
|
|
assert.equal(game.isCleared, true);
|
|
assert.equal(pickClueHint(game, [0, 1]), null);
|
|
});
|
|
|
|
test('不覆盖原数字、不重复、不接受越界或奇偶不符的补充数字', () => {
|
|
const game = line();
|
|
for (const [cell, step] of [[0, 2], [1, 1], [1, 9], [1, 0], [1, 10], [-1, 2], [9, 2], [1.5, 2], [1, 2.5]]) {
|
|
assert.deepEqual(game.apply({ type: 'reveal', cell, step }), []);
|
|
}
|
|
game.apply({ type: 'reveal', cell: 4, step: 5 });
|
|
assert.deepEqual(game.apply({ type: 'reveal', cell: 4, step: 6 }), []);
|
|
assert.deepEqual(game.apply({ type: 'reveal', cell: 3, step: 5 }), []);
|
|
assert.equal(pickClueHint(game, []), null);
|
|
const parity = new Game(Board.fromCells([
|
|
{ id: 'a', x: 0, y: 0, w: 1, h: 1, parity: 'even' },
|
|
{ id: 'b', x: 1, y: 0, w: 1, h: 1 },
|
|
]));
|
|
assert.deepEqual(parity.apply({ type: 'reveal', cell: 0, step: 1 }), []);
|
|
});
|
|
|
|
test('补数字的渲染:数字与原奇偶同在,走错时显示要求并变红', () => {
|
|
const game = new Game(Board.fromCells([
|
|
{ id: 'a', x: 0, y: 0, w: 1, h: 1 },
|
|
{ id: 'b', x: 1, y: 0, w: 1, h: 1, parity: 'even' },
|
|
]));
|
|
const view = new BoardView(game, { reducedMotion: true });
|
|
view.layout = fitBoard(game.board, 320, 400);
|
|
const labels: string[] = [];
|
|
const fills: string[] = [];
|
|
const painter: Painter = {
|
|
text: s => { labels.push(s); }, fillRoundRect: (_r, _radius, color) => { fills.push(color); },
|
|
strokeRoundRect: () => {}, fillCircle: () => {},
|
|
};
|
|
game.apply({ type: 'reveal', cell: 1, step: 2 }).forEach(e => view.onEvent(e));
|
|
view.draw(painter);
|
|
assert.ok(labels.includes('2') && labels.includes('偶'));
|
|
labels.length = 0;
|
|
game.apply({ type: 'start', cell: 1 }).forEach(e => view.onEvent(e));
|
|
view.draw(painter);
|
|
assert.ok(labels.includes('1') && labels.includes('2'));
|
|
assert.ok(fills.includes(THEME.conflict));
|
|
});
|
|
|
|
test('26 关反复补数最终用尽且每个数符合唯一解,重开仍可正常通关', () => {
|
|
for (const file of ['pack-1-tutorial.json', 'pack-2-advanced.json', 'pack-3-parity.json']) {
|
|
const levels = loadPack(parsePack(JSON.parse(readFileSync(new URL(`../levels/${file}`, import.meta.url), 'utf8'))));
|
|
for (const level of levels) {
|
|
const game = new Game(level.board);
|
|
const path = uniqueSolution(level.board)!;
|
|
for (let i = 0; i <= game.n; i++) {
|
|
const h = pickClueHint(game, path);
|
|
if (!h) break;
|
|
assert.ok(i < game.n, `${level.def.id} 提示应有界`);
|
|
assert.equal(path[h.step - 1], h.cell);
|
|
assert.equal(game.apply({ type: 'reveal', ...h }).length, 1);
|
|
}
|
|
assert.equal(pickClueHint(game, path), null);
|
|
assert.deepEqual(game.path, []);
|
|
game.apply({ type: 'start', cell: path[0] });
|
|
game.apply({ type: 'reset' });
|
|
path.forEach((cell, i) => game.apply({ type: i === 0 ? 'start' : 'append', cell }));
|
|
assert.equal(game.isCleared, true, level.def.id);
|
|
}
|
|
}
|
|
});
|