import assert from 'node:assert/strict'; import { mkdirSync, writeFileSync } from 'node:fs'; import { FlowGame } from '../src/flow'; import { findFlowChains, canFlowAppend } from '../src/flow-rules'; import { Gesture } from '../src/input'; import { HEIGHT, WIDTH } from '../src/config'; import { Random } from '../src/random'; let states = 0, maxOverlap = 0; function check(game: FlowGame): void { states++; assert(game.balls.length <= 24); assert.equal(new Set(game.balls.map(b => b.id)).size, game.balls.length); for (const a of game.balls) { assert(Number.isFinite(a.x) && Number.isFinite(a.y)); assert(a.n >= 1 && a.n < 10); assert(a.x >= a.r && a.x <= WIDTH - a.r && a.y >= a.r - 2 && a.y <= HEIGHT - a.r, `outside: ${JSON.stringify(a)}`); for (const b of game.balls) if (a.id < b.id) { const overlap = a.r + b.r - Math.hypot(a.x - b.x, a.y - b.y); maxOverlap = Math.max(maxOverlap, overlap); assert(overlap < 2, `overlap ${overlap}`); } } assert(findFlowChains(game.balls).length > 0, 'dead board'); } const report = []; for (const strategy of ['random', 'guided'] as const) { const sessions = strategy === 'random' ? 24 : 8; let combos = 0, reactions = 0, flown = 0, breezes = 0, minChoices = Infinity; const times: number[] = [], runs = []; for (let seed = 1; seed <= sessions; seed++) { const game = new FlowGame(seed * 937), rng = new Random(seed), input = new Gesture(() => game.balls, canFlowAppend); check(game); let comboGestures = 0; for (let turn = 0; turn < 100; turn++) { const paths = findFlowChains(game.balls); minChoices = Math.min(minChoices, paths.length); const ids = strategy === 'guided' ? game.hint() : paths[Math.floor(rng.next() * paths.length)]; assert(ids.length >= 3, 'hint missing'); if (strategy === 'guided') { const targets = ids.map(id => game.balls.find(b => b.id === id)!); input.down(targets[0]); targets.slice(1).forEach(b => input.move(b)); assert.deepEqual(input.up(), ids, 'unplayable hint'); } const started = performance.now(), result = game.commit(ids); times.push(performance.now() - started); assert(result); if (result.stages.length > 1) {combos++;comboGestures++;} check(game); assert(result.stages.length <= (result.doubled ? 4 : 3)); } flown += game.stats.flown; reactions += game.stats.reactions; breezes += game.breezes; runs.push({seed:seed * 937,score:game.stats.score,peak:game.stats.maxNumber,flown:game.stats.flown,comboGestures,breezes:game.breezes}); } times.sort((a,b)=>a-b); const metrics = {strategy,sessions,gestures:sessions*100,comboGestures:combos,comboRate:combos/(sessions*100),reactions,flown,breezes,minPaths:minChoices, commitMs:{median:times[Math.floor(times.length*.5)],p95:times[Math.floor(times.length*.95)],max:times[times.length-1]}}; report.push({...metrics,runs}); console.log(JSON.stringify(metrics)); } const result = {generatedAt:new Date().toISOString(),note:'固定种子算法抽样;随机策略从合法链均匀抽取,提示策略通过真实手势控制器。连消率不是玩家数据。',states,maxOverlap,strategies:report}; mkdirSync(new URL('../docs/qa-v2/',import.meta.url),{recursive:true}); writeFileSync(new URL('../docs/qa-v2/validation.json',import.meta.url),JSON.stringify(result,null,2)+'\n'); console.log(`检查 ${states} 个稳定盘面;最大重叠 ${maxOverlap.toFixed(3)}px。`);