import { test } from 'node:test'; import assert from 'node:assert/strict'; import { FlowGame, FEVER_TARGET } from '../src/flow'; import { FlowAnimation } from '../src/flow-animation'; import { balloonRadius, cascade } from '../src/cascade'; import { canFlowAppend, legalFlow } from '../src/flow-rules'; import { Gesture } from '../src/input'; import { supply } from '../src/supply'; import { Random } from '../src/random'; import { FLOW_SAVE_KEY, freshRecord, loadRecord, recordRound, saveRecord } from '../src/record'; import { readFileSync } from 'node:fs'; import { settle } from '../src/physics'; import type { Ball } from '../src/types'; const ball = (n: number, id: number, x: number, y = 160): Ball => ({ n, id, x, y, r: balloonRadius(n), vx: 0, vy: 0 }); // 最右端合成的 4,依次接上左侧的 2/3、3/4 和 4/5。 const relay = () => [ball(1, 1, 100, 280), ball(2, 2, 150, 235), ball(3, 3, 190, 190), ball(2, 4, 85, 125), ball(3, 5, 135, 155), ball(3, 6, 90, 210), ball(4, 7, 140, 205), ball(4, 8, 92, 180), ball(5, 9, 140, 130)]; test('畅连:正反顺号和同号都可连,不能混号、重复、跳号或只有两颗', () => { const ascending = [ball(1, 1, 50), ball(2, 2, 100), ball(3, 3, 150)]; const same = [ball(2, 1, 50), ball(2, 2, 100), ball(2, 3, 150)]; assert(legalFlow(ascending)); assert(legalFlow([...ascending].reverse())); assert(legalFlow(same)); assert(!legalFlow(same.slice(0, 2))); assert(!legalFlow([same[0], same[1], ascending[2]])); assert(!legalFlow([ascending[0], ascending[1], same[2]])); assert(!canFlowAppend(ascending, ascending[0])); assert(!canFlowAppend([ascending[0]], ascending[2])); assert(!canFlowAppend([ascending[0], ascending[1]], ball(1, 9, 140))); assert(!legalFlow([ball(2, 1, 40), ball(2, 2, 200), ball(2, 3, 300)])); }); test('同号手势支持快滑、回退、取消;非法松手不会提交', () => { const balls = [ball(2, 1, 45), ball(2, 2, 95), ball(2, 3, 145)]; const gesture = new Gesture(() => balls, canFlowAppend); gesture.down(balls[0]); gesture.move(balls[2]); assert.deepEqual(gesture.chain, [1, 2, 3]); gesture.move(balls[1]); assert.deepEqual(gesture.chain, [1, 2]); gesture.cancel(); assert.deepEqual(gesture.up(), []); }); test('自动接力在链尾升级,按 1/2/3 倍计分,普通手势最多三次', () => { const source = relay(), saved = structuredClone(source), r = cascade(source, [1, 2, 3])!; assert.equal(r.stages.length, 3); assert.deepEqual(r.stages.map(s => s.created.n), [4, 5, 6]); assert.deepEqual(r.stages.map(s => s.points), [1200, 3000, 5400]); assert.equal(r.score, 9600); assert(r.stages.every(s => s.created.id === 3 && s.created.x === 190 && s.created.y === 190)); for (const s of r.stages) assert(legalFlow(s.path)); assert.deepEqual(source, saved); }); test('彩虹允许第四连,每次基础合成双倍;10 号放飞只加一次奖励', () => { const r = cascade(relay(), [1, 2, 3], true)!; assert.equal(r.stages.length, 4); assert.deepEqual(r.stages.map(s => s.points), [2400, 6000, 10800, 16800]); const ten = cascade([ball(7, 1, 70), ball(8, 2, 160), ball(9, 3, 250)], [1, 2, 3], true)!; assert.equal(ten.score, 16000); assert.equal(ten.flight?.n, 10); assert.equal(ten.balls.length, 0); assert.equal(ten.stages.length, 1); }); test('非法路径与预览不消耗随机数、预告、能量或撤销记录', () => { const game = new FlowGame(), before = game.snapshot(); assert(game.preview(game.hint())); assert.equal(game.commit([1, 2]), null); assert.equal(game.commit([9991, 9992, 9993]), null); assert.deepEqual(game.snapshot(), before); assert.equal(game.canUndo, false); }); test('补球后的自动接力进入同一条时间线,撤销重走逐项一致', () => { const game = new FlowGame(1), before = game.snapshot(), ids = [1, 2, 3]; const first = game.commit(ids)!; assert.equal(first.stages.length, 3); const kinds = first.beats.map(b => b.kind).join(','); assert(kinds.includes('fall,merge')); assert.equal(game.stats.turns, 1); assert.equal(game.stats.reactions, 3); const after = game.snapshot(); assert(game.undo()); assert.deepEqual(game.snapshot(), before); const second = game.commit(ids); assert.deepEqual(second, first); assert.deepEqual(game.snapshot(), after); }); test('下一袋预告按序兑现,新落点保留现有气球 ID 和数字', () => { const game = new FlowGame(1234), announced = game.queue.slice(0, 3), result = game.commit(game.hint())!; const drops = result.beats.filter(b => b.kind === 'fall'); assert(drops.length); assert.deepEqual(drops[0].supply.spawned.slice(0, 3).map(b => b.n), announced); for (let i = 1; i < result.beats.length; i++) { const beat = result.beats[i]; if (beat.kind !== 'fall' || beat.supply.breeze) continue; const previous = result.beats[i - 1]; if (previous.kind !== 'merge') continue; const consumed = new Set(previous.stage.path.map(b => b.id)); for (const b of previous.stage.before.filter(b => !consumed.has(b.id))) assert(beat.supply.balls.some(a => a.id === b.id && a.n === b.n)); } }); test('彩虹从下一次手势起持续五次,免费工具不消耗,结束后重新积攒', () => { const game = new FlowGame(45), prepared = game.snapshot(); prepared.feverCharge = FEVER_TARGET - 1; game.restore(prepared); const trigger = game.commit(game.hint())!; assert(trigger.feverStarted); assert(!trigger.doubled); assert.equal(game.feverLeft, 5); for (let remaining = 4; remaining >= 0; remaining--) { const before = game.snapshot(); game.shuffle(); assert.equal(game.feverLeft, before.feverLeft); assert(game.undo()); const r = game.commit(game.hint())!; assert(r.doubled); assert.equal(game.feverLeft, remaining); assert.equal(game.feverCharge, 0); } assert(!game.commit(game.hint())!.doubled); assert(game.feverCharge > 0); }); test('10 号放飞腾出空间、累计记录,撤销恢复放飞前全部状态', () => { const game = new FlowGame(), s = game.snapshot(); s.balls = [ball(7, 1, 70), ball(8, 2, 160), ball(9, 3, 250)]; s.nextId = 4; game.restore(s); const r = game.commit([1, 2, 3])!; assert.equal(game.stats.flown, 1); assert.equal(game.stats.maxNumber, 10); assert.equal(game.stats.score, 13000); assert(!game.balls.some(b => b.n >= 10)); assert(game.hint().length >= 3); assert(r.beats.some(b => b.kind === 'fall' && b.flight?.n === 10)); assert(game.undo()); assert.deepEqual(game.snapshot(), s); }); test('吹口气免费、可连续使用并撤销;正常重排保留全部数字和 ID', () => { const game = new FlowGame(65), before = game.snapshot(); for (let i = 0; i < 3; i++) { const r = game.shuffle(); assert.equal(r.kind, 'breeze'); assert(game.hint().length >= 3); assert.deepEqual(game.balls.map(b => [b.id, b.n]).sort(), before.balls.map(b => [b.id, b.n]).sort()); assert.deepEqual(game.stats, before.stats); assert.deepEqual(game.queue, before.queue); } for (let i = 0; i < 3; i++) assert(game.undo()); assert.deepEqual(game.snapshot(), before); }); test('极端断号通过有界微风恢复连线,并保留大气球', () => { const big = [ball(5, 1, 70), ball(7, 2, 180), ball(9, 3, 285)]; const r = supply(big, [], 4, new Random(88)); assert(r.breeze); assert(r.choices > 0); assert(r.balls.some(b => b.id === 3 && b.n === 9)); assert(r.balls.length <= 24); assert.equal(new Set(r.balls.map(b => b.id)).size, r.balls.length); }); test('合成和多次掉落的动画跳帧与逐帧相同,且不改变最终规则状态', () => { const game = new FlowGame(), result = game.commit(game.hint())!, saved = game.snapshot(); const step = new FlowAnimation(result, 0), jump = new FlowAnimation(result, 0); for (let t = 0; t < step.duration; t += 97) { const a = step.sample(t); assert.deepEqual(a, jump.sample(t)); const direct = new FlowAnimation(result, 0); assert.deepEqual(a, direct.sample(t)); } assert.equal(step.sample(step.duration), null); assert.deepEqual(game.snapshot(), saved); }); test('v2 纪录独立、单调累计;损坏、非法及不可用存储可恢复', () => { const map = new Map([['mode:02-connect-1234:v1', '{"version":1,"bestScore":99}']]); const storage = { read: (k: string) => map.get(k) || null, write: (k: string, v: string) => {map.set(k, v);} }; assert.deepEqual(loadRecord(storage), freshRecord()); const record = freshRecord(), game = new FlowGame(); game.commit(game.hint()); recordRound(record, game.stats); assert(saveRecord(storage, record)); assert.deepEqual(loadRecord(storage), record); assert.equal(map.get('mode:02-connect-1234:v1'), '{"version":1,"bestScore":99}'); game.undo(); recordRound(record, game.stats); assert(record.bestScore > 0); map.set(FLOW_SAVE_KEY, '{broken'); assert.deepEqual(loadRecord(storage), freshRecord()); map.set(FLOW_SAVE_KEY, JSON.stringify({version:2,bestScore:-1,bestNumber:1.5,bestCombo:Infinity,sound:false})); assert.deepEqual(loadRecord(storage), {...freshRecord(),sound:false}); const blocked = {read(): string|null {throw Error('blocked');}, write(){throw Error('quota');}}; assert.deepEqual(loadRecord(blocked), freshRecord()); assert(!saveRecord(blocked, record)); }); test('后期多球接触点充分松弛,修复种子 6559 的拥挤重叠', () => { const fixture = JSON.parse(readFileSync(new URL('../docs/qa-v2/overlap-regression.json', import.meta.url), 'utf8')); const balls: Ball[] = fixture.balls; settle(balls, 0, 96); for (const a of balls) for (const b of balls) if (a.id < b.id) assert(a.r + b.r - Math.hypot(a.x - b.x, a.y - b.y) < 1); });