129 lines
12 KiB
JavaScript
129 lines
12 KiB
JavaScript
import fs from 'node:fs';
|
|
import vm from 'node:vm';
|
|
import assert from 'node:assert/strict';
|
|
import crypto from 'node:crypto';
|
|
import { fileURLToPath } from 'node:url';
|
|
import path from 'node:path';
|
|
import { solve, violations, assertStructure } from './src/upstream/rules.mjs';
|
|
const root = path.dirname(fileURLToPath(import.meta.url)), pack = JSON.parse(fs.readFileSync(path.join(root, 'src/upstream/levels.json'))), manifest = JSON.parse(fs.readFileSync(path.join(root, 'source-manifest.json')));
|
|
for (const file of manifest.files) {
|
|
const hash = p => crypto.createHash('sha256').update(fs.readFileSync(p)).digest('hex');
|
|
assert.equal(hash(path.join(root, 'src/upstream', file.name)), file.sha256);
|
|
assert.equal(hash(path.resolve(root, manifest.source, file.name)), file.sha256);
|
|
}
|
|
const ordered = pack.playOrder.map(id => pack.levels.find(l => l.id === id));
|
|
assert.equal(ordered.length, 200);
|
|
assert.equal(new Set(pack.playOrder).size, 200);
|
|
function boot(storage = new Map(), width = 375, height = 812) {
|
|
const handlers = {}, frames = new Map(), played=[];
|
|
let frameId = 0,now=100000;
|
|
class Clock extends Date {static now(){return now;}}
|
|
const text = [],images=[];
|
|
const ctx = new Proxy({ drawImage:(image,x,y,w,h)=>images.push({source:image.source||null,x,y,w,h}),measureText: s => ({ width: [...String(s)].reduce((n,c)=>n+(/[^\x00-\x7f]/.test(c)?1:.55),0)*(Number(/([\d.]+)px/.exec(ctx.font)?.[1])||16) }), fillText: (s, x, y) => text.push({ s, x, y }) }, { get: (o, k) => o[k] ?? (() => { }), set: (o, k, v) => (o[k] = v, true) });
|
|
const wx = {createImage:()=>({set src(value){const b=fs.readFileSync(path.join(root,value));this.width=b.readUInt32BE(16);this.height=b.readUInt32BE(20);this.source=value;this.onload?.();}}),setInnerAudioOption:()=>{},createInnerAudioContext:()=>({stop(){},onError(){},play(){assert.ok(fs.existsSync(path.join(root,this.src)),this.src);played.push(this.src);}}), createCanvas: () => ({ getContext: () => ctx }), getWindowInfo: () => ({ windowWidth: width, windowHeight: height, pixelRatio: 2, safeArea: { top: 44, bottom: height - 34 } }), getMenuButtonBoundingClientRect: () => ({ bottom: 80 }), getStorageSync: k => storage.get(k) || '', setStorageSync: (k, v) => storage.set(k, v), removeStorageSync: k => storage.delete(k), getPerformance: () => ({ now: () => Date.now() }), showModal: () => { }, env: { USER_DATA_PATH: '/user' } };
|
|
wx.loadFont=p=>{assert.ok(fs.existsSync(path.join(root,p)),'字体必须随包提供 '+p);return path.basename(p,'.ttf');};
|
|
for (const name of ['TouchStart', 'TouchMove', 'TouchEnd', 'TouchCancel', 'Hide', 'Show', 'WindowResize'])
|
|
wx['on' + name] = cb => handlers[name] = cb;
|
|
const global = {};
|
|
vm.runInNewContext(fs.readFileSync(path.join(root, 'game.js'), 'utf8'), {Date:Clock, wx, GameGlobal: global, requestAnimationFrame: cb => { frames.set(++frameId, cb); return frameId; }, cancelAnimationFrame: id => frames.delete(id), setTimeout: () => 1, clearTimeout: () => { } }, { timeout: 10000 });
|
|
const tick = () => { const callbacks=[...frames.values()];frames.clear();text.length=0;images.length=0;for(const fn of callbacks)fn(); };
|
|
tick();
|
|
const touch = (kind, x, y) => handlers[kind]({ changedTouches: [{ identifier: 1, clientX: x, clientY: y }], touches: [{ identifier: 1, clientX: x, clientY: y }] });
|
|
const tap = (x, y) => { touch('TouchStart', x, y); touch('TouchEnd', x, y); tick(); };
|
|
const api = global.numberSwipeR2;
|
|
const button = label => { const b = api.buttons().find(b => b.label === label); assert.ok(b, '缺少按钮 ' + label); assert.ok(b.enabled, '按钮不可用 ' + label); tap(b.x + b.w / 2, b.y + b.h / 2); };
|
|
const cell = id => { const s=api.snapshot(),[x,y]=s.centers[id];tap(x+s.boardRect.x,y+s.boardRect.y); };
|
|
const select = i => { if(api.buttons().some(b=>b.label==='返回游戏'))button('返回游戏'); if (api.snapshot().screen !== 'select')
|
|
button('选关'); const label = String(i + 1); let steps = 0; while (!api.buttons().some(b => b.label === label || b.label === label + ' ✓')) {
|
|
button(api.snapshot().selection.page>Math.floor(i/15)+1?'上一页':'下一页');
|
|
assert.ok(++steps < 25);
|
|
} button(api.buttons().some(b => b.label === label) ? label : label + ' ✓'); if (api.buttons().some(b => b.label === '返回游戏'))
|
|
button('返回游戏'); assert.equal(api.snapshot().level, ordered[i].id); };
|
|
return { advance(ms){now+=ms;tick();}, api, button, cell, select, tick, handlers, frames, storage, text, played,images };
|
|
}
|
|
const wallLesson=boot();wallLesson.select(4);wallLesson.button('规则');wallLesson.advance(1200);assert.equal(wallLesson.api.snapshot().lesson.finished,false);assert.ok(wallLesson.frames.size>0,'教学超过1.1秒仍需继续刷新');wallLesson.advance(wallLesson.api.snapshot().lesson.duration-1200+50);assert.equal(wallLesson.api.snapshot().lesson.finished,true,'墙壁教学必须播到完成');wallLesson.advance(900);assert.equal(wallLesson.api.snapshot().lesson.finished,true);wallLesson.advance(100);assert.equal(wallLesson.api.snapshot().lesson.finished,false,'停留1秒后自动重播');wallLesson.button('返回游戏');wallLesson.advance(6000);assert.equal(wallLesson.api.snapshot().lesson,null,'离开后停止教学');
|
|
const ui = boot(), rows = [];
|
|
for (let i = 0; i < 200; i++) {
|
|
const l = ordered[i];
|
|
assertStructure(l);
|
|
const paths = solve(l, { limit: 2, nodeLimit: 1500000 });
|
|
assert.equal(paths.length, 1, '非唯一 ' + l.id);
|
|
const solution = paths[0];
|
|
assert.equal(violations(l, solution).length, 0);
|
|
ui.select(i);
|
|
ui.button('规则');
|
|
assert.ok(!/共边|落格|访问步号/.test(ui.api.snapshot().rules),l.id);
|
|
if(l.tutorial){const before=ui.api.snapshot();for(let j=0;j<25;j++)ui.advance(1300);const after=ui.api.snapshot();assert.deepEqual(after.path,before.path,'演示不能修改玩家路线');assert.deepEqual(after.centers,before.centers,'演示不能改变游戏布局');assert.deepEqual(after.violations,before.violations);assert.equal(after.cleared,before.cleared);assert.ok(after.lesson?.message);ui.button('重播');}
|
|
let ruleTurns=0;
|
|
while(ui.api.buttons().some(b=>b.label==='下一段'&&b.enabled)){ui.button('下一段');assert.ok(++ruleTurns<15);}
|
|
assert.ok(ui.api.buttons().every(b=>b.y>=0&&b.y+b.h<=812),'规则按钮不能越界');
|
|
ui.button('返回游戏');
|
|
ui.cell(solution[0]);
|
|
if (solution.length > 1)
|
|
ui.cell(solution[1]);
|
|
const originalFills=JSON.stringify(ui.api.snapshot().cellViews);
|
|
ui.button('撤销');
|
|
assert.equal(ui.api.snapshot().path.length, 1);
|
|
ui.button('重开');
|
|
assert.equal(ui.api.snapshot().path.length, 0);
|
|
ui.button('指路');
|
|
const hinted=ui.api.snapshot().hint;
|
|
assert.ok(hinted?.route.length>=2);
|
|
assert.equal(ui.api.snapshot().path.length,0);
|
|
for (const [step,id] of solution.entries()) {
|
|
const playedBefore=ui.played.length;ui.cell(id);
|
|
if(step===solution.length-1)assert.deepEqual(ui.played.slice(playedBefore),['audio/complete.wav'],'最后一步只播放一次过关音,不叠加路径点音');
|
|
if(step===1)assert.equal(JSON.stringify(ui.api.snapshot().cellViews),originalFills,'点击指路前后玩家填色必须一致 '+l.id);
|
|
assert.equal(Boolean(ui.api.snapshot().hint),step<hinted.end,'到达段末后消失');
|
|
}
|
|
const s = ui.api.snapshot();
|
|
assert.equal(s.cleared, true, '无法触摸通关 ' + l.id);
|
|
assert.equal(s.violations.length, 0);
|
|
const action=ui.api.buttons().find(b=>b.label===(i===199?'返回选关':'下一关 →'));
|
|
assert.ok(action,'通关按钮必须存在');
|
|
assert.ok(!ui.api.buttons().some(b=>['重开','撤销','指路'].includes(b.label)),'通关按钮独占操作栏');
|
|
const board=ui.images.at(-1);assert.ok(action.y>=board.y+board.h+24,'下一关不能覆盖盘面');
|
|
rows.push({ order: i + 1, id: l.id, geometry: l.geometry, cells: l.cells.length, unique: true, touchCleared: true });
|
|
}
|
|
ui.handlers.Hide();
|
|
assert.equal(ui.frames.size, 0);
|
|
ui.handlers.Show();
|
|
assert.equal(ui.frames.size, 1);
|
|
ui.tick();
|
|
assert.ok(ui.played.length>500,'发布包必须实际调用原生音频播放');
|
|
assert.ok(!ui.api.snapshot().rules.includes('共边'));
|
|
const restored = boot(ui.storage, 320, 640);
|
|
assert.equal(restored.api.snapshot().level, ordered[199].id);
|
|
assert.equal(JSON.parse(ui.storage.get('variant:campaign-200-r2:v1')).length, 200);
|
|
restored.button('规则');assert.ok(restored.api.buttons().every(b=>b.y>=0&&b.y+b.h<=640));const smallBefore=restored.api.snapshot();restored.advance(5200);assert.deepEqual(restored.api.snapshot().path,smallBefore.path);restored.button('返回游戏');
|
|
restored.button('设置');restored.button('音效:开');restored.button('完成');
|
|
const reopen = boot(ui.storage);
|
|
assert.equal(reopen.api.snapshot().soundEnabled, false);
|
|
// 在多种手机尺寸下验证通关后的真实按钮、绘制区域与点击跳转。
|
|
for(const [w,h]of [[320,568],[375,667],[390,844],[414,896]]){
|
|
const phone=boot(new Map(),w,h);
|
|
for(const i of [0,28,34,99,198,199]){
|
|
phone.select(i);for(const id of solve(ordered[i],{limit:1})[0])phone.cell(id);
|
|
const buttons=phone.api.buttons(),board=phone.images.at(-1),action=buttons.find(b=>b.label===(i===199?'返回选关':'下一关 →'));
|
|
assert.ok(action.y>=board.y+board.h+24,w+'x'+h+' 不覆盖盘面');
|
|
assert.ok(action.x>=0&&action.x+action.w<=w&&action.y+action.h<=h-34,'不能进入底部安全区');
|
|
for(let a=0;a<buttons.length;a++)for(let b=a+1;b<buttons.length;b++){const x=buttons[a],y=buttons[b];assert.ok(x.x+x.w<=y.x||y.x+y.w<=x.x||x.y+x.h<=y.y||y.y+y.h<=x.y,'按钮不能相叠');}
|
|
phone.button(action.label);
|
|
if(i===199)assert.equal(phone.api.snapshot().screen,'select');else{assert.equal(phone.api.snapshot().displayOrder,i+2);assert.ok(phone.api.buttons().some(b=>b.label==='重开'||b.label==='返回游戏'));}
|
|
}
|
|
}
|
|
const narrow=boot(new Map(),320,568);
|
|
for(const l of ordered.filter(l=>l.tutorial)){narrow.select(ordered.indexOf(l));narrow.button('规则');let pages=0;do{assert.ok(narrow.api.buttons().every(b=>b.y>=0&&b.y+b.h<=568));const before=narrow.api.snapshot();narrow.advance(3900);const after=narrow.api.snapshot();for(const field of ['path','centers','doors','latchedDoors','records','lastSound'])assert.deepEqual(after[field],before[field],field);if(!narrow.api.buttons().some(b=>b.label==='下一段'&&b.enabled))break;narrow.button('下一段');assert.ok(++pages<30);}while(true);narrow.button('返回游戏');}
|
|
const idle=boot();if(idle.api.buttons().some(b=>b.label==='返回游戏'))idle.button('返回游戏');idle.button('选关');idle.advance(1000);assert.equal(idle.frames.size,0,'静态选关页不持续绘制');
|
|
assert.ok(Object.values(idle.api.snapshot().fonts).every(v=>v==='loaded'));
|
|
idle.button('设置');idle.button('音乐:开');idle.button('完成');
|
|
assert.equal(idle.api.snapshot().music.enabled,false);
|
|
idle.select(162);idle.cell(solve(ordered[162])[0][0]);idle.advance(2000);
|
|
assert.equal(idle.frames.size,0,'停手思考时停止绘制');
|
|
idle.button('指路');idle.advance(1000);assert.ok(idle.api.snapshot().hint);assert.ok(idle.frames.size>0,'流动指路持续更新');
|
|
const reducedRestored=boot(idle.storage);assert.equal(reducedRestored.api.snapshot().music.enabled,false);
|
|
const result = {channel:'main',eventFeedback:true,backtrackCore:true,hapticsIndependent:true,selectionColumns:3,deviceValidation:{iPhone:'pending',Android:'pending'},startupGameplay:true,chapterSelection:true,theme:'bright-classroom',bundledFonts:true,musicPreferencePersisted:true,idleRenderingStops:true,segmentHints:true,playerFillUnchangedAfterHint:true,animatedLessons:10,previewStateIsolation:true,narrowTutorials:'320x568', source: manifest.source, version: pack.version, total: 200, passed: 200, sourceHashesMatch: true, uniqueSolution: true, simulatedWeChatTouch: true, undoRestartHint: true, storageRestore: true, foregroundLoop: true, nativeAudioPlaybackCalls: ui.played.length, plainLanguageRules: true, pagedRules: true, clearActionLayout: true, phoneSizesChecked: 4, rows };
|
|
fs.writeFileSync(path.join(root, 'validation.json'), JSON.stringify(result, null, 2));
|
|
console.log('PASS 200/200: r2源文件哈希一致、唯一解、微信发布包触摸通关、撤销/重开/指路、存档/音效设置恢复、前后台切换。');
|
|
|