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 entry=frames.entries().next().value;if(!entry)return;const [id,fn]=entry; frames.delete(id);text.length=0;images.length=0; 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),stepb.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;ab.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源文件哈希一致、唯一解、微信发布包触摸通关、撤销/重开/指路、存档/音效设置恢复、前后台切换。');