初始提交:三玩法 Web 原型与设计文档
- 玩法一 数字一笔:规则库、求解器、26 关、竖屏触摸 Web 原型、测试 - 玩法二 连连气球:连消畅玩版核心、Web 原型、Cocos 球槽组件与导出脚本 - 玩法三 六边一笔:规则库、求解器、10 关 + 转墙实验包、Web 原型、测试 - 产品文档、UI 设计规范、各玩法 GDD / HANDOFF - 文档移除全部外部参考作引用;删除玩法二 v0 旧稿
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {writeFile} from 'node:fs/promises';
|
||||
// 本次任务已有的空间;新的验收任务请改成已创建的空间 ID。
|
||||
const task=await taskSpace(4),page=task.page('p1');
|
||||
const dir='/Users/wukaibing/Grok/number-swipe/modes/02-connect-1234/docs/qa-v2';
|
||||
await page.keyboard.press('Escape');
|
||||
assert(await page.evaluate(()=>document.querySelector('#modal').hidden));
|
||||
await page.click('#records'); await page.screenshot({path:dir+'/records.png'});
|
||||
await page.keyboard.press('Escape');
|
||||
const layouts=[];
|
||||
for(const [width,height] of [[320,568],[360,740],[390,844],[430,932],[844,390],[1280,900]]){
|
||||
await page.cdp('Emulation.setDeviceMetricsOverride',{width,height,deviceScaleFactor:2,mobile:width<600});
|
||||
await page.waitForFunction(w=>innerWidth===w,width,{timeout:3000});
|
||||
const layout=await page.evaluate(()=>({width:innerWidth,height:innerHeight,scrollWidth:document.documentElement.scrollWidth,scrollHeight:document.documentElement.scrollHeight,
|
||||
buttons:[...document.querySelectorAll('#app button')].map(b=>{const r=b.getBoundingClientRect();return {id:b.id,w:r.width,h:r.height,bottom:r.bottom}}),stageHeight:document.querySelector('#stage').getBoundingClientRect().height}));
|
||||
assert.equal(layout.scrollWidth,width); assert(layout.buttons.every(b=>b.w>=44&&b.h>=44)); layouts.push(layout);
|
||||
if(width===320)await page.screenshot({path:dir+'/small-phone.png'});
|
||||
if(width===1280)await page.screenshot({path:dir+'/desktop.png'});
|
||||
}
|
||||
await page.cdp('Emulation.setDeviceMetricsOverride',{width:390,height:844,deviceScaleFactor:2,mobile:true});
|
||||
await page.cdp('Emulation.setEmulatedMedia',{features:[{name:'prefers-reduced-motion',value:'reduce'}]});
|
||||
await page.click('#restart'); await page.click('button[data-action="new"]');
|
||||
assert.equal(await page.evaluate(()=>window.__balloons.snapshot().stats.turns),0);
|
||||
const points=await page.evaluate(()=>{const api=window.__balloons,s=api.snapshot(),t=api.transform(),r=document.querySelector('#board').getBoundingClientRect();return api.hint().map(id=>{const b=s.balls.find(b=>b.id===id);return{x:r.left+t.x+b.x*t.scale,y:r.top+t.y+b.y*t.scale,id:0}})});
|
||||
for(let i=0;i<points.length;i++)await page.cdp('Input.dispatchTouchEvent',{type:i?'touchMove':'touchStart',touchPoints:[points[i]]});
|
||||
await page.cdp('Input.dispatchTouchEvent',{type:'touchEnd',touchPoints:[]});
|
||||
assert.equal(await page.evaluate(()=>window.__balloons.snapshot().stats.turns),1);assert(!await page.evaluate(()=>window.__balloons.animating()));
|
||||
// 减少动态效果下的横幅仍可显示(不依赖 CSS keyframes)。
|
||||
const bannerStyle=await page.evaluate(()=>{const el=document.querySelector('#combo-banner');el.classList.add('show');const style=getComputedStyle(el);return{opacity:style.opacity,animation:style.animationName};});
|
||||
assert.equal(bannerStyle.opacity,'1');assert.equal(bannerStyle.animation,'none');
|
||||
await page.cdp('Emulation.setEmulatedMedia',{features:[]});
|
||||
await writeFile(dir+'/layout.json',JSON.stringify({layouts,reducedMotion:true,keyboardModal:true},null,2)+'\n');
|
||||
console.log(JSON.stringify({layouts:layouts.map(l=>({width:l.width,height:l.height,scrollHeight:l.scrollHeight,stageHeight:l.stageHeight})),reducedMotion:true}));
|
||||
@@ -0,0 +1,34 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {writeFile} from 'node:fs/promises';
|
||||
const task=await taskSpace(4),page=task.page('p1');
|
||||
const dir='/Users/wukaibing/Grok/number-swipe/modes/02-connect-1234/docs/qa-v2';
|
||||
await page.goto('http://localhost:8002');
|
||||
const opening=await page.evaluate(()=>({state:window.__balloons.snapshot(),hint:window.__balloons.hint()}));
|
||||
await page.goto('http://localhost:8003');
|
||||
await page.waitForFunction(()=>document.querySelector('#app').dataset.mode==='flow',undefined,{timeout:5000});
|
||||
const key='mode:02-connect-1234:balloons-v2';
|
||||
const backup=await page.evaluate(k=>({v2:localStorage.getItem(k),v1:localStorage.getItem('mode:02-connect-1234:v1')}),key);
|
||||
assert.equal(await page.evaluate(()=>typeof window.__balloons),'undefined');
|
||||
await page.evaluate(()=>{window.qaErrors=[];addEventListener('error',e=>window.qaErrors.push(e.message));addEventListener('unhandledrejection',e=>window.qaErrors.push(String(e.reason)));});
|
||||
const coords=await page.evaluate(data=>{const r=document.querySelector('#board').getBoundingClientRect(),s=Math.min(r.width/360,r.height/416);return data.hint.map(id=>{const b=data.state.balls.find(b=>b.id===id);return{x:r.left+(r.width-360*s)/2+b.x*s,y:r.top+(r.height-416*s)/2+b.y*s,id:0};});},opening);
|
||||
for(let i=0;i<coords.length;i++)await page.cdp('Input.dispatchTouchEvent',{type:i?'touchMove':'touchStart',touchPoints:[coords[i]]});
|
||||
await page.cdp('Input.dispatchTouchEvent',{type:'touchEnd',touchPoints:[]});
|
||||
await page.waitForFunction(()=>document.querySelector('#app').dataset.animating==='false',undefined,{timeout:7000});
|
||||
const result=await page.evaluate(()=>({turns:Number(document.querySelector('#app').dataset.turns),score:document.querySelector('#score').textContent,errors:window.qaErrors,debugExposed:typeof window.__balloons!=='undefined'}));
|
||||
assert.equal(result.turns,1);assert(Number(result.score.replaceAll(',',''))>0);assert.deepEqual(result.errors,[]);
|
||||
await page.screenshot({path:dir+'/production-merge.png'});
|
||||
await page.reload();assert.equal(await page.evaluate(()=>document.querySelector('#best-score').textContent),result.score);
|
||||
await page.click('#help');
|
||||
await page.waitForFunction(()=>document.querySelector('#sheet').getAnimations().every(a=>a.playState==='finished'),undefined,{timeout:3000});
|
||||
await page.screenshot({path:dir+'/help.png'});await page.click('button[aria-label="关闭面板"]');
|
||||
// 仅恢复本次正式包验收前的 v2 纪录;保留其它存档。
|
||||
await page.evaluate(({key,v2})=>{if(v2===null)localStorage.removeItem(key);else localStorage.setItem(key,v2);},{key,v2:backup.v2});
|
||||
assert.equal(await page.evaluate(()=>localStorage.getItem('mode:02-connect-1234:v1')),backup.v1);
|
||||
await page.reload();
|
||||
await page.screenshot({path:dir+'/mobile.png'});
|
||||
await page.cdp('Emulation.setDeviceMetricsOverride',{width:1280,height:900,deviceScaleFactor:2,mobile:false});
|
||||
await page.waitForFunction(()=>innerWidth===1280,undefined,{timeout:3000});await page.screenshot({path:dir+'/desktop.png'});
|
||||
await page.cdp('Emulation.setDeviceMetricsOverride',{width:390,height:844,deviceScaleFactor:2,mobile:true});
|
||||
await page.waitForFunction(()=>innerWidth===390,undefined,{timeout:3000});
|
||||
await writeFile(dir+'/production.json',JSON.stringify({...result,persistence:true,originalRecordRestored:true,legacyRecordPreserved:true,url:await page.url()},null,2)+'\n');
|
||||
console.log({...result,persistence:true,originalRecordRestored:true,url:await page.url()});
|
||||
@@ -0,0 +1,45 @@
|
||||
// 本次任务已有空间为 4;新验收任务需改成已创建的空间 ID。
|
||||
// 只读调试快照作为触摸定位依据,不改游戏状态。
|
||||
import assert from 'node:assert/strict';
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
const task = await taskSpace(4);
|
||||
const page = task.page('p1');
|
||||
const dir = process.env.BALLOON_QA_DIR || '/Users/wukaibing/Grok/number-swipe/modes/02-connect-1234/docs/qa-v2';
|
||||
await page.reload();
|
||||
await page.evaluate(() => {window.qaErrors=[];addEventListener('error',e=>window.qaErrors.push(e.message));addEventListener('unhandledrejection',e=>window.qaErrors.push(String(e.reason)));});
|
||||
const snapshot = () => page.evaluate(() => window.__balloons.snapshot());
|
||||
const targets = () => page.evaluate(() => {const api=window.__balloons, s=api.snapshot(), t=api.transform(), r=document.querySelector('#board').getBoundingClientRect();return api.hint().map(id=>{const b=s.balls.find(b=>b.id===id);return {x:r.left+t.x+b.x*t.scale,y:r.top+t.y+b.y*t.scale,id:0,radiusX:5,radiusY:5,force:1};});});
|
||||
const touch = (type, points=[]) => page.cdp('Input.dispatchTouchEvent',{type,touchPoints:points});
|
||||
const swipe = async(points,cancel=false) => {
|
||||
for(let i=0;i<points.length;i++) await touch(i?'touchMove':'touchStart',[points[i]]);
|
||||
await touch(cancel?'touchCancel':'touchEnd');
|
||||
};
|
||||
const opening=await snapshot(), points=await targets();
|
||||
await swipe(points.slice(0,2)); assert.deepEqual(await snapshot(),opening);
|
||||
await swipe(points,true); assert.deepEqual(await snapshot(),opening);
|
||||
await swipe([points[0],points[1],points[2],points[1]]); assert.deepEqual(await snapshot(),opening);
|
||||
const moves=[];
|
||||
for(let i=0;i<24;i++){
|
||||
const before=await snapshot(); await swipe(await targets());
|
||||
await page.waitForFunction(()=>!window.__balloons.animating(),undefined,{timeout:7000});
|
||||
const after=await snapshot(); assert.equal(after.stats.turns,before.stats.turns+1); assert(after.stats.score>before.stats.score);
|
||||
assert(await page.evaluate(()=>document.querySelector('#modal').hidden));
|
||||
moves.push({turn:after.stats.turns,score:after.stats.score,combo:after.stats.lastCombo,peak:after.stats.maxNumber,flown:after.stats.flown,feverLeft:after.feverLeft});
|
||||
if(i===0){
|
||||
await page.screenshot({path:dir+'/first-merge.png'});
|
||||
await page.click('#undo'); assert.deepEqual(await snapshot(),before);
|
||||
await swipe(await targets()); await page.waitForFunction(()=>!window.__balloons.animating(),undefined,{timeout:7000});
|
||||
assert.deepEqual(await snapshot(),after);
|
||||
}
|
||||
if(after.feverLeft===5 && !moves.slice(0,-1).some(m=>m.feverLeft===5)) await page.screenshot({path:dir+'/rainbow.png'});
|
||||
if(after.stats.flown===1 && !moves.slice(0,-1).some(m=>m.flown>0)) await page.screenshot({path:dir+'/flight.png'});
|
||||
}
|
||||
const beforeBreeze=await snapshot(); await page.click('#shuffle');
|
||||
await page.waitForFunction(()=>!window.__balloons.animating(),undefined,{timeout:7000});
|
||||
assert.deepEqual((await snapshot()).stats,beforeBreeze.stats);
|
||||
await page.click('#undo'); assert.deepEqual(await snapshot(),beforeBreeze);
|
||||
const errors=await page.evaluate(()=>window.qaErrors); assert.deepEqual(errors,[]);
|
||||
assert(moves.some(m=>m.combo>=3)); assert(moves.some(m=>m.flown>0)); assert(moves.some(m=>m.feverLeft>0));
|
||||
await writeFile(dir+'/browser.json',JSON.stringify({viewport:[390,844],input:'CDP touch events',invalidCancelBacktrack:true,undoDeterministic:true,breezeUndo:true,moves,errors},null,2)+'\n');
|
||||
console.log(JSON.stringify({gestures:moves.length,final:moves.at(-1),errors}));
|
||||
console.log(await page.snapshot());
|
||||
@@ -0,0 +1,54 @@
|
||||
const space = Number(process.env.GARDEN_SPACE);
|
||||
if (!space || !process.env.GARDEN_QA_DIR) throw new Error('请设置 GARDEN_SPACE 与 GARDEN_QA_DIR,复用当前 QA 空间。');
|
||||
const task = await taskSpace(space), page = task.page('p1');
|
||||
const fs = await import('node:fs/promises');
|
||||
const root = process.env.GARDEN_QA_DIR;
|
||||
await page.reload();
|
||||
await page.waitForFunction(() => !!window.__garden);
|
||||
const saved = await page.evaluate(() => JSON.parse(localStorage.getItem('mode:02-connect-1234:v1')));
|
||||
if (Object.keys(saved.levels).length !== 10) throw new Error('completion not persisted');
|
||||
let before = await page.evaluate(() => window.__garden.snapshot());
|
||||
const touchHint = async () => {
|
||||
const data = await page.evaluate(() => {
|
||||
const g=window.__garden, s=g.snapshot(), t=g.transform(), r=document.getElementById('board').getBoundingClientRect();
|
||||
return { merges:s.stats.merges, points:g.hint().map(id=>{const b=s.balls.find(b=>b.id===id);return {x:r.left+t.x+b.x*t.scale,y:r.top+t.y+b.y*t.scale};})};
|
||||
});
|
||||
for (let i=0;i<data.points.length;i++) await page.cdp('Input.dispatchTouchEvent',{type:i?'touchMove':'touchStart',touchPoints:[{...data.points[i],id:0,radiusX:5,radiusY:5,force:1}]});
|
||||
await page.cdp('Input.dispatchTouchEvent',{type:'touchEnd',touchPoints:[]});
|
||||
await page.waitForFunction(n=>window.__garden.snapshot().stats.merges===n+1&&!window.__garden.animating(),data.merges,{timeout:5000});
|
||||
};
|
||||
await touchHint();
|
||||
const after = await page.evaluate(() => window.__garden.snapshot());
|
||||
await page.click('#undo');
|
||||
if (JSON.stringify(await page.evaluate(()=>window.__garden.snapshot()))!==JSON.stringify(before)) throw new Error('undo not exact');
|
||||
await touchHint();
|
||||
if (JSON.stringify(await page.evaluate(()=>window.__garden.snapshot()))!==JSON.stringify(after)) throw new Error('redo not deterministic');
|
||||
await page.click('#undo');
|
||||
await page.click('#shuffle');
|
||||
const shuffled = await page.evaluate(()=>window.__garden.snapshot());
|
||||
if (shuffled.shuffles!==0 || shuffled.movesLeft!==before.movesLeft || JSON.stringify(shuffled.balls.map(b=>b.n).sort())!==JSON.stringify(before.balls.map(b=>b.n).sort())) throw new Error('shuffle mismatch');
|
||||
await page.click('#undo');
|
||||
if (JSON.stringify(await page.evaluate(()=>window.__garden.snapshot()))!==JSON.stringify(before)) throw new Error('shuffle undo mismatch');
|
||||
await page.click('#levels'); await page.click('[data-action="endless"]');
|
||||
await touchHint();
|
||||
if ((await page.evaluate(()=>window.__garden.snapshot())).movesLeft!==-1) throw new Error('endless consumed move');
|
||||
await page.click('#levels'); await page.click('[data-level="8"]');
|
||||
const layouts=[];
|
||||
for (const [width,height,mobile] of [[320,568,true],[360,740,true],[390,844,true],[430,932,true],[844,390,true],[1280,900,false]]) {
|
||||
await page.cdp('Emulation.setDeviceMetricsOverride',{width,height,deviceScaleFactor:2,mobile});
|
||||
await page.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve))));
|
||||
const layout=await page.evaluate(()=>({width:innerWidth,height:innerHeight,overflow:document.documentElement.scrollWidth>innerWidth+1,bodyHeight:document.documentElement.scrollHeight,buttons:[...document.querySelectorAll('.tools button,.header-actions button')].map(b=>({id:b.id,w:b.getBoundingClientRect().width,h:b.getBoundingClientRect().height}))}));
|
||||
if(layout.overflow)throw new Error(`horizontal overflow at ${width}`);
|
||||
if(layout.buttons.some(b=>b.w<44||b.h<44))throw new Error(`small touch target at ${width}`);
|
||||
layouts.push(layout);
|
||||
if(width===320||width===1280)await page.screenshot({path:`${root}/${width===320?'mobile-320':'desktop'}.png`});
|
||||
}
|
||||
await page.cdp('Emulation.setDeviceMetricsOverride',{width:390,height:844,deviceScaleFactor:2,mobile:true});
|
||||
await page.click('#help');
|
||||
await page.waitForFunction(()=>!document.getElementById('sheet').getAnimations().some(a=>a.playState==='running'));
|
||||
await page.screenshot({path:`${root}/mobile-help.png`});
|
||||
await page.keyboard.press('Escape');
|
||||
if(!(await page.evaluate(()=>document.getElementById('modal').hidden)))throw new Error('escape not closed');
|
||||
await fs.writeFile(`${root}/layout.json`,JSON.stringify({checks:['刷新保留十关成绩','撤销精确恢复盘面及补球','重走结果确定一致','打乱保留数字和步数','撤销恢复打乱库存','自由模式无步数限制','帮助可用 Escape 关闭'],layouts},null,2));
|
||||
console.log(JSON.stringify(layouts));
|
||||
console.log(await page.snapshot());
|
||||
@@ -0,0 +1,59 @@
|
||||
// 通过 ego-browser nodejs < tools/browser-smoke.mjs 运行;仅访问本地测试页面。
|
||||
// 空间编号由首次 browser QA 建立,同一任务一直复用该空间。
|
||||
const space = Number(process.env.GARDEN_SPACE);
|
||||
if (!space || !process.env.GARDEN_QA_DIR) throw new Error('请设置 GARDEN_SPACE 与 GARDEN_QA_DIR,复用当前 QA 空间。');
|
||||
const task = await taskSpace(space), page = task.page('p1');
|
||||
await page.reload();
|
||||
await page.waitForFunction(() => !!window.__garden);
|
||||
const fs = await import('node:fs/promises');
|
||||
const root = process.env.GARDEN_QA_DIR;
|
||||
const report = { checks: [], levels: [] };
|
||||
await page.evaluate(() => { window.__qaErrors = []; window.addEventListener('error', e => window.__qaErrors.push(e.message)); window.addEventListener('unhandledrejection', e => window.__qaErrors.push(String(e.reason))); });
|
||||
const info = () => page.evaluate(() => {
|
||||
const g = window.__garden, s = g.snapshot(), t = g.transform(), r = document.getElementById('board').getBoundingClientRect();
|
||||
return { snapshot: s, level: g.level(), hint: g.hint(), points: g.hint().map(id => { const b = s.balls.find(b => b.id === id); return { x: r.left + t.x + b.x * t.scale, y: r.top + t.y + b.y * t.scale }; }) };
|
||||
});
|
||||
const send = async (type, points) => page.cdp('Input.dispatchTouchEvent', { type, touchPoints: points.map((p, id) => ({...p, id, radiusX: 5, radiusY: 5, force: 1})) });
|
||||
let initial = await info();
|
||||
if (initial.level.id !== 1) throw new Error('QA expects an untouched campaign');
|
||||
// 两颗松手、系统取消、回滑撤回均不得扣步。
|
||||
await send('touchStart', [initial.points[0]]); await send('touchMove', [initial.points[1]]); await send('touchEnd', []);
|
||||
if ((await info()).snapshot.stats.merges !== 0) throw new Error('short gesture committed');
|
||||
report.checks.push('两颗松手不扣步');
|
||||
await send('touchStart', [initial.points[0]]); await send('touchMove', [initial.points[1]]); await send('touchCancel', []);
|
||||
if ((await info()).snapshot.stats.merges !== 0) throw new Error('cancel committed');
|
||||
report.checks.push('触摸取消不提交');
|
||||
await send('touchStart', [initial.points[0]]); await send('touchMove', [initial.points[1]]); await send('touchMove', [initial.points[2]]); await send('touchMove', [initial.points[1]]); await send('touchEnd', []);
|
||||
if ((await info()).snapshot.stats.merges !== 0) throw new Error('backtrack failed');
|
||||
report.checks.push('回滑撤回');
|
||||
await page.click('#sound');
|
||||
await page.click('#hint');
|
||||
await page.screenshot({path: `${root}/mobile-initial.png`});
|
||||
for (let expected = 1; expected <= 10; expected++) {
|
||||
let data = await info();
|
||||
if (data.level.id !== expected) throw new Error(`Expected L${expected}, got ${data.level.id}`);
|
||||
for (let turn = 0; turn < data.level.moves; turn++) {
|
||||
data = await info();
|
||||
if (data.snapshot.status === 'won') break;
|
||||
if (data.snapshot.status !== 'playing') throw new Error(`L${expected} prematurely ${data.snapshot.status}`);
|
||||
const previous = data.snapshot.stats.merges;
|
||||
await send('touchStart', [data.points[0]]);
|
||||
for (const p of data.points.slice(1)) await send('touchMove', [p]);
|
||||
await send('touchEnd', []);
|
||||
await page.waitForFunction(previous => window.__garden.snapshot().stats.merges > previous && !window.__garden.animating(), previous, { timeout: 5000 });
|
||||
}
|
||||
data = await info();
|
||||
if (data.snapshot.status !== 'won') throw new Error(`L${expected} not won`);
|
||||
report.levels.push({ level: expected, moves: data.snapshot.stats.merges, score: data.snapshot.stats.score });
|
||||
console.log(`触摸通关 L${expected} ${data.snapshot.stats.merges} 步 ${data.snapshot.stats.score} 分`);
|
||||
if (expected === 7) await page.screenshot({path: `${root}/mobile-result.png`});
|
||||
if (expected < 10) {
|
||||
await page.click('[data-action="next"]');
|
||||
if (expected === 7) await page.screenshot({path: `${root}/mobile-level8.png`});
|
||||
}
|
||||
}
|
||||
await page.screenshot({path: `${root}/mobile-all-clear.png`});
|
||||
report.errors = await page.evaluate(() => window.__qaErrors);
|
||||
if (report.errors.length) throw new Error(JSON.stringify(report.errors));
|
||||
await fs.writeFile(`${root}/browser.json`, JSON.stringify(report, null, 2));
|
||||
console.log(await page.snapshot());
|
||||
@@ -0,0 +1,25 @@
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import { Game } from '../src/game';
|
||||
import { solve } from '../src/solver';
|
||||
import type { Level } from '../src/types';
|
||||
|
||||
const file = new URL('../levels/campaign.json', import.meta.url);
|
||||
const pack = JSON.parse(readFileSync(file, 'utf8')) as { levels: Level[] };
|
||||
for (const level of pack.levels) {
|
||||
if (level.solution?.length) continue;
|
||||
let found = false;
|
||||
for (let trial = 0; trial < 24; trial++) {
|
||||
const start = Date.now();
|
||||
const game = new Game(level);
|
||||
const solution = solve(game, level.id < 7 ? 12 : 18);
|
||||
console.log(`L${level.id} seed=${level.seed} ${solution ? `WIN ${solution.path.length} moves / ${solution.visited} states` : 'no proof'} ${Date.now() - start}ms`);
|
||||
if (solution) {
|
||||
level.solution = solution.path;
|
||||
level.par = solution.path.length;
|
||||
writeFileSync(file, JSON.stringify(pack, null, 2) + '\n');
|
||||
found = true; break;
|
||||
}
|
||||
level.seed += 137;
|
||||
}
|
||||
if (!found) throw new Error(`Unable to find L${level.id}`);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
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。`);
|
||||
@@ -0,0 +1,57 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { writeFileSync } from 'node:fs';
|
||||
import { Game } from '../src/game';
|
||||
import { LEVELS } from '../src/levels';
|
||||
import { goalView } from '../src/goals';
|
||||
import { findChains } from '../src/rules';
|
||||
import { Gesture } from '../src/input';
|
||||
import { HEIGHT, WIDTH } from '../src/config';
|
||||
import { Random } from '../src/random';
|
||||
|
||||
assert.equal(LEVELS.length, 10);
|
||||
const report: unknown[] = [];
|
||||
let states = 0, maxOverlap = 0;
|
||||
function check(g: Game): void {
|
||||
states++;
|
||||
for (const a of g.balls) {
|
||||
assert(Number.isFinite(a.x) && Number.isFinite(a.y));
|
||||
assert(a.x >= a.r && a.x <= WIDTH - a.r && a.y <= HEIGHT - a.r && a.y >= a.r - 2, `ball outside: ${JSON.stringify(a)}`);
|
||||
for (const b of g.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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const l of LEVELS) {
|
||||
assert(l.initial.every(n => n >= 1 && n <= 4));
|
||||
assert(l.refill.length > 0 && l.refill.every(n => n >= 1 && n <= 4));
|
||||
const g = new Game(l), input = new Gesture(() => g.balls); check(g);
|
||||
const openings = findChains(g.balls).length;
|
||||
const moves: unknown[] = [];
|
||||
for (const ids of l.solution || []) {
|
||||
const balls = ids.map(id => g.balls.find(b => b.id === id)!);
|
||||
input.down(balls[0]); balls.slice(1).forEach(b => input.move(b));
|
||||
assert.deepEqual(input.chain, ids, `L${l.id} touch route`);
|
||||
const result = g.commit(input.up()); assert(result); check(g);
|
||||
moves.push({ ids, numbers: balls.map(b => b.n), created: result.created.n, score: g.stats.score });
|
||||
}
|
||||
assert.equal(g.status, 'won', `L${l.id}`);
|
||||
assert.equal(l.par, g.stats.merges);
|
||||
report.push({ id: l.id, title: l.title, seed: l.seed, budget: l.moves, solutionMoves: g.stats.merges,
|
||||
spareMoves: g.movesLeft, score: g.stats.score, openings, goals: l.goals.map(goal => goalView(goal, g.stats)), moves });
|
||||
console.log(`L${String(l.id).padStart(2,'0')} ${l.title}: ${g.stats.merges}/${l.moves} 步,${g.stats.score} 分,最高 ${g.stats.maxNumber}`);
|
||||
}
|
||||
let randomMerges = 0, longestRun = 0;
|
||||
for (let seed = 1; seed <= 128; seed++) {
|
||||
const r = new Random(seed), g = new Game({ ...LEVELS[7], seed: seed * 937 }, true); check(g);
|
||||
for (let turn = 0; turn < 30 && g.status === 'playing'; turn++) {
|
||||
const paths = findChains(g.balls); const path = paths[Math.floor(r.next() * paths.length)];
|
||||
assert(g.commit(path)); check(g); randomMerges++; longestRun = Math.max(longestRun, turn + 1);
|
||||
if (g.snapshot().status === 'stuck' && g.shuffles) g.shuffle();
|
||||
}
|
||||
}
|
||||
assert(randomMerges >= 500, `随机合成覆盖不足: ${randomMerges}`);
|
||||
const result = { generatedAt: new Date().toISOString(), levels: report, physics: { checkedStates: states, randomMerges, longestRun, maxOverlap } };
|
||||
writeFileSync(new URL('../docs/qa/validation.json', import.meta.url), JSON.stringify(result, null, 2) + '\n');
|
||||
console.log(`物理检查:${states} 个稳定盘面,${randomMerges} 次随机合成,最长连续 ${longestRun} 次,最大重叠 ${maxOverlap.toFixed(3)}px`);
|
||||
Reference in New Issue
Block a user