// v1 关卡版回归:保留历史实验,当前 Web / Cocos 主入口使用 FlowGame。 import { test } from 'node:test'; import assert from 'node:assert/strict'; import { Game } from '../src/game'; import { Gesture } from '../src/input'; import { LEVELS } from '../src/levels'; import { WIDTH, HEIGHT, radius } from '../src/config'; import { adjacent, canAppend, findChains, legal, resultNumber } from '../src/rules'; import { fresh, loadProgress, saveProgress, starsFor, unlocked } from '../src/progress'; import { settle } from '../src/physics'; import { hint } from '../src/solver'; import type { Ball } from '../src/types'; const ball = (n: number, id = n, x = n * 45, y = 120): Ball => ({ id, n, x, y, r: radius(n), vx: 0, vy: 0 }); test('正反连续链得到同一个最大数 +1;不能同号、跳号、换向或重复', () => { const chain = [ball(1), ball(2), ball(3), ball(4)]; assert(legal(chain)); assert(legal([...chain].reverse())); assert.equal(resultNumber(chain), 5); assert.equal(resultNumber([...chain].reverse()), 5); assert(!legal(chain.slice(0, 2))); assert(!legal([chain[0], chain[1], chain[3]])); assert(!canAppend(chain.slice(0, 3), ball(2, 9, 150))); assert(!legal([ball(2, 1, 100), ball(2, 2, 140), ball(2, 3, 180)])); assert(!canAppend([chain[0], chain[1]], chain[0])); }); test('邻接只看圆心距离,边界是半径和的 1.35 倍', () => { const a = ball(1, 1, 0), b = ball(2, 2, (22 + 28) * 1.35); assert(adjacent(a, b)); b.x += .001; assert(!adjacent(a, b)); }); test('合成位置取链尾,最后一步目标完成优先于步数失败', () => { const g = new Game({ ...LEVELS[0], moves: 1 }); const ids = hint(g), tail = g.balls.find(b => b.id === ids[ids.length - 1])!; const x = tail.x, y = tail.y, r = tail.r; const result = g.commit(ids)!; assert.equal(result.created.id, tail.id); assert.equal(result.created.x, x); assert.equal(result.created.y, y); assert(result.created.r > r); assert.equal(g.stats.score, 1200); assert.equal(g.movesLeft, 0); assert.equal(g.status, 'won'); }); test('非法和过时 ID 不改变盘面、步数和随机数', () => { const g = new Game(LEVELS[2]), before = g.snapshot(); assert.equal(g.commit([1, 2]), null); assert.equal(g.commit([991, 992, 993]), null); assert.deepEqual(g.snapshot(), before); assert.equal(g.canUndo, false); }); test('同一种子、撤销重走后,补球、坐标、分数都完全一致', () => { const g = new Game(LEVELS[4]), ids = hint(g), initial = g.snapshot(); assert.deepEqual(initial, new Game(LEVELS[4]).snapshot()); assert(g.commit(ids)); const after = g.snapshot(); assert(g.undo()); assert.deepEqual(g.snapshot(), initial); assert(g.commit(ids)); assert.deepEqual(g.snapshot(), after); }); test('快滑采样中间球;回到上一颗撤回,取消不提交', () => { const balls = [ball(1, 1, 40), ball(2, 2, 90), ball(3, 3, 145)]; const input = new Gesture(() => balls); input.down(balls[0]); input.move(balls[2]); assert.deepEqual(input.chain, [1, 2, 3]); input.move(balls[1]); assert.deepEqual(input.chain, [1, 2]); input.move(balls[2]); assert.deepEqual(input.up(), [1, 2, 3]); assert.equal(input.active, false); assert.deepEqual(input.up(), []); input.down(balls[0]); input.move(balls[2]); input.cancel(); assert.deepEqual(input.up(), []); }); test('打乱保留数字、不扣步且只用一次;撤销恢复打乱之前', () => { const g = new Game(LEVELS[4]), before = g.snapshot(), numbers = g.balls.map(b => b.n).sort(); assert(g.shuffle()); assert.equal(g.shuffles, 0); assert.equal(g.movesLeft, before.movesLeft); assert.deepEqual(g.balls.map(b => b.n).sort(), numbers); assert(!g.shuffle()); assert(g.undo()); assert.deepEqual(g.snapshot(), before); }); test('无解数字不会空扣打乱次数', () => { const g = new Game(LEVELS[0]); g.balls.forEach(b => { b.n = 1; b.r = 22; }); g.status = 'stuck'; const before = g.snapshot(); assert(!g.shuffle()); assert.deepEqual(g.snapshot(), before); }); test('目标没完成而步数耗尽,进入失败且允许撤销', () => { const g = new Game({ ...LEVELS[0], moves: 1, goals: [{ kind: 'number', value: 8 }] }); assert(g.commit(findChains(g.balls)[0])); assert.equal(g.status, 'lost'); assert(g.undo()); assert.equal(g.status, 'playing'); assert.equal(g.movesLeft, 1); }); test('自由模式不检查关卡目标、不扣步数', () => { const g = new Game(LEVELS[0], true); assert(g.commit(findChains(g.balls)[0])); assert.notEqual(g.status, 'won'); assert.equal(g.movesLeft, -1); }); test('物理将重叠同心球分开并限制左右与底部边界', () => { const balls = Array.from({ length: 18 }, (_, i) => ball(i % 4 + 1, i + 1, 180, 100)); settle(balls, 400); for (const a of balls) { assert(Number.isFinite(a.x) && Number.isFinite(a.y)); assert(a.x >= a.r && a.x <= WIDTH - a.r); assert(a.y <= HEIGHT - a.r); for (const b of balls) if (a.id !== b.id) assert(Math.hypot(a.x - b.x, a.y - b.y) >= a.r + b.r - 1.5); } }); test('损坏/不可用存档不阻塞游戏,非法成绩被过滤', () => { assert.deepEqual(loadProgress({ read: () => '{broken', write() {} }), fresh()); assert.deepEqual(loadProgress({ read() { throw Error('blocked'); }, write() {} }), fresh()); assert.equal(saveProgress({ read: () => null, write() { throw Error('quota'); } }, fresh()), false); const p = loadProgress({ read: () => JSON.stringify({ version: 1, levels: { 1: { stars: 3, score: 1200 }, 2: { stars: 99, score: -1 } }, sound: false }), write() {} }); assert.equal(unlocked(p), 2); assert.equal(p.sound, false); assert.equal(p.levels[2], undefined); assert.equal(starsFor(3, 3, false), 3); assert.equal(starsFor(3, 3, true), 2); assert.equal(starsFor(6, 3, false), 1); }); for (const level of LEVELS) test(`第 ${level.id} 关:标准解通过真实手势控制器逐步回放`, () => { const g = new Game(level), input = new Gesture(() => g.balls); assert(level.solution?.length); assert.equal(g.status, 'playing'); for (const path of level.solution!) { assert.deepEqual(hint(g), path); const targets = path.map(id => g.balls.find(b => b.id === id)!); input.down(targets[0]); for (const target of targets.slice(1)) input.move(target); assert.deepEqual(input.chain, path, `第 ${g.stats.merges + 1} 步不能直接划到`); assert(g.commit(input.up())); for (const b of g.balls) { assert(b.y >= b.r - 2, `球 ${b.id} 超出顶部`); assert(b.x >= b.r && b.x <= WIDTH - b.r && b.y <= HEIGHT - b.r); } } assert.equal(g.status, 'won'); assert(g.stats.merges <= level.moves); }); test('动画不改变规则状态,跳帧与逐帧采样得到相同物理画面', async () => { const { MergeAnimation } = await import('../src/animation'); const g = new Game(LEVELS[7]), result = g.commit(hint(g))!, saved = g.snapshot(); const a = new MergeAnimation(result, 0), b = new MergeAnimation(result, 0); for (let t = 0; t < 700; t += 16) a.sample(t); assert.deepEqual(a.sample(700), b.sample(700)); assert.deepEqual(g.snapshot(), saved); assert.equal(a.sample(760), null); });