auu
This commit is contained in:
parent
d995726462
commit
0a343ecbc5
326 changed files with 15584 additions and 0 deletions
11
test/anomaly-smoke-test.js
Normal file
11
test/anomaly-smoke-test.js
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
'use strict';
|
||||
const {vm,assert,functionSource,loadBendPuzzle,loadAppLogic}=require('./helpers/app-source');
|
||||
const BendPuzzle=loadBendPuzzle(),AppLogic=loadAppLogic();
|
||||
const context={AppLogic,hash32:BendPuzzle.hash32,rngFrom:BendPuzzle.rngFrom,shuffle:BendPuzzle.shuffle,deepClone:value=>JSON.parse(JSON.stringify(value)),ckey:(r,c)=>`${r},${c}`,sameCell:(a,b)=>a[0]===b[0]&&a[1]===b[1],manhattan:(a,b)=>Math.abs(a[0]-b[0])+Math.abs(a[1]-b[1]),SIDE_D:{N:[-1,0],S:[1,0],W:[0,-1],E:[0,1]}};
|
||||
vm.createContext(context);
|
||||
vm.runInContext(['specialCellSet','warpMap','warpPairForCell','isWarpTransition','gateObj','outsidePoint','analyzeTurns','turnAnalysis','obstacleDetours','obstacleCellLimit','addObstaclePattern'].map(name=>functionSource(name)).join('\n')+'\nthis.logic={obstacleCellLimit,addObstaclePattern};',context);
|
||||
let obstacleCells=0,allCells=0,boards=0;for(let seed=1;seed<=240;seed++){let puzzle;try{puzzle=BendPuzzle.generatePuzzle([[0,0]],0x330000+seed,3,seed%7,-seed%5)}catch(_){continue}const changed=context.logic.addObstaclePattern(puzzle,0x550000+seed);obstacleCells+=(changed.obstacles||[]).length;allCells+=puzzle.valid.length;boards++}
|
||||
const obstacleRate=obstacleCells/allCells;assert(boards>200,'Too few obstacle samples');assert(obstacleRate>=.008&&obstacleRate<=.05,`Obstacle rate ${(obstacleRate*100).toFixed(2)}% is implausible`);
|
||||
let largeObstacleCells=0,largeAllCells=0,largeBoards=0;for(let seed=1;seed<=60;seed++){let puzzle;try{puzzle=BendPuzzle.generatePuzzle([[0,0],[1,0],[0,1],[1,1]],0x660000+seed,5,20,-20)}catch(_){continue}const changed=context.logic.addObstaclePattern(puzzle,0x770000+seed),totalCells=changed.valid.length+(changed.obstacles||[]).length;assert((changed.obstacles||[]).length<=Math.floor(totalCells*.2),'Obstacle generation exceeded 20% of the puzzle');largeObstacleCells+=(changed.obstacles||[]).length;largeAllCells+=totalCells;largeBoards++}
|
||||
const largeObstacleRate=largeObstacleCells/largeAllCells;assert(largeBoards>=45,'Too few large obstacle samples');assert(largeObstacleRate>obstacleRate+.005,`Large-puzzle obstacle rate ${(largeObstacleRate*100).toFixed(2)}% is not higher than small puzzles`);
|
||||
console.log(`Obstacle test passed: ${(obstacleRate*100).toFixed(2)}% small and ${(largeObstacleRate*100).toFixed(2)}% large obstacle cells`);
|
||||
110
test/browser-field-storage-benchmark.js
Normal file
110
test/browser-field-storage-benchmark.js
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
'use strict';
|
||||
|
||||
const fs=require('fs');
|
||||
const os=require('os');
|
||||
const path=require('path');
|
||||
const http=require('http');
|
||||
const crypto=require('crypto');
|
||||
const {spawn,spawnSync}=require('child_process');
|
||||
const {assert,root}=require('./helpers/app-source');
|
||||
|
||||
const browserPath=process.env.BEND_FIELD_BROWSER_PATH||process.env.BEND_FIELD_EDGE_PATH||(
|
||||
process.platform==='win32'?'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe':'/usr/bin/chromium'
|
||||
);
|
||||
const requested=(process.env.BEND_FIELD_SCALE_SIZES||'10000,100000,200000').split(',').map(Number).filter(value=>Number.isSafeInteger(value)&&value>0&&value<=200000);
|
||||
if(!requested.length)throw new Error('BEND_FIELD_SCALE_SIZES did not contain a supported board count.');
|
||||
const worldDbName='bend-field:v30:v47-field-reset-20260728-interaction-fix:world',explicitBenchmarkUrl=process.env.BEND_FIELD_BENCHMARK_URL||'',benchmarkHost=process.env.BEND_FIELD_BENCHMARK_HOST||'localhost',useExtension=process.env.BEND_FIELD_BENCHMARK_EXTENSION==='1';
|
||||
const debuggingPort=22000+Math.floor(Math.random()*1000),temporaryRoot=fs.mkdtempSync(path.join(os.tmpdir(),'bend-field-v2-scale-')),profilePath=path.join(temporaryRoot,'browser-profile');
|
||||
const sleep=milliseconds=>new Promise(resolve=>setTimeout(resolve,milliseconds));
|
||||
function stopBrowserTree(child){
|
||||
if(!child?.pid)return;
|
||||
if(process.platform==='win32'){
|
||||
const taskkill=path.join(process.env.SystemRoot||'C:\\Windows','System32','taskkill.exe'),
|
||||
result=spawnSync(taskkill,['/pid',String(child.pid),'/T','/F'],{stdio:'ignore',windowsHide:true});
|
||||
if(result.status!==0&&!child.killed)try{child.kill()}catch(_){}
|
||||
}else if(!child.killed)child.kill('SIGTERM');
|
||||
}
|
||||
|
||||
|
||||
function extensionIdFromPublicKey(der){
|
||||
const digest=crypto.createHash('sha256').update(der).digest().subarray(0,16);
|
||||
return[...digest].map(byte=>String.fromCharCode(97+(byte>>4),97+(byte&15))).join('');
|
||||
}
|
||||
function prepareBenchmarkExtension(){
|
||||
const directory=path.join(temporaryRoot,'extension');fs.mkdirSync(directory,{recursive:true});
|
||||
for(const name of['index.html','style.css','favicon.svg','favicon.ico','puzzle-core.js','app-logic.js','field-persistence.js','field-persistence-worker.js','app.js','puzzle-worker.js'])fs.copyFileSync(path.join(root,name),path.join(directory,name));
|
||||
const{publicKey}=crypto.generateKeyPairSync('rsa',{modulusLength:2048}),der=publicKey.export({type:'spki',format:'der'}),key=der.toString('base64'),id=extensionIdFromPublicKey(der);
|
||||
fs.writeFileSync(path.join(directory,'manifest.json'),JSON.stringify({manifest_version:3,name:'Bend Field V2 Storage Benchmark',version:'47.36.0',key,permissions:['unlimitedStorage']},null,2));
|
||||
return{directory,id,url:`chrome-extension://${id}/index.html`};
|
||||
}
|
||||
|
||||
class CdpClient{
|
||||
constructor(url){this.url=url;this.sequence=0;this.pending=new Map();this.socket=null}
|
||||
async connect(){
|
||||
this.socket=new WebSocket(this.url);
|
||||
await new Promise((resolve,reject)=>{const timer=setTimeout(()=>reject(new Error('CDP connection timed out')),15000);this.socket.addEventListener('open',()=>{clearTimeout(timer);resolve()},{once:true});this.socket.addEventListener('error',event=>{clearTimeout(timer);reject(event.error||new Error('CDP connection failed'))},{once:true})});
|
||||
this.socket.addEventListener('message',event=>{const message=JSON.parse(event.data);if(!message.id)return;const pending=this.pending.get(message.id);if(!pending)return;this.pending.delete(message.id);if(message.error)pending.reject(new Error(`${pending.method}: ${message.error.message}`));else pending.resolve(message.result)});
|
||||
}
|
||||
send(method,params={}){const id=++this.sequence;return new Promise((resolve,reject)=>{this.pending.set(id,{resolve,reject,method});this.socket.send(JSON.stringify({id,method,params}))})}
|
||||
async evaluate(expression){const response=await this.send('Runtime.evaluate',{expression,awaitPromise:true,returnByValue:true,userGesture:true});if(response.exceptionDetails)throw new Error(response.exceptionDetails.exception?.description||response.exceptionDetails.text||'Browser evaluation failed');return response.result.value}
|
||||
close(){try{this.socket?.close()}catch(_){}}
|
||||
}
|
||||
async function waitFor(check,{timeout=30000,interval=50,label='condition'}={}){const deadline=Date.now()+timeout;let lastError=null;while(Date.now()<deadline){try{const value=await check();if(value)return value}catch(error){lastError=error}await sleep(interval)}throw lastError||new Error(`Timed out waiting for ${label}`)}
|
||||
async function endpoint(){return waitFor(async()=>{const response=await fetch(`http://127.0.0.1:${debuggingPort}/json/list`,{signal:AbortSignal.timeout(1500)});if(!response.ok)return null;return(await response.json()).find(target=>target.type==='page'&&target.webSocketDebuggerUrl)},{timeout:20000,label:'browser DevTools endpoint'})}
|
||||
async function startServer(){
|
||||
const types={'.html':'text/html; charset=utf-8','.js':'text/javascript; charset=utf-8','.css':'text/css; charset=utf-8','.svg':'image/svg+xml','.ttf':'font/ttf','.ico':'image/x-icon'};
|
||||
const server=http.createServer((request,response)=>{const pathname=new URL(request.url,'http://localhost').pathname,relative=pathname==='/'?'index.html':decodeURIComponent(pathname.slice(1)),file=path.resolve(root,relative);if(file!==root&&!file.startsWith(root+path.sep)){response.writeHead(403);response.end();return}fs.readFile(file,(error,body)=>{if(error){response.writeHead(error.code==='ENOENT'?404:500);response.end();return}response.writeHead(200,{'content-type':types[path.extname(file)]||'application/octet-stream','cache-control':'no-store'});response.end(body)})});
|
||||
await new Promise((resolve,reject)=>{server.once('error',reject);server.listen(0,'127.0.0.1',()=>{server.off('error',reject);resolve()})});return server;
|
||||
}
|
||||
const transactionDoneSource=`tx=>new Promise((resolve,reject)=>{tx.oncomplete=resolve;tx.onerror=()=>reject(tx.error);tx.onabort=()=>reject(tx.error)})`;
|
||||
async function waitUntilReady(client,timeout=60000){return waitFor(()=>client.evaluate("document.body?.dataset?.ready==='true'&&globalThis.__bendStartupSnapshot"),{timeout,label:'game startup'})}
|
||||
async function waitForV2(client){
|
||||
return waitFor(()=>client.evaluate(`(async()=>{const db=await new Promise((resolve,reject)=>{const request=indexedDB.open(${JSON.stringify(worldDbName)});request.onsuccess=()=>resolve(request.result);request.onerror=()=>reject(request.error)});const tx=db.transaction('control','readonly'),request=tx.objectStore('control').get('active'),control=await new Promise((resolve,reject)=>{request.onsuccess=()=>resolve(request.result);request.onerror=()=>reject(request.error)});db.close();return control?.activeFormat===2&&control.activeEpoch})()`),{timeout:60000,label:'V2 storage migration'});
|
||||
}
|
||||
async function readStarter(client){
|
||||
return client.evaluate(`(async()=>{const done=${transactionDoneSource},read=request=>new Promise((resolve,reject)=>{request.onsuccess=()=>resolve(request.result);request.onerror=()=>reject(request.error)}),db=await new Promise((resolve,reject)=>{const request=indexedDB.open(${JSON.stringify(worldDbName)});request.onsuccess=()=>resolve(request.result);request.onerror=()=>reject(request.error)});let tx=db.transaction('control','readonly'),control=await read(tx.objectStore('control').get('active'));await done(tx);const epoch=control.activeEpoch;tx=db.transaction(['worlds','boardIndex','boardPuzzles','boardStates'],'readonly');const[world,index,puzzle,state]=await Promise.all([read(tx.objectStore('worlds').get(epoch)),read(tx.objectStore('boardIndex').get([epoch,'B0'])),read(tx.objectStore('boardPuzzles').get([epoch,'B0'])),read(tx.objectStore('boardStates').get([epoch,'B0']))]);await done(tx);db.close();return{control,world,index,puzzle,state}})()`);
|
||||
}
|
||||
async function seedV2(client,starter,count){
|
||||
const expression=`(async()=>{
|
||||
const starter=${JSON.stringify(starter)},count=${count},dbName=${JSON.stringify(worldDbName)},batchSize=4000,
|
||||
done=${transactionDoneSource},open=()=>new Promise((resolve,reject)=>{const request=indexedDB.open(dbName);request.onsuccess=()=>resolve(request.result);request.onerror=()=>reject(request.error)}),db=await open(),epoch=starter.control.activeEpoch;
|
||||
let tx=db.transaction(['boardIndex','boardPuzzles','boardStates','worlds'],'readwrite');tx.objectStore('boardIndex').clear();tx.objectStore('boardPuzzles').clear();tx.objectStore('boardStates').clear();tx.objectStore('worlds').clear();await done(tx);
|
||||
const puzzleValue=starter.puzzle.puzzle,stateValue=starter.state.value,started=performance.now();
|
||||
for(let offset=0;offset<count;offset+=batchSize){
|
||||
tx=db.transaction(['boardIndex','boardPuzzles','boardStates'],'readwrite');const indexStore=tx.objectStore('boardIndex'),puzzleStore=tx.objectStore('boardPuzzles'),stateStore=tx.objectStore('boardStates'),end=Math.min(count,offset+batchSize);
|
||||
for(let number=offset;number<end;number++){
|
||||
const id='B'+number,x=number%1000,y=Math.floor(number/1000),metaRev=1000000+number*2,stateRev=metaRev+1,author='scale-benchmark';
|
||||
indexStore.put({epoch,id,number,x,y,chunks:[[0,0]],level:1,targetLevel:1,seed:number+1,axis:starter.index.axis,entrySide:null,metaRev,stateRev,revAuthor:author,solved:false,expanded:false,scoreAwarded:0,hasProgress:false,specialFlags:{crossing:false,warp:false,lock:false},shop:null});
|
||||
puzzleStore.put({epoch,id,metaRev,revAuthor:author,generatorVersion:starter.puzzle.generatorVersion,puzzle:puzzleValue});
|
||||
stateStore.put({epoch,id,stateRev,revAuthor:author,value:{...stateValue,paths:[],specialProgress:{crossings:[]},solved:false,expanded:false,scoreAwarded:0,store:null,rev:stateRev,revAuthor:author}});
|
||||
}
|
||||
await done(tx);await new Promise(resolve=>setTimeout(resolve,0));
|
||||
}
|
||||
const global={...starter.world.global,worldEpoch:epoch,nextId:count,solved:0,score:0,globalRev:(starter.world.global.globalRev||0)+count+1,globalRevAuthor:'scale-benchmark',selectedBoardId:'B0',cameraAnchor:null,updatedAt:Date.now()},world={...starter.world,epoch,status:'active',global,boardCount:count,solvedCount:0,score:0,bounds:{minX:0,minY:0,maxX:Math.min(1000,count),maxY:Math.ceil(count/1000)},approximateBytes:count*2048,source:{kind:'benchmark'},progress:null};
|
||||
tx=db.transaction(['control','worlds'],'readwrite');tx.objectStore('worlds').put(world);tx.objectStore('control').put({...starter.control,key:'active',activeFormat:2,activeEpoch:epoch,previousEpoch:undefined,activationVerified:true});await done(tx);db.close();localStorage.clear();sessionStorage.clear();return{seedMs:performance.now()-started,count};
|
||||
})()`;
|
||||
const result=await client.evaluate(expression);assert(result?.count===count,`Only ${result?.count||0} of ${count} V2 boards were seeded`);return result;
|
||||
}
|
||||
async function measure(client,count){
|
||||
const navigationStarted=Date.now();await client.send('Page.reload',{ignoreCache:true});const startup=await waitUntilReady(client,180000),readyMs=Date.now()-navigationStarted;
|
||||
const snapshot=await client.evaluate(`(()=>({capture:globalThis.__bendStartupSnapshot,ready:document.body.dataset.ready,loaded:fieldIndexLoadedCount,expected:fieldIndexExpectedCount,complete:fieldIndexComplete,b0:Boolean(data?.metas?.B0?.puzzle),navigation:performance.getEntriesByType('navigation')[0]?.duration||0,memory:performance.memory?.usedJSHeapSize||null}))()`);
|
||||
assert(snapshot.ready==='true'&&snapshot.b0,`${count} board V2 startup did not hydrate B0`);assert(snapshot.capture.expected===count,`${count} board V2 startup declared ${snapshot.capture.expected} boards`);assert(snapshot.capture.loaded<=512,`${count} board V2 startup eagerly loaded ${snapshot.capture.loaded} board indexes`);assert(snapshot.capture.complete===false,`${count} board V2 startup completed the full index scan before first paint`);
|
||||
const scanStarted=Date.now();await waitFor(()=>client.evaluate('fieldIndexComplete===true&&fieldIndexLoadedCount===fieldIndexExpectedCount'),{timeout:600000,interval:100,label:`${count} board index scan`});const scanMs=Date.now()-scanStarted;
|
||||
const final=await client.evaluate(`({loaded:fieldIndexLoadedCount,expected:fieldIndexExpectedCount,metas:Object.keys(data.metas).length,memory:performance.memory?.usedJSHeapSize||null})`);assert(final.loaded===count&&final.metas===count,`${count} board V2 background index scan was incomplete`);
|
||||
return{boards:count,readyMs,navigationMs:snapshot.navigation,capturedReadyMs:snapshot.capture.at,initialIndexes:snapshot.capture.loaded,scanMs,heapAtReady:snapshot.memory,heapAfterScan:final.memory};
|
||||
}
|
||||
async function main(){
|
||||
if(!fs.existsSync(browserPath))throw new Error(`Browser was not found at ${browserPath}`);
|
||||
const extension=useExtension?prepareBenchmarkExtension():null;console.log(`Launching ${browserPath}`);const server=await startServer(),port=server.address().port,args=['--headless','--no-first-run','--no-sandbox','--enable-unsafe-swiftshader','--allow-file-access-from-files','--disable-dev-shm-usage','--disable-background-networking',`--remote-debugging-port=${debuggingPort}`,'--remote-allow-origins=*',`--user-data-dir=${profilePath}`,'--window-size=1280,900','about:blank'];if(extension){args.splice(-1,0,`--disable-extensions-except=${extension.directory}`,`--load-extension=${extension.directory}`)}const browser=spawn(browserPath,args,{stdio:'ignore'});let client=null;
|
||||
try{
|
||||
console.log('Waiting for DevTools');const target=await endpoint();console.log('Connecting to page');client=new CdpClient(target.webSocketDebuggerUrl);await client.connect();await client.send('Page.enable');await client.send('Runtime.enable');
|
||||
await client.send('Page.addScriptToEvaluateOnNewDocument',{source:`(()=>{const capture=()=>{if(document.body?.dataset?.ready==='true'&&!globalThis.__bendStartupSnapshot)globalThis.__bendStartupSnapshot={at:performance.now(),loaded:typeof fieldIndexLoadedCount==='number'?fieldIndexLoadedCount:null,expected:typeof fieldIndexExpectedCount==='number'?fieldIndexExpectedCount:null,complete:typeof fieldIndexComplete==='boolean'?fieldIndexComplete:null};};const observe=()=>{if(document.documentElement)new MutationObserver(capture).observe(document.documentElement,{subtree:true,attributes:true,attributeFilter:['data-ready']});capture();};if(document.documentElement)observe();else addEventListener('DOMContentLoaded',observe,{once:true})})();`});
|
||||
const benchmarkUrl=explicitBenchmarkUrl||(extension?extension.url:`http://${benchmarkHost}:${port}/`);console.log(`Navigating to ${benchmarkUrl}`);await client.send('Page.navigate',{url:benchmarkUrl});try{await waitUntilReady(client,30000)}catch(error){const state=await client.evaluate("({href:location.href,ready:document.body?.dataset?.ready,error:document.body?.dataset?.error,status:document.querySelector('#statusMessage')?.textContent||'',text:document.body?.innerText?.slice(0,800)||'',scripts:[...document.scripts].map(s=>s.src)})").catch(()=>null);throw new Error(`${error.message}: ${JSON.stringify(state)}`)}console.log('Game ready; waiting for V2');await waitForV2(client);console.log('V2 active; reading starter');const starter=await readStarter(client);assert(starter?.index?.id==='B0'&&starter?.puzzle?.puzzle&&starter?.state?.value,'Could not read the V2 starter board');
|
||||
const results=[];for(const count of requested){console.log(`Seeding ${count.toLocaleString()} boards`);const seeded=await seedV2(client,starter,count),measured=await measure(client,count),result={...measured,seedMs:seeded.seedMs};results.push(result);console.log(`${count.toLocaleString()} boards | seed ${(seeded.seedMs/1000).toFixed(1)} s | ready ${measured.readyMs} ms (${measured.initialIndexes} indexes) | full scan ${(measured.scanMs/1000).toFixed(1)} s`)}
|
||||
console.log(`FIELD_STORAGE_BENCHMARK_JSON=${JSON.stringify(results)}`);console.log('V2 field storage scale benchmark passed');
|
||||
}finally{
|
||||
client?.close();stopBrowserTree(browser);
|
||||
server.closeAllConnections?.();await new Promise(resolve=>server.close(()=>resolve()));for(let attempt=0;attempt<8;attempt++){try{fs.rmSync(temporaryRoot,{recursive:true,force:true});break}catch(error){if(attempt===7)console.warn(`Benchmark cleanup deferred: ${error.message}`);await sleep(200)}}
|
||||
}
|
||||
}
|
||||
main().then(()=>process.exit(0)).catch(error=>{console.error(error);process.exit(1)});
|
||||
635
test/browser-performance-benchmark.js
Normal file
635
test/browser-performance-benchmark.js
Normal file
|
|
@ -0,0 +1,635 @@
|
|||
'use strict';
|
||||
|
||||
const fs=require('fs');
|
||||
const os=require('os');
|
||||
const path=require('path');
|
||||
const http=require('http');
|
||||
const {spawn,spawnSync}=require('child_process');
|
||||
const {assert,root}=require('./helpers/app-source');
|
||||
|
||||
const edgePath=process.env.BEND_FIELD_EDGE_PATH||'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe';
|
||||
const sleep=milliseconds=>new Promise(resolve=>setTimeout(resolve,milliseconds));
|
||||
let serverPort=0;
|
||||
const debuggingPort=20000+Math.floor(Math.random()*1000),benchmarkHost=process.env.BEND_FIELD_BENCHMARK_HOST||'localhost';
|
||||
const startupOnly=process.env.BEND_FIELD_STARTUP_ONLY==='1';
|
||||
const temporaryRoot=fs.mkdtempSync(path.join(os.tmpdir(),'bend-field-browser-benchmark-'));
|
||||
const profilePath=path.join(temporaryRoot,'edge-profile');
|
||||
const worldDbName='bend-field:v30:v47-field-reset-20260728-interaction-fix:world';
|
||||
const allProfiles=[
|
||||
{name:'small',boards:16,mode:'grid'},
|
||||
{name:'medium',boards:128,mode:'grid'},
|
||||
{name:'large',boards:512,mode:'grid'},
|
||||
{name:'complex',boards:16,mode:'complex',chunks:10},
|
||||
{name:'long-line',boards:256,mode:'line'}
|
||||
];
|
||||
const requestedProfile=process.env.BEND_FIELD_BENCHMARK_PROFILE||'',profiles=requestedProfile?allProfiles.filter(profile=>profile.name===requestedProfile):allProfiles;
|
||||
if(requestedProfile&&!profiles.length)throw new Error(`Unknown benchmark profile: ${requestedProfile}`);
|
||||
function stopBrowserTree(child){
|
||||
if(!child?.pid)return;
|
||||
if(process.platform==='win32'){
|
||||
const taskkill=path.join(process.env.SystemRoot||'C:\\Windows','System32','taskkill.exe'),
|
||||
result=spawnSync(taskkill,['/pid',String(child.pid),'/T','/F'],{stdio:'ignore',windowsHide:true});
|
||||
if(result.status!==0&&!child.killed)try{child.kill()}catch(_){}
|
||||
}else if(!child.killed)child.kill('SIGTERM');
|
||||
}
|
||||
|
||||
class CdpClient{
|
||||
constructor(url){this.url=url;this.sequence=0;this.pending=new Map();this.events=[];this.socket=null}
|
||||
async connect(){
|
||||
this.socket=new WebSocket(this.url);
|
||||
await new Promise((resolve,reject)=>{
|
||||
const timer=setTimeout(()=>reject(new Error('CDP WebSocket connection timed out')),10000);
|
||||
this.socket.addEventListener('open',()=>{clearTimeout(timer);resolve()},{once:true});
|
||||
this.socket.addEventListener('error',event=>{clearTimeout(timer);reject(event.error||new Error('CDP WebSocket failed'))},{once:true});
|
||||
});
|
||||
this.socket.addEventListener('message',event=>{
|
||||
const message=JSON.parse(event.data);
|
||||
if(!message.id){this.events.push(message);if(this.events.length>200)this.events.shift();return}
|
||||
const pending=this.pending.get(message.id);if(!pending)return;this.pending.delete(message.id);
|
||||
if(message.error)pending.reject(new Error(`${pending.method}: ${message.error.message}`));
|
||||
else pending.resolve(message.result);
|
||||
});
|
||||
}
|
||||
send(method,params={}){
|
||||
const id=++this.sequence;
|
||||
return new Promise((resolve,reject)=>{
|
||||
this.pending.set(id,{resolve,reject,method});
|
||||
this.socket.send(JSON.stringify({id,method,params}));
|
||||
});
|
||||
}
|
||||
async evaluate(expression){
|
||||
const response=await this.send('Runtime.evaluate',{expression,awaitPromise:true,returnByValue:true,userGesture:true});
|
||||
if(response.exceptionDetails)throw new Error(response.exceptionDetails.exception?.description||response.exceptionDetails.text||'Browser evaluation failed');
|
||||
return response.result.value;
|
||||
}
|
||||
close(){try{this.socket?.close()}catch(_){}}
|
||||
}
|
||||
|
||||
async function waitFor(check,{timeout=20000,interval=100,label='condition'}={}){
|
||||
const deadline=Date.now()+timeout;let lastError=null;
|
||||
while(Date.now()<deadline){
|
||||
try{const value=await check();if(value)return value}catch(error){lastError=error}
|
||||
await sleep(interval);
|
||||
}
|
||||
throw lastError||new Error(`Timed out waiting for ${label}`);
|
||||
}
|
||||
|
||||
async function endpoint(){
|
||||
return waitFor(async()=>{
|
||||
const response=await fetch(`http://127.0.0.1:${debuggingPort}/json/list`,{signal:AbortSignal.timeout(1500)});
|
||||
if(!response.ok)return null;
|
||||
const targets=await response.json();
|
||||
return targets.find(target=>target.type==='page'&&target.webSocketDebuggerUrl);
|
||||
},{label:'Edge DevTools endpoint'});
|
||||
}
|
||||
|
||||
async function startStaticServer(){
|
||||
const types={'.html':'text/html; charset=utf-8','.js':'text/javascript; charset=utf-8','.css':'text/css; charset=utf-8','.svg':'image/svg+xml','.ttf':'font/ttf','.ico':'image/x-icon'};
|
||||
const server=http.createServer((request,response)=>{
|
||||
const pathname=new URL(request.url,'http://localhost').pathname;
|
||||
const relative=pathname==='/'?'index.html':decodeURIComponent(pathname.slice(1));
|
||||
const file=path.resolve(root,relative);
|
||||
if(file!==root&&!file.startsWith(root+path.sep)){response.writeHead(403);response.end();return}
|
||||
fs.readFile(file,(error,body)=>{
|
||||
if(error){response.writeHead(error.code==='ENOENT'?404:500);response.end();return}
|
||||
response.writeHead(200,{'content-type':types[path.extname(file)]||'application/octet-stream','cache-control':'no-store'});
|
||||
response.end(body);
|
||||
});
|
||||
});
|
||||
await new Promise((resolve,reject)=>{
|
||||
server.once('error',reject);server.listen(0,'127.0.0.1',()=>{server.off('error',reject);resolve()});
|
||||
});
|
||||
serverPort=server.address().port;
|
||||
return server;
|
||||
}
|
||||
|
||||
async function ready(client){
|
||||
try{
|
||||
await waitFor(()=>client.evaluate("document.body?.dataset?.ready==='true'&&Boolean(globalThis.BEND_PERF)"),
|
||||
{timeout:30000,label:'game startup'});
|
||||
}catch(error){
|
||||
const state=await client.evaluate("({href:location.href,ready:document.body?.dataset?.ready,hasPerf:Boolean(globalThis.BEND_PERF),status:document.querySelector('#statusMessage')?.textContent||'',text:document.body?.innerText?.slice(0,500)||''})").catch(()=>null);
|
||||
const exception=client.events.findLast?.(event=>event.method==='Runtime.exceptionThrown')?.params?.exceptionDetails;
|
||||
const consoleError=client.events.findLast?.(event=>event.method==='Runtime.consoleAPICalled'&&event.params?.type==='error')?.params;
|
||||
const consoleText=consoleError?.args?.map(argument=>argument.description||argument.value||'').join(' ')||'';
|
||||
throw new Error(`${error.message}: ${JSON.stringify(state)} ${exception?.exception?.description||exception?.text||consoleText}`);
|
||||
}
|
||||
await client.evaluate("document.querySelector('#normalPlayBtn')?.click();true");
|
||||
await sleep(350);
|
||||
}
|
||||
|
||||
async function readStarterRows(client){
|
||||
const expression=`(async()=>{
|
||||
const db=await new Promise((resolve,reject)=>{const request=indexedDB.open(${JSON.stringify(worldDbName)});request.onsuccess=()=>resolve(request.result);request.onerror=()=>reject(request.error)});
|
||||
const read=request=>new Promise((resolve,reject)=>{request.onsuccess=()=>resolve(request.result);request.onerror=()=>reject(request.error)});
|
||||
let tx=db.transaction('control','readonly'),control=await read(tx.objectStore('control').get('active'));
|
||||
await new Promise((resolve,reject)=>{tx.oncomplete=resolve;tx.onerror=()=>reject(tx.error);tx.onabort=()=>reject(tx.error)});
|
||||
const epoch=control?.activeEpoch;tx=db.transaction(['worlds','boardIndex','boardPuzzles','boardStates'],'readonly');
|
||||
const [world,index,puzzle,state]=await Promise.all([
|
||||
read(tx.objectStore('worlds').get(epoch)),read(tx.objectStore('boardIndex').get([epoch,'B0'])),
|
||||
read(tx.objectStore('boardPuzzles').get([epoch,'B0'])),read(tx.objectStore('boardStates').get([epoch,'B0']))
|
||||
]);
|
||||
await new Promise((resolve,reject)=>{tx.oncomplete=resolve;tx.onerror=()=>reject(tx.error);tx.onabort=()=>reject(tx.error)});
|
||||
db.close();return{control,world,index,puzzle,state};
|
||||
})()`;
|
||||
return waitFor(async()=>{
|
||||
const rows=await client.evaluate(expression);
|
||||
return rows?.control?.activeFormat===2&&rows?.world?.global&&rows?.index&&rows?.puzzle?.puzzle&&rows?.state?.value?rows:null;
|
||||
},{timeout:20000,label:'starter IndexedDB rows'});
|
||||
}
|
||||
|
||||
async function populateWorld(client,starter,profile){
|
||||
const expression=`(async()=>{
|
||||
const starter=${JSON.stringify(starter)},profile=${JSON.stringify(profile)},count=profile.boards;
|
||||
const makeComplexPuzzle=(source,chunkCount)=>{
|
||||
const puzzle=structuredClone(source),columns=5,g=[],n=[],valid=[],solution=[];
|
||||
for(let chunk=0;chunk<chunkCount;chunk++){
|
||||
const rowOffset=Math.floor(chunk/columns)*5,columnOffset=(chunk%columns)*5,gateOffset=g.length;
|
||||
for(const gate of source.g)g.push([gate[0]+rowOffset,gate[1]+columnOffset,gate[2]]);
|
||||
for(const number of source.n||[])n.push([number[0]+rowOffset,number[1]+columnOffset,number[2]]);
|
||||
for(const cell of source.valid||[])valid.push([cell[0]+rowOffset,cell[1]+columnOffset]);
|
||||
for(const path of source.solution||[])solution.push({...structuredClone(path),startGate:path.startGate+gateOffset,endGate:path.endGate+gateOffset,cells:path.cells.map(cell=>[cell[0]+rowOffset,cell[1]+columnOffset])});
|
||||
}
|
||||
puzzle.g=g;puzzle.n=n;puzzle.valid=valid;puzzle.solution=solution;puzzle.bounds={w:Math.min(columns,chunkCount)*5,h:Math.ceil(chunkCount/columns)*5};puzzle.level=10;puzzle.difficulty=10;puzzle.maxTurns=Math.max(...solution.map(path=>path.cells.length));puzzle.totalTurns=solution.reduce((sum,path)=>sum+path.cells.length,0);return puzzle;
|
||||
};
|
||||
const starterPuzzle=starter.puzzle.puzzle,starterState=starter.state.value,
|
||||
complexPuzzle=profile.mode==='complex'?makeComplexPuzzle(starterPuzzle,profile.chunks||10):null,
|
||||
complexChunks=profile.mode==='complex'?Array.from({length:profile.chunks||10},(_,index)=>[index%5,Math.floor(index/5)]):null;
|
||||
const db=await new Promise((resolve,reject)=>{const request=indexedDB.open(${JSON.stringify(worldDbName)});request.onsuccess=()=>resolve(request.result);request.onerror=()=>reject(request.error)});
|
||||
const tx=db.transaction(['control','worlds','boardIndex','boardPuzzles','boardStates','outboxV2','recoveryV2','tombstonesV2'],'readwrite'),
|
||||
indexStore=tx.objectStore('boardIndex'),puzzleStore=tx.objectStore('boardPuzzles'),stateStore=tx.objectStore('boardStates');
|
||||
for(const name of ['worlds','boardIndex','boardPuzzles','boardStates','outboxV2','recoveryV2','tombstonesV2'])tx.objectStore(name).clear();
|
||||
const columns=Math.ceil(Math.sqrt(count)),spacingX=profile.mode==='complex'?7:2,spacingY=profile.mode==='complex'?4:2;
|
||||
for(let index=0;index<count;index++){
|
||||
const id='B'+index,x=profile.mode==='line'?index:(index%columns)*spacingX,y=profile.mode==='line'?0:Math.floor(index/columns)*spacingY,
|
||||
metaRev=100000+index,stateRev=200000+index,author='benchmark',puzzle=profile.mode==='complex'?structuredClone(complexPuzzle):structuredClone(starterPuzzle),
|
||||
chunks=profile.mode==='complex'?structuredClone(complexChunks):structuredClone(starter.index.chunks),state=structuredClone(starterState);
|
||||
state.paths=profile.mode==='line'&&index>0?[{startGate:0,endGate:3,openGate:null,cells:[[0,0],[0,1],[0,2],[0,3],[0,4]],colorIndex:0,startColorIndex:0,endColorIndex:0}]:[];
|
||||
state.solved=false;state.specialProgress={crossings:[]};state.rev=stateRev;state.revAuthor=author;
|
||||
indexStore.put({epoch:starter.control.activeEpoch,id,number:index,x,y,chunks,level:profile.mode==='complex'?10:starter.index.level,targetLevel:profile.mode==='complex'?10:starter.index.targetLevel,seed:index+1,axis:starter.index.axis,entrySide:null,sealedSides:[],metaRev,stateRev,metaRevAuthor:author,stateRevAuthor:author,revAuthor:author,solved:false,expanded:false,scoreAwarded:0,hasProgress:state.paths.length>0,specialFlags:{crossing:false,warp:false,lock:false},shop:null});
|
||||
puzzleStore.put({epoch:starter.control.activeEpoch,id,metaRev,revAuthor:author,generatorVersion:starter.puzzle.generatorVersion,puzzle});
|
||||
stateStore.put({epoch:starter.control.activeEpoch,id,stateRev,revAuthor:author,value:state});
|
||||
}
|
||||
const current=structuredClone(starter.world.global),epoch=starter.control.activeEpoch;
|
||||
current.worldEpoch=epoch;current.nextId=count;current.solved=0;current.score=0;current.bonusScore=0;current.bonusEvents={};
|
||||
current.globalRev=300000+count;current.globalRevAuthor='benchmark';current.updatedAt=Date.now();current.clockFloor=Date.now();
|
||||
const world={...starter.world,epoch,status:'active',global:current,boardCount:count,solvedCount:0,score:0,bounds:{minX:0,minY:0,maxX:profile.mode==='line'?count:columns*spacingX,maxY:profile.mode==='line'?1:Math.ceil(count/columns)*spacingY},source:{kind:'benchmark'},progress:null};
|
||||
tx.objectStore('worlds').put(world);tx.objectStore('control').put({key:'active',activeFormat:2,activeEpoch:epoch,activationVerified:true});
|
||||
await new Promise((resolve,reject)=>{tx.oncomplete=resolve;tx.onerror=()=>reject(tx.error);tx.onabort=()=>reject(tx.error)});
|
||||
db.close();localStorage.clear();sessionStorage.clear();return{count,solution:profile.mode==='complex'?complexPuzzle.solution:starterPuzzle.solution};
|
||||
})()`;
|
||||
const result=await client.evaluate(expression);assert(result?.count===profile.boards,'Synthetic benchmark world was not written completely');return result.solution;
|
||||
}
|
||||
|
||||
async function reload(client){
|
||||
await client.evaluate("document.body.dataset.ready='reloading';true");
|
||||
await client.send('Page.reload',{ignoreCache:true});
|
||||
await ready(client);
|
||||
await client.evaluate("centerMeta(data.metas.B0);ensureBoards();renderAll();true");
|
||||
await waitFor(()=>client.evaluate("Boolean(document.querySelector('.board-card[data-id=\"B0\"]:not(.board-static)'))"),
|
||||
{timeout:15000,label:'interactive origin board'});
|
||||
await sleep(500);
|
||||
}
|
||||
async function waitForPersistenceIdle(client){
|
||||
await client.evaluate("(async()=>{if(hasPendingPersistence())await persistNow({skipCloud:true});return true})()");
|
||||
await waitFor(()=>client.evaluate("!hasPendingPersistence()&&!saveTimer"),{timeout:20000,label:'benchmark persistence idle'});
|
||||
await client.evaluate("persistQueue.then(()=>true)");
|
||||
}
|
||||
async function prepareInteractionMeasurement(client){
|
||||
await waitForPersistenceIdle(client);
|
||||
await client.evaluate("globalThis.gc?.();true");
|
||||
await sleep(120);
|
||||
await client.evaluate('BEND_PERF.reset();true');
|
||||
}
|
||||
async function restoreOriginState(client,state){
|
||||
const restored=await client.evaluate(`(async()=>{
|
||||
const board=rendered.get('B0'),snapshot=${JSON.stringify(state)},nextFrame=()=>new Promise(resolve=>requestAnimationFrame(()=>resolve())),
|
||||
applySnapshot=()=>{
|
||||
if(board){cancelBoardDragFrame(board);clearDragRender(board);board.drawing=null;board.armedGate=null;if(board.pendingClaimPointer)clearPendingClaimPointer(board,board.pendingClaimPointer.pointerId)}
|
||||
realtimeHeldPointers.clear();const current=metaState('B0');replaceObjectContents(current,deepClone(snapshot));normalizedStateObjects.add(current);return current;
|
||||
};
|
||||
applySnapshot();refreshInteractionState();await nextFrame();await new Promise(resolve=>setTimeout(resolve,80));
|
||||
const settledPathCount=metaState('B0').paths.length;applySnapshot();changed('B0');
|
||||
if(board)renderBoardNow(board);refreshInteractionState();centerMeta(data.metas.B0,{select:false});await nextFrame();await persistNow({skipCloud:true});
|
||||
return{pathCount:metaState('B0').paths.length,settledPathCount,solved:metaState('B0').solved,drawing:Boolean(board?.drawing),pending:Boolean(board?.pendingClaimPointer)};
|
||||
})()`);
|
||||
assert(restored.pathCount===(state.paths||[]).length&&restored.solved===Boolean(state.solved)&&!restored.drawing&&!restored.pending,`Canonical origin restore failed: ${JSON.stringify(restored)}`);
|
||||
}
|
||||
|
||||
async function pointFor(client,selector){
|
||||
const point=await client.evaluate(`(()=>{const element=document.querySelector(${JSON.stringify(selector)});if(!element)return null;const rect=element.getBoundingClientRect();return{x:rect.left+rect.width/2,y:rect.top+rect.height/2}})()`);
|
||||
if(!point)throw new Error(`Missing benchmark target: ${selector}`);
|
||||
return point;
|
||||
}
|
||||
async function pointForCell(client,boardId,row,column){
|
||||
const point=await client.evaluate(`(()=>{const board=rendered.get(${JSON.stringify(boardId)});if(!board)return null;const rect=board.svg.getBoundingClientRect(),vb=board.svg.viewBox.baseVal,x=PAD+(${column}+.5)*CELL,y=PAD+(${row}+.5)*CELL;return{x:rect.left+x*rect.width/vb.width,y:rect.top+y*rect.height/vb.height}})()`);
|
||||
if(!point)throw new Error(`Missing benchmark board cell: ${boardId} ${row},${column}`);
|
||||
return point;
|
||||
}
|
||||
async function pointsForCells(client,boardId,cells){
|
||||
const points=await client.evaluate(`(()=>{const board=rendered.get(${JSON.stringify(boardId)}),cells=${JSON.stringify(cells)};if(!board)return null;const rect=board.svg.getBoundingClientRect(),vb=board.svg.viewBox.baseVal;return cells.map(([row,column])=>{const x=PAD+(column+.5)*CELL,y=PAD+(row+.5)*CELL;return{x:rect.left+x*rect.width/vb.width,y:rect.top+y*rect.height/vb.height}})})()`);
|
||||
if(!points)throw new Error(`Missing benchmark board cells: ${boardId}`);
|
||||
return points;
|
||||
}
|
||||
|
||||
async function safePanPoint(client,selector){
|
||||
const point=await client.evaluate(`(()=>{const card=document.querySelector(${JSON.stringify(selector)}),view=document.querySelector('#viewport')?.getBoundingClientRect(),rect=card?.getBoundingClientRect();if(!card||!view||!rect)return null;for(const fy of[.18,.32,.5,.68,.82])for(const fx of[.18,.32,.5,.68,.82]){const x=rect.left+rect.width*fx,y=rect.top+rect.height*fy;if(x<view.left||x>view.right||y<view.top||y>view.bottom)continue;const target=document.elementFromPoint(x,y);if(target?.closest?.(${JSON.stringify(selector)})&&!target.closest?.('button,a,input,select,textarea,[role="button"]'))return{x,y}}return null})()`);
|
||||
return point||pointFor(client,selector);
|
||||
}
|
||||
|
||||
async function solveOrigin(client,solution,cpuRate=1){
|
||||
for(const pathRow of solution){
|
||||
const start=await pointFor(client,`.board-card[data-id="B0"] .gate-hit[data-gate="${pathRow.startGate}"]`),
|
||||
end=await pointFor(client,`.board-card[data-id="B0"] .gate-hit[data-gate="${pathRow.endGate}"]`);
|
||||
const cellPoints=await pointsForCells(client,'B0',pathRow.cells);
|
||||
await client.send('Input.dispatchMouseEvent',{type:'mousePressed',x:start.x,y:start.y,button:'left',buttons:1,clickCount:1});
|
||||
await sleep(20);
|
||||
for(const point of cellPoints){
|
||||
await client.send('Input.dispatchMouseEvent',{type:'mouseMoved',x:point.x,y:point.y,button:'left',buttons:1});
|
||||
await sleep(cpuRate===1?8:16);
|
||||
}
|
||||
await client.send('Input.dispatchMouseEvent',{type:'mouseMoved',x:end.x,y:end.y,button:'left',buttons:1});
|
||||
await sleep(25*Math.max(1,cpuRate));
|
||||
await client.send('Input.dispatchMouseEvent',{type:'mouseReleased',x:end.x,y:end.y,button:'left',buttons:0,clickCount:1});
|
||||
await waitFor(()=>client.evaluate("!rendered.get('B0')?.drawing"),{timeout:3000*Math.max(1,cpuRate),label:`origin path ${pathRow.startGate}-${pathRow.endGate} release`});
|
||||
await sleep(45*Math.max(1,cpuRate));
|
||||
}
|
||||
}
|
||||
|
||||
async function viewportCenter(client){
|
||||
return client.evaluate("(()=>{const r=document.querySelector('#viewport').getBoundingClientRect();return{x:r.left+r.width/2,y:r.top+r.height/2}})()");
|
||||
}
|
||||
|
||||
async function pan(client,steps=24,{button='right',selector=null,startPoint=null}={}){
|
||||
const center=await viewportCenter(client),start=startPoint||(selector?await safePanPoint(client,selector):{x:center.x+180,y:center.y+80}),buttons=button==='left'?1:2;
|
||||
await client.send('Input.dispatchMouseEvent',{type:'mousePressed',x:start.x,y:start.y,button,buttons,clickCount:1});
|
||||
for(let index=1;index<=steps;index++){
|
||||
await client.send('Input.dispatchMouseEvent',{
|
||||
type:'mouseMoved',x:start.x-index*9,y:start.y-index*4,button:'none',buttons
|
||||
});
|
||||
await sleep(12);
|
||||
}
|
||||
await client.send('Input.dispatchMouseEvent',{type:'mouseReleased',x:start.x-steps*9,y:start.y-steps*4,button,buttons:0,clickCount:1});
|
||||
await sleep(250);
|
||||
return start;
|
||||
}
|
||||
async function panCadenceProbe(client,steps=24){
|
||||
return client.evaluate(`new Promise(resolve=>{
|
||||
const viewport=document.querySelector('#viewport'),rect=viewport.getBoundingClientRect(),pointerId=91,startX=rect.left+rect.width*.7,startY=rect.top+rect.height*.6;
|
||||
const [worldX,worldY]=worldUnitAtClient(startX,startY),reactionNow=Date.now(),remoteId='benchmark-remote';
|
||||
applyRemotePlayer({presenceId:remoteId,playerId:'benchmark',name:'Remote',cursorStyle:'default',x:worldX,y:worldY,vx:.08,vy:.03,sentAt:reactionNow});
|
||||
applyRealtimeReaction({id:'benchmark-reaction',emoji:REACTION_EMOJIS[0],x:worldX,y:worldY,createdAt:reactionNow,expiresAt:reactionNow+Math.max(3000,${steps}*25)});
|
||||
const dispatch=(type,index,buttons)=>viewport.dispatchEvent(new PointerEvent(type,{bubbles:true,cancelable:true,pointerId,pointerType:'mouse',isPrimary:true,button:type==='pointerdown'||type==='pointerup'?2:-1,buttons,clientX:startX-index*7,clientY:startY-index*3}));
|
||||
dispatch('pointerdown',0,2);let index=0;
|
||||
const tick=()=>{index++;dispatch('pointermove',index,2);if(index===10)queueWorldSignal({sessionId:'benchmark-cross-tab',commitId:'benchmark:'+Date.now(),worldEpoch:data.worldEpoch,stateIds:['B1']});if(index<${steps})requestAnimationFrame(tick);else{dispatch('pointerup',index,0);setTimeout(()=>Promise.resolve(syncQueue).finally(()=>{remotePlayers.delete(remoteId);realtimeReactions.delete('benchmark-reaction');resolve(index)}),80)}};
|
||||
requestAnimationFrame(tick);
|
||||
})`);
|
||||
}
|
||||
async function pinchZoomProbe(client){
|
||||
return client.evaluate(`new Promise(resolve=>{
|
||||
const viewport=document.querySelector('#viewport'),rect=viewport.getBoundingClientRect(),cx=rect.left+rect.width/2,cy=rect.top+rect.height/2,before=cam.scale;
|
||||
const dispatch=(type,pointerId,x)=>viewport.dispatchEvent(new PointerEvent(type,{bubbles:true,cancelable:true,pointerId,pointerType:'touch',isPrimary:pointerId===111,button:type==='pointerdown'?0:-1,buttons:type==='pointerup'?0:1,clientX:x,clientY:cy}));
|
||||
dispatch('pointerdown',111,cx-45);dispatch('pointerdown',112,cx+45);let frame=0;
|
||||
const tick=()=>{frame++;dispatch('pointermove',111,cx-45-frame*5);dispatch('pointermove',112,cx+45+frame*5);if(frame<8)requestAnimationFrame(tick);else{dispatch('pointerup',111,cx-85);dispatch('pointerup',112,cx+85);setTimeout(()=>resolve({before,after:cam.scale,changed:Math.abs(cam.scale-before)>.01}),100)}};
|
||||
requestAnimationFrame(tick);
|
||||
})`);
|
||||
}
|
||||
async function pickupEdgePanProbe(client,pathRow){
|
||||
const originalState=await client.evaluate("deepClone(metaState('B0'))"),start=await pointFor(client,`.board-card[data-id="B0"] .gate-hit[data-gate="${pathRow.startGate}"]`),
|
||||
edge=await client.evaluate("(()=>{const r=document.querySelector('#viewport').getBoundingClientRect();return{x:r.right-2,y:r.top+r.height/2}})()"),
|
||||
before=await client.evaluate("({x:cam.x,y:cam.y,frames:BEND_PERF.snapshot().counters.dragFrames||0})");
|
||||
await client.send('Input.dispatchMouseEvent',{type:'mousePressed',x:start.x,y:start.y,button:'left',buttons:1,clickCount:1});
|
||||
try{
|
||||
await waitFor(()=>client.evaluate("Boolean(rendered.get('B0')?.drawing)"),{timeout:5000,label:'pickup edge-pan activation'});
|
||||
await client.send('Input.dispatchMouseEvent',{type:'mouseMoved',x:edge.x,y:edge.y,button:'left',buttons:1});await sleep(350);
|
||||
const after=await client.evaluate("({x:cam.x,y:cam.y,frames:BEND_PERF.snapshot().counters.dragFrames||0})");
|
||||
return{cameraMoved:Math.hypot(after.x-before.x,after.y-before.y)>5,dragFrames:after.frames-before.frames};
|
||||
}finally{
|
||||
await client.send('Input.dispatchMouseEvent',{type:'mouseReleased',x:edge.x,y:edge.y,button:'left',buttons:0,clickCount:1}).catch(()=>{});
|
||||
await client.evaluate(`(()=>{data.states.B0=${JSON.stringify(originalState)};normalizedStateObjects.add(data.states.B0);markStateDirty('B0');renderBoardNow(rendered.get('B0'));return true})()`);
|
||||
await client.evaluate("(async()=>{await persistNow({skipCloud:true});return true})()");
|
||||
}
|
||||
}
|
||||
async function pickupCadenceProbe(client,pathRow,steps=120,sampleDelay=8){
|
||||
const start=await pointFor(client,`.board-card[data-id="B0"] .gate-hit[data-gate="${pathRow.startGate}"]`),
|
||||
center=await pointForCell(client,'B0',pathRow.cells[0][0],pathRow.cells[0][1]),
|
||||
originalState=await client.evaluate("deepClone(metaState('B0'))");
|
||||
await client.send('Input.dispatchMouseEvent',{type:'mousePressed',x:start.x,y:start.y,button:'left',buttons:1,clickCount:1});
|
||||
try{
|
||||
await waitFor(()=>client.evaluate("Boolean(rendered.get('B0')?.drawing)"),{timeout:5000,label:'continuous pickup activation'});
|
||||
const pendingMoves=[];
|
||||
for(let index=0;index<steps;index++){
|
||||
pendingMoves.push(client.send('Input.dispatchMouseEvent',{type:'mouseMoved',x:center.x+(index%2?3:-3),y:center.y+((index%3)-1)*2,button:'left',buttons:1}));
|
||||
await sleep(sampleDelay);
|
||||
}
|
||||
await Promise.all(pendingMoves);
|
||||
return{moves:steps,reason:'complete',snapshot:await client.evaluate('BEND_PERF.snapshot()')};
|
||||
}finally{
|
||||
await client.send('Input.dispatchMouseEvent',{type:'mouseReleased',x:center.x,y:center.y,button:'left',buttons:0,clickCount:1}).catch(()=>{});
|
||||
await sleep(120);
|
||||
await client.evaluate(`(()=>{data.states.B0=${JSON.stringify(originalState)};normalizedStateObjects.add(data.states.B0);markStateDirty('B0');renderBoardNow(rendered.get('B0'));return true})()`);
|
||||
await client.evaluate("(async()=>{await persistNow({skipCloud:true});return true})()");
|
||||
}
|
||||
}
|
||||
async function claimLatencyProbe(client,pathRow,approve){
|
||||
return client.evaluate(`(async()=>{
|
||||
const board=rendered.get('B0'),state=metaState('B0'),originalState=deepClone(state),originalEnsure=ensureBoardClaimForInput,pointerId=${approve?93:94};let resolveClaim;
|
||||
const
|
||||
wait=milliseconds=>new Promise(resolve=>setTimeout(resolve,milliseconds)),nextFrame=()=>new Promise(resolve=>requestAnimationFrame(resolve));
|
||||
const cleanup=()=>{cancelBoardDragFrame(board);clearDragRender(board);board.drawing=null;board.armedGate=null;clearPendingClaimPointer(board,pointerId,{release:false});realtimeHeldPointers.delete(pointerId);try{if(board.svg.hasPointerCapture?.(pointerId))board.svg.releasePointerCapture(pointerId)}catch(_){}data.states.B0=originalState;normalizedStateObjects.add(originalState);renderBoardNow(board);refreshInteractionState()};
|
||||
try{
|
||||
const cells=${JSON.stringify(pathRow.cells)},startGate=${pathRow.startGate},startColor=canonicalGateColorIndex(board.meta,startGate,null);
|
||||
state.paths=[{startGate,endGate:null,openGate:null,cells:cells.slice(0,2).map(cell=>[...cell]),colorIndex:startColor,startColorIndex:startColor,endColorIndex:null}];board.drawing=null;renderBoardNow(board);
|
||||
const hit=board.pathLayer.querySelector('.endpoint-hit[data-path-index="0"][data-endpoint-side="end"]'),rect=board.svg.getBoundingClientRect(),vb=board.svg.viewBox.baseVal,
|
||||
screenPoint=([row,column])=>({clientX:rect.left+(PAD+(column+.5)*CELL)*rect.width/vb.width,clientY:rect.top+(PAD+(row+.5)*CELL)*rect.height/vb.height}),
|
||||
down=screenPoint(cells[1]),latest=screenPoint(cells[Math.min(2,cells.length-1)]),before=JSON.stringify(state.paths),fullBefore=perfCounters.fullBoardRenders||0;
|
||||
ensureBoardClaimForInput=()=>new Promise(resolve=>{resolveClaim=resolve});
|
||||
hit.dispatchEvent(new PointerEvent('pointerdown',{bubbles:true,cancelable:true,pointerId,pointerType:'mouse',isPrimary:true,button:0,buttons:1,...down}));
|
||||
await nextFrame();board.svg.dispatchEvent(new PointerEvent('pointermove',{bubbles:true,cancelable:true,pointerId,pointerType:'mouse',isPrimary:true,button:-1,buttons:1,...latest}));
|
||||
await wait(70);await nextFrame();
|
||||
const pending=board.pendingClaimPointer,previewWithinFrame=Boolean(pending?.preview?.getAttribute?.('transform')),tracksLatest=Boolean(pending&&Math.abs(pending.clientX-latest.clientX)<.1&&Math.abs(pending.clientY-latest.clientY)<.1),
|
||||
modelUntouched=JSON.stringify(state.paths)===before&&!board.drawing;
|
||||
resolveClaim(${approve});await wait(20);await nextFrame();await nextFrame();
|
||||
const approved=Boolean(board.drawing?.pointerId===pointerId),processed=board.lastProcessedPointerMove,
|
||||
noJump=${approve}?Boolean(processed&&Math.abs(processed.clientX-latest.clientX)<.1&&Math.abs(processed.clientY-latest.clientY)<.1):!board.drawing&&!board.pendingClaimPointer&&JSON.stringify(state.paths)===before,
|
||||
fullBoardRenders=(perfCounters.fullBoardRenders||0)-fullBefore;
|
||||
return{previewWithinFrame,tracksLatest,modelUntouched,approved,noJump,fullBoardRenders};
|
||||
}finally{ensureBoardClaimForInput=originalEnsure;cleanup()}
|
||||
})()`);
|
||||
}
|
||||
|
||||
async function measureCursorModes(client){
|
||||
return client.evaluate(`(async()=>{
|
||||
const emoji=CURSOR_ITEMS.find(item=>item.cursorEmoji&&!item.flagAsset),flag=CURSOR_ITEMS.find(item=>item.flagAsset),nextFrame=()=>new Promise(resolve=>requestAnimationFrame(resolve));
|
||||
try{
|
||||
localStorage.removeItem('bend-field-cursor-renderer');syncCursorAppearance('default');const defaultMode=document.body.dataset.cursorMode;
|
||||
syncCursorAppearance(emoji.cursorStyle);const emojiMode=document.body.dataset.cursorMode;
|
||||
syncCursorAppearance(flag.cursorStyle);const flagMode=document.body.dataset.cursorMode;
|
||||
localStorage.setItem('bend-field-cursor-renderer','dom');syncCursorAppearance(emoji.cursorStyle);
|
||||
const viewport=document.querySelector('#viewport'),cursorEvent='onpointerrawupdate'in window?'pointerrawupdate':'pointermove',first=new PointerEvent(cursorEvent,{bubbles:true,pointerId:95,pointerType:'mouse',clientX:120,clientY:130});
|
||||
viewport.dispatchEvent(first);await nextFrame();const firstTransform=customEmojiCursor.style.transform,visibleBeforeDrag=customEmojiCursor.classList.contains('visible');
|
||||
document.body.classList.add('is-drawing');viewport.dispatchEvent(new PointerEvent(cursorEvent,{bubbles:true,pointerId:95,pointerType:'mouse',clientX:160,clientY:170}));await nextFrame();
|
||||
return{defaultMode,emojiMode,flagMode,domMode:document.body.dataset.cursorMode,visibleBeforeDrag,visibleDuringDrag:customEmojiCursor.classList.contains('visible'),movedDuringDrag:firstTransform!==customEmojiCursor.style.transform};
|
||||
}finally{document.body.classList.remove('is-drawing');localStorage.removeItem('bend-field-cursor-renderer');syncCursorAppearance('default')}
|
||||
})()`);
|
||||
}
|
||||
async function measureDisplayCadence(client,frames=90){
|
||||
return client.evaluate(`new Promise(resolve=>{
|
||||
const gaps=[];let previous=0,count=0;
|
||||
const tick=timestamp=>{if(previous)gaps.push(timestamp-previous);previous=timestamp;if(++count<${frames})requestAnimationFrame(tick);else{const sorted=gaps.sort((a,b)=>a-b),pick=q=>sorted[Math.floor((sorted.length-1)*q)]||0;resolve({count:gaps.length,p50:pick(.5),p95:pick(.95),max:sorted[sorted.length-1]||0})}};
|
||||
requestAnimationFrame(tick);
|
||||
})`);
|
||||
}
|
||||
async function measureCursorCadence(client,steps=180){
|
||||
const setup=await client.evaluate(`(()=>{
|
||||
const emoji=CURSOR_ITEMS.find(item=>item.cursorEmoji&&!item.flagAsset),rect=document.querySelector('#viewport').getBoundingClientRect();
|
||||
localStorage.setItem('bend-field-cursor-renderer','dom');syncCursorAppearance(emoji.cursorStyle);BEND_PERF.reset();
|
||||
return{left:rect.left+40,top:rect.top+40,width:Math.max(120,rect.width-80),height:Math.max(120,rect.height-80)};
|
||||
})()`);
|
||||
try{
|
||||
for(let index=0;index<steps;index++){
|
||||
await client.send('Input.dispatchMouseEvent',{type:'mouseMoved',x:setup.left+(index*7)%setup.width,y:setup.top+(index*3)%setup.height,button:'none',buttons:0});
|
||||
await sleep(8);
|
||||
}
|
||||
await sleep(120);const snapshot=await client.evaluate('BEND_PERF.snapshot()'),gap=timing(snapshot,'cursorFrameGap'),age=timing(snapshot,'cursorInputAge');
|
||||
assert(gap.count>=30&&gap.p50<=20,`DOM cursor cadence missed acceptance: ${JSON.stringify(gap)}`);
|
||||
assert(age.count>=25&&age.p95<25,`DOM cursor input age missed acceptance: ${JSON.stringify(age)}`);
|
||||
return snapshot;
|
||||
}finally{await client.evaluate("localStorage.removeItem('bend-field-cursor-renderer');syncCursorAppearance('default');true")}
|
||||
}
|
||||
|
||||
async function zoom(client,deltaY,repetitions){
|
||||
const center=await viewportCenter(client);
|
||||
for(let index=0;index<repetitions;index++){
|
||||
await client.send('Input.dispatchMouseEvent',{type:'mouseWheel',x:center.x,y:center.y,deltaX:0,deltaY});
|
||||
await sleep(22);
|
||||
}
|
||||
await sleep(300);
|
||||
}
|
||||
|
||||
function timing(snapshot,name){return snapshot.timings?.[name]||{count:0,p50:0,p95:0,max:0}}
|
||||
async function measureGameplaySimplificationBudgets(client){
|
||||
const result=await client.evaluate(`(async()=>{
|
||||
const percentile=(rows,p)=>{const ordered=[...rows].sort((a,b)=>a-b);return ordered[Math.min(ordered.length-1,Math.floor(ordered.length*p))]||0};
|
||||
const sample=(operation,count=2000)=>{const rows=[];for(let index=0;index<count;index++){const started=performance.now();operation(index);rows.push(performance.now()-started)}return{p95:percentile(rows,.95),max:Math.max(...rows)}};
|
||||
const snap=sample(index=>directionFromDelta(CELL*(index%2?.46:.6),index%2?0:CELL*.55));
|
||||
const pointerSamples=sample(()=>pointerEventSamples({pointerId:1,pointerType:'pen',getCoalescedEvents:()=>[{clientX:10,clientY:20},{clientX:20,clientY:20},{clientX:20,clientY:10}]}));
|
||||
const rect=getMinimapRect(),event={clientX:rect.left+rect.width*.5,clientY:rect.top+rect.height*.5};
|
||||
const minimap=sample(()=>minimapWorldPoint(event));
|
||||
const noise=sample(()=>paintNoiseBackground(false),80);
|
||||
const controls={
|
||||
origin:Boolean(document.querySelector('#minimapOriginBtn')),
|
||||
random:Boolean(document.querySelector('#minimapRandomBtn')),
|
||||
unsolved:Boolean(document.querySelector('#minimapUnsolvedBtn')),
|
||||
current:Boolean(document.querySelector('#minimapCurrentBtn'))
|
||||
};
|
||||
const originalScale=cam.scale;cam.scale=OVERVIEW_ZOOM_THRESHOLD-.001;const overviewBelow=inWorldOverview();
|
||||
cam.scale=OVERVIEW_ZOOM_THRESHOLD+.001;const overviewAbove=inWorldOverview();cam.scale=originalScale;inWorldOverview();
|
||||
const summaryProbe=document.createElementNS('http://www.w3.org/2000/svg','path');summaryProbe.classList.add('static-summary-fill');
|
||||
document.body.append(summaryProbe);const summaryFill=getComputedStyle(summaryProbe).fill;summaryProbe.remove();
|
||||
const active=rendered.get(activeBoard);let northHudGap=null;
|
||||
if(active){positionBoardLabel(active);if(active.label.dataset.side==='N'){const placement=hudPlacementCandidates(active.meta)[0],edgeY=PAD+placement.dy*UNIT;northHudGap=edgeY-parseFloat(active.label.style.top)}}
|
||||
let heartbeats=0;const heartbeat=setInterval(()=>heartbeats++,0),workerStarted=performance.now();
|
||||
const workerResult=await verifyPuzzleUniquenessAsync(data.metas.B0.puzzle,2000);
|
||||
const workerElapsed=performance.now()-workerStarted;clearInterval(heartbeat);
|
||||
return{
|
||||
snap,pointerSamples,minimap,noise,workerElapsed,heartbeats,workerStatus:workerResult.status,
|
||||
controls,overviewBelow,overviewAbove,summaryFill,northHudGap,
|
||||
cellHitCount:document.querySelectorAll('.cell-hit').length,
|
||||
cellShapeCount:document.querySelectorAll('.board-card .cell-shape').length,
|
||||
renderedBoardCount:rendered.size,
|
||||
visibleDetailedCount:[...visibleMetaIds()].filter(id=>data.metas[id]?.puzzle&&rendered.has(id)).length,
|
||||
visiblePuzzleCount:[...visibleMetaIds()].filter(id=>data.metas[id]?.puzzle).length,
|
||||
fpsVisible:Boolean(document.querySelector('#fpsCounter')),
|
||||
difficultyItems:STORE_ITEMS.filter(item=>item.id==='level-min-10'||item.id==='level-max-10').length
|
||||
};
|
||||
})()`);
|
||||
assert(result.snap.p95<1,`Pointer snap p95 ${result.snap.p95.toFixed(3)} ms exceeded 1 ms`);
|
||||
assert(result.pointerSamples.p95<1,`Pointer-sample p95 ${result.pointerSamples.p95.toFixed(3)} ms exceeded 1 ms`);
|
||||
assert(result.minimap.p95<1,`Minimap conversion p95 ${result.minimap.p95.toFixed(3)} ms exceeded 1 ms`);
|
||||
assert(result.noise.p95<10&&result.noise.max<20,`Noise update p95/max ${result.noise.p95.toFixed(3)}/${result.noise.max.toFixed(3)} ms exceeded the budget`);
|
||||
assert(result.controls.origin&&result.controls.random&&!result.controls.unsolved&&!result.controls.current,'Minimap teleport controls do not match origin + random');
|
||||
assert(result.overviewBelow&&!result.overviewAbove,'World overview retained zoom hysteresis');
|
||||
assert(result.summaryFill==='rgba(0, 0, 0, 0)'||result.summaryFill==='transparent',`Nearby board summary still has a filled square (${result.summaryFill})`);
|
||||
if(result.northHudGap!=null)assert(Math.abs(result.northHudGap-22)<.01,`Top puzzle HUD gap is ${result.northHudGap}, expected 22 world pixels`);
|
||||
assert(result.cellHitCount===0&&result.cellShapeCount>0,'Detailed boards still allocate one hit node per cell or lack compound cell paths');
|
||||
assert(result.visibleDetailedCount===result.visiblePuzzleCount,'A visible valid board was hidden from detailed rendering');
|
||||
assert(result.fpsVisible&&result.difficultyItems===0,'FPS display is missing or retired difficulty items remain');
|
||||
assert(result.heartbeats>0&&['unique','multiple','unsolved','timeout','invalid'].includes(result.workerStatus),'Uniqueness verification did not yield to the main thread');
|
||||
return result;
|
||||
}
|
||||
function validateMeasurement(result){
|
||||
const {snapshot,cpuRate,profile}=result,cadenceSnapshot=result.pickupCadence||snapshot,
|
||||
drag=timing(snapshot,'processBoardDragFrame'),
|
||||
probeDrag=timing(cadenceSnapshot,'processBoardDragFrame'),
|
||||
camera=timing(snapshot,'commitCameraInteraction'),
|
||||
dragGap=timing(cadenceSnapshot,'pickupVisualFrameGap'),cameraGap=timing(snapshot,'cameraFrameGap'),
|
||||
dragAge=timing(cadenceSnapshot,'pickupVisualInputAge'),cameraAge=timing(snapshot,'cameraInputAge'),
|
||||
minimap=timing(snapshot,'drawMinimap'),ensure=timing(snapshot,'ensureBoards'),save=timing(snapshot,'persistDirtyToDb'),
|
||||
overview=timing(snapshot,'drawWorldOverview'),mirrorChunk=timing(snapshot,'mirrorChunkWrite'),
|
||||
dragLimit=cpuRate===1?8:16,minimapLimit=cpuRate===1?20:33,lodLimit=cpuRate===1?40:100;
|
||||
assert(drag.count+probeDrag.count>=5,`${profile}/${cpuRate}x captured only ${drag.count} functional and ${probeDrag.count} continuous-input drag frames: ${JSON.stringify(result.pickupProbe)}`);
|
||||
assert(camera.count>=5,`${profile}/${cpuRate}x did not capture frame-coalesced camera work`);
|
||||
assert(minimap.count>=2,`${profile}/${cpuRate}x did not capture minimap work`);
|
||||
assert(ensure.count>=2,`${profile}/${cpuRate}x did not capture LOD work`);
|
||||
assert(save.count>=1,`${profile}/${cpuRate}x did not capture an autosave`);
|
||||
assert(overview.count>=1,`${profile}/${cpuRate}x did not capture overview rendering`);
|
||||
assert(cameraGap.count>=5&&(cpuRate!==1||dragGap.count>=4),`${profile}/${cpuRate}x did not capture enough real interaction cadence samples (pickup ${dragGap.count}, camera ${cameraGap.count})`);
|
||||
if(cpuRate===1){
|
||||
const approved=result.claimApproved,denied=result.claimDenied;
|
||||
assert(approved?.previewWithinFrame&&approved.tracksLatest&&approved.modelUntouched&&approved.approved&&approved.noJump&&approved.fullBoardRenders===0,`${profile} claim approval preview/commit failed: ${JSON.stringify(approved)}`);
|
||||
assert(denied?.previewWithinFrame&&denied.tracksLatest&&denied.modelUntouched&&!denied.approved&&denied.noJump&&denied.fullBoardRenders===0,`${profile} claim denial rollback failed: ${JSON.stringify(denied)}`);
|
||||
assert(result.edgePan?.cameraMoved&&result.edgePan.dragFrames>=2,`${profile} pickup edge-pan did not retain continuous drag frames: ${JSON.stringify(result.edgePan)}`);
|
||||
}
|
||||
assert(result.pinch?.changed,`${profile}/${cpuRate}x pinch zoom did not change camera scale: ${JSON.stringify(result.pinch)}`);
|
||||
assert(result.overviewPathsObserved===0,`${profile}/${cpuRate}x far overview rendered route lines`);
|
||||
assert((snapshot.counters.overviewCacheBuilds||0)>result.overviewBuildBaseline,`${profile}/${cpuRate}x long overview pan did not rebuild the exhausted cache after settlement`);
|
||||
if(cpuRate===1){
|
||||
const cadenceHot=Object.entries(cadenceSnapshot.timings||{}).filter(([,value])=>value.max>1).sort((a,b)=>b[1].max-a[1].max).slice(0,12);
|
||||
assert(dragGap.p50<=18&&dragGap.p95<=22,`${profile} pickup visual median/p95 gap ${dragGap.p50.toFixed(2)}/${dragGap.p95.toFixed(2)} ms exceeded the capped 60 Hz cadence budget; active timings ${JSON.stringify(cadenceHot)}`);
|
||||
assert(cameraGap.p50<=20,`${profile} camera median gap ${cameraGap.p50.toFixed(2)} ms exceeded 20 ms`);
|
||||
assert(dragAge.p95<30,`${profile} pickup input age ${dragAge.p95.toFixed(2)} ms exceeded 30 ms`);
|
||||
assert(cameraAge.p95<25,`${profile} camera input age ${cameraAge.p95.toFixed(2)} ms exceeded 25 ms`);
|
||||
}
|
||||
assert(Math.max(drag.p95,probeDrag.p95)<=dragLimit,`${profile}/${cpuRate}x drag p95 ${Math.max(drag.p95,probeDrag.p95).toFixed(2)} ms exceeded acceptance`);
|
||||
assert(camera.p95<=dragLimit,`${profile}/${cpuRate}x camera p95 ${camera.p95.toFixed(2)} ms exceeded acceptance`);
|
||||
assert(minimap.p95<=minimapLimit,`${profile}/${cpuRate}x minimap p95 ${minimap.p95.toFixed(2)} ms exceeded acceptance`);
|
||||
assert(ensure.p95<=lodLimit,`${profile}/${cpuRate}x LOD p95 ${ensure.p95.toFixed(2)} ms exceeded acceptance`);
|
||||
if(mirrorChunk.count)assert(mirrorChunk.max<50,`${profile}/${cpuRate}x mirror chunk write ${mirrorChunk.max.toFixed(2)} ms became a long task`);
|
||||
if(cpuRate===1){
|
||||
const modelWork=timing(snapshot,'pickupModelWork'),visualWork=timing(snapshot,'pickupVisualWork'),dragRender=timing(snapshot,'renderDragFrame');
|
||||
assert(result.pickupProbeLongTasks===0&&result.pickupLongTasks===0&&result.panLongTasks===0,`${profile} recorded a 50 ms long task during continuous pickup (${result.pickupProbeLongTasks}), real pickup (${result.pickupLongTasks}), or the ten-second pan (${result.panLongTasks}); last ${snapshot.gauges.lastInteractionLongTaskMs||0} ms; phases preview ${timing(snapshot,'pickupPointerDownPreview').max.toFixed(1)}, commit ${timing(snapshot,'pickupPointerDownCommit').max.toFixed(1)}, finish ${timing(snapshot,'pickupPointerFinish').max.toFixed(1)}, drag ${drag.max.toFixed(1)}, model ${modelWork.max.toFixed(1)}, visual ${visualWork.max.toFixed(1)}, render ${dragRender.max.toFixed(1)}; ${snapshot.gauges.lastInteractionLoafScripts||'no LoAF attribution'}`);
|
||||
}
|
||||
for(const name of ['minimapDrawsDuringInteraction','lodPassesDuringInteraction','overviewBuildsDuringInteraction','persistenceDuringInteraction','worldRefreshesDuringInteraction'])
|
||||
assert((snapshot.counters[name]||0)===0&&(cadenceSnapshot.counters[name]||0)===0,`${profile}/${cpuRate}x ran ${name} during an active gesture`);
|
||||
assert((snapshot.counters.worldRefreshesDeferredDuringInteraction||0)>=1,`${profile}/${cpuRate}x did not defer the injected cross-tab refresh until gesture settlement`);
|
||||
assert(snapshot.gauges.renderedBoards>=snapshot.gauges.visibleUnsolvedBoards,`${profile}/${cpuRate}x omitted a visible unsolved board from detailed rendering`);
|
||||
assert((cadenceSnapshot.rates.dragFramesPerSecond||0)<=65,`${profile}/${cpuRate}x pickup presentation exceeded the 60 FPS ceiling (${(cadenceSnapshot.rates.dragFramesPerSecond||0).toFixed(1)} FPS)`);
|
||||
assert(snapshot.gauges.domNodes<18000,`${profile}/${cpuRate}x DOM size is not viewport-bounded`);
|
||||
}
|
||||
|
||||
async function measureScenario(client,starter,profile,cpuRate){
|
||||
await client.send('Emulation.setCPUThrottlingRate',{rate:1});
|
||||
await client.evaluate("if(typeof lifecyclePersistenceSuppressed!=='undefined')lifecyclePersistenceSuppressed=true;true");
|
||||
const solution=await populateWorld(client,starter,profile);
|
||||
await reload(client);
|
||||
const pristineOriginState=await client.evaluate("deepClone(metaState('B0'))");
|
||||
await client.send('Emulation.setCPUThrottlingRate',{rate:cpuRate});
|
||||
const claimApproved=cpuRate===1?await claimLatencyProbe(client,solution[0],true):null;
|
||||
if(cpuRate===1)await restoreOriginState(client,pristineOriginState);
|
||||
const claimDenied=cpuRate===1?await claimLatencyProbe(client,solution[0],false):null;
|
||||
if(cpuRate===1)await restoreOriginState(client,pristineOriginState);
|
||||
const edgePan=cpuRate===1?await pickupEdgePanProbe(client,solution[0]):null;
|
||||
if(cpuRate===1)await restoreOriginState(client,pristineOriginState);
|
||||
if(cpuRate===1)await sleep(300);
|
||||
await prepareInteractionMeasurement(client);
|
||||
const pickupProbe=await pickupCadenceProbe(client,solution[0],cpuRate===1?180:120,cpuRate===1?8:16);
|
||||
const pickupCadence=pickupProbe.snapshot;delete pickupProbe.snapshot;
|
||||
const pickupProbeLongTasks=pickupCadence?.counters.interactionLongTasks||0;
|
||||
await restoreOriginState(client,pristineOriginState);
|
||||
await sleep(300);
|
||||
await prepareInteractionMeasurement(client);
|
||||
const preSolveState=await client.evaluate("({pathCount:metaState('B0').paths.length,solved:metaState('B0').solved,drawing:Boolean(rendered.get('B0')?.drawing),pending:Boolean(rendered.get('B0')?.pendingClaimPointer)})");
|
||||
assert(preSolveState.pathCount===0&&!preSolveState.solved&&!preSolveState.drawing&&!preSolveState.pending,`${profile.name}/${cpuRate}x pickup probes did not restore a pristine origin board: ${JSON.stringify(preSolveState)}`);
|
||||
await solveOrigin(client,solution,cpuRate);
|
||||
try{await waitFor(()=>client.evaluate("metaState('B0').solved===true"),{timeout:5000*Math.max(1,cpuRate),label:`${profile.name}/${cpuRate}x solved origin`})}
|
||||
catch(error){const diagnostic=await client.evaluate("(()=>{const state=metaState('B0'),board=rendered.get('B0');return{solved:state.solved,paths:state.paths.map(path=>({startGate:path.startGate,endGate:path.endGate,openGate:path.openGate,cells:path.cells})),drawing:board?.drawing?{pathIndex:board.drawing.pathIndex,catchupPending:board.drawing.catchupPending}:null,queued:board?.pointerMoveSamples?.length||0,logicalTrace:board?.logicalPointerCellTrace||[],flushTrace:board?.lastFlushPointerCells||[],staticBoard:staticRendered.has('B0')}})()");throw new Error(`${error.message}: ${JSON.stringify(diagnostic)}`)}
|
||||
await sleep(850);
|
||||
const pickupLongTasks=await client.evaluate('BEND_PERF.snapshot().counters.interactionLongTasks||0');
|
||||
await client.evaluate('finishCompletionVisual("B0",true);centerMeta(data.metas.B0);true');await sleep(350);
|
||||
const leftStart=await safePanPoint(client,'.board-card[data-id="B0"]'),
|
||||
leftProbe=await client.evaluate(`(()=>{const target=document.elementFromPoint(${leftStart.x},${leftStart.y});return{tag:target?.tagName||null,classes:target?.getAttribute?.('class')||null,board:target?.closest?.('.board-card')?.dataset?.id||null,allowed:leftFieldPanAllowed({button:0,target})}})()`);
|
||||
const beforeLeftPan=await client.evaluate('({x:cam.x,y:cam.y,solved:metaState("B0").solved})');
|
||||
assert(beforeLeftPan.solved&&leftProbe.allowed,`${profile.name}/${cpuRate}x solved-board left drag is not eligible for panning: ${JSON.stringify({leftStart,leftProbe,beforeLeftPan})}`);
|
||||
await zoom(client,180,15);
|
||||
const overviewPathsObserved=await client.evaluate('drawWorldOverview();BEND_PERF.snapshot().gauges.overviewPaths||0');
|
||||
const overviewBuildBaseline=await client.evaluate('BEND_PERF.snapshot().counters.overviewCacheBuilds||0');
|
||||
const panLongTaskBaseline=await client.evaluate('BEND_PERF.snapshot().counters.interactionLongTasks||0');
|
||||
await pan(client);
|
||||
await panCadenceProbe(client,cpuRate===1?600:60);
|
||||
await waitFor(()=>client.evaluate(`(()=>{if(!inWorldOverview()||!overviewCache)return false;const[centerX,centerY]=cameraCenterInChunks();return!overviewDirty&&Math.abs(centerX-overviewCache.anchorX)*overviewCache.unit<=overviewCache.overscan*.82&&Math.abs(centerY-overviewCache.anchorY)*overviewCache.unit<=overviewCache.overscan*.82})()`),{timeout:10000,label:'settled overview cache rebuild'});
|
||||
const pinch=await pinchZoomProbe(client);
|
||||
await sleep(80);
|
||||
const panLongTasks=(await client.evaluate('BEND_PERF.snapshot().counters.interactionLongTasks||0'))-panLongTaskBaseline;
|
||||
await zoom(client,-180,15);
|
||||
await sleep(650);
|
||||
try{await waitFor(()=>client.evaluate("!hasPendingPersistence()"),{timeout:20000,label:'durable persistence drain'})}
|
||||
catch(error){
|
||||
const diagnostic=await client.evaluate(`({globalDirty,dirtyMetaIds:[...dirtyMetaIds],dirtyStateIds:[...dirtyStateIds],deletedBoardIds:[...deletedBoardIds],cloudOutboxDeleteKeys:[...cloudOutboxDeleteKeys],saveTimer:Boolean(saveTimer),status:saveStatusEl?.dataset?.state||null,message:statusMessage?.textContent||null,storageFormat:activeStorageFormat,epoch:data?.worldEpoch})`);
|
||||
throw new Error(`${error.message}: ${JSON.stringify(diagnostic)}`);
|
||||
}
|
||||
await sleep(500);
|
||||
const snapshot=await client.evaluate('BEND_PERF.snapshot()');
|
||||
const result={profile:profile.name,boards:profile.boards,cpuRate,snapshot,pickupCadence,pickupProbe,overviewPathsObserved,overviewBuildBaseline,pickupProbeLongTasks,pickupLongTasks,panLongTasks,claimApproved,claimDenied,edgePan,pinch};
|
||||
validateMeasurement(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
async function main(runProfiles=profiles,cpuRates=[1,4]){
|
||||
if(!fs.existsSync(edgePath))throw new Error(`Microsoft Edge was not found at ${edgePath}`);
|
||||
const server=await startStaticServer();
|
||||
const edge=spawn(edgePath,[
|
||||
'--headless','--no-first-run','--no-sandbox','--force-device-scale-factor=1','--js-flags=--expose-gc','--disable-dev-shm-usage','--disable-extensions','--disable-background-networking','--disable-background-timer-throttling','--disable-renderer-backgrounding','--disable-backgrounding-occluded-windows','--disable-crash-reporter','--disable-breakpad','--disable-component-update','--disable-features=OptimizationGuideModelDownloading,OnDeviceModelService,PromptApiForGeminiNano',
|
||||
`--remote-debugging-port=${debuggingPort}`,'--remote-allow-origins=*',`--user-data-dir=${profilePath}`,
|
||||
`--host-resolver-rules=MAP ${benchmarkHost} 127.0.0.1`,'--window-size=1440,1000','about:blank'
|
||||
],{stdio:'ignore',windowsHide:true});
|
||||
let client=null;
|
||||
try{
|
||||
const target=await endpoint();client=new CdpClient(target.webSocketDebuggerUrl);await client.connect();
|
||||
await client.send('Page.enable');await client.send('Runtime.enable');
|
||||
await client.send('Page.navigate',{url:`http://${benchmarkHost}:${serverPort}/`});
|
||||
if(startupOnly){
|
||||
await sleep(12000);
|
||||
const state=await client.evaluate("({ready:document.body?.dataset?.ready||null,version:document.querySelector('.brand small')?.textContent||null,boards:typeof data!=='undefined'?Object.keys(data.metas).length:null,origin:typeof data!=='undefined'&&Boolean(data.metas?.B0?.puzzle),worldGeneration:typeof data!=='undefined'?data.worldGeneration:null,turnFont:getComputedStyle(document.querySelector('.board-svg text')||document.body).fontFamily,status:document.querySelector('#statusMessage')?.textContent||'',text:document.body?.innerText?.slice(0,300)||''})");
|
||||
assert(state.ready==='true'&&state.version==='v47.77'&&state.boards===1&&state.origin&&state.worldGeneration==='v47-field-reset-20260728-interaction-fix',`Real-browser startup state is incomplete: ${JSON.stringify(state)}`);
|
||||
assert(/DotGothic16|Press Start 2P|MS Gothic|monospace/i.test(state.turnFont),'Dot-styled game font is not active in the browser');
|
||||
console.log(`Real-browser startup passed: ${JSON.stringify(state)}`);return;
|
||||
}
|
||||
await ready(client);await sleep(1000);
|
||||
const displayCadence=await measureDisplayCadence(client);
|
||||
assert(displayCadence.count>=60&&displayCadence.p50<=20,`Headless display baseline is not 60 Hz: ${JSON.stringify(displayCadence)}`);
|
||||
const gameplayBudgets=await measureGameplaySimplificationBudgets(client);
|
||||
const cursorModes=await measureCursorModes(client);
|
||||
assert(cursorModes.defaultMode==='default'&&cursorModes.emojiMode==='native'&&cursorModes.flagMode==='native'&&cursorModes.domMode==='dom'&&cursorModes.visibleBeforeDrag&&cursorModes.visibleDuringDrag&&cursorModes.movedDuringDrag,`Cursor mode runtime probe failed: ${JSON.stringify(cursorModes)}`);
|
||||
const cursorCadence=await measureCursorCadence(client);
|
||||
console.log(`Display cadence | median ${displayCadence.p50.toFixed(2)} ms | p95 ${displayCadence.p95.toFixed(2)} ms`);
|
||||
console.log(`Gameplay budgets | snap ${gameplayBudgets.snap.p95.toFixed(3)} ms | pointer samples ${gameplayBudgets.pointerSamples.p95.toFixed(3)} ms | minimap ${gameplayBudgets.minimap.p95.toFixed(3)} ms | noise max ${gameplayBudgets.noise.max.toFixed(3)} ms | worker ${gameplayBudgets.workerElapsed.toFixed(1)} ms`);
|
||||
console.log(`Cursor cadence | median gap ${timing(cursorCadence,'cursorFrameGap').p50.toFixed(2)} ms | input p95 ${timing(cursorCadence,'cursorInputAge').p95.toFixed(2)} ms`);
|
||||
const starter=await readStarterRows(client),results=[];
|
||||
for(const profile of runProfiles)for(const cpuRate of cpuRates){
|
||||
const result=await measureScenario(client,starter,profile,cpuRate);results.push(result);
|
||||
const drag=timing(result.snapshot,'processBoardDragFrame'),camera=timing(result.snapshot,'commitCameraInteraction'),minimap=timing(result.snapshot,'drawMinimap'),
|
||||
ensure=timing(result.snapshot,'ensureBoards'),save=timing(result.snapshot,'persistDirtyToDb');
|
||||
console.log(`${profile.name.padEnd(6)} ${cpuRate}x CPU | drag p95 ${drag.p95.toFixed(2)} ms | camera ${camera.p95.toFixed(2)} ms | minimap ${minimap.p95.toFixed(2)} ms | LOD ${ensure.p95.toFixed(2)} ms | save ${save.p95.toFixed(2)} ms | DOM ${result.snapshot.gauges.domNodes}`);
|
||||
}
|
||||
console.log(`BROWSER_BENCHMARK_JSON=${JSON.stringify(results.map(result=>({
|
||||
profile:result.profile,boards:result.boards,cpuRate:result.cpuRate,
|
||||
dragP95:timing(result.snapshot,'processBoardDragFrame').p95,
|
||||
cameraP95:timing(result.snapshot,'commitCameraInteraction').p95,
|
||||
dragGapP95:timing(result.pickupCadence||result.snapshot,'pickupVisualFrameGap').p95,
|
||||
dragInputAgeP95:timing(result.pickupCadence||result.snapshot,'pickupVisualInputAge').p95,
|
||||
cameraGapP50:timing(result.snapshot,'cameraFrameGap').p50,
|
||||
cameraInputAgeP95:timing(result.snapshot,'cameraInputAge').p95,
|
||||
minimapP95:timing(result.snapshot,'drawMinimap').p95,
|
||||
lodP95:timing(result.snapshot,'ensureBoards').p95,
|
||||
saveP95:timing(result.snapshot,'persistDirtyToDb').p95,
|
||||
overviewP95:timing(result.snapshot,'drawWorldOverview').p95,
|
||||
renderedBoards:result.snapshot.gauges.renderedBoards,
|
||||
staticBoards:result.snapshot.gauges.staticBoards,
|
||||
domNodes:result.snapshot.gauges.domNodes,
|
||||
longTasks:result.snapshot.counters.longTasks||0
|
||||
})))}`);
|
||||
console.log('Real-browser performance benchmark passed');
|
||||
}finally{
|
||||
client?.close();
|
||||
stopBrowserTree(edge);
|
||||
server.closeAllConnections?.();await new Promise(resolve=>server.close(()=>resolve()));
|
||||
for(let attempt=0;attempt<8;attempt++){
|
||||
try{fs.rmSync(temporaryRoot,{recursive:true,force:true});break}
|
||||
catch(error){if(attempt===7)console.warn(`Benchmark cleanup deferred: ${error.message}`);await sleep(150)}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports={main,allProfiles};
|
||||
if(require.main===module)main().then(()=>process.exit(0)).catch(error=>{console.error(error);process.exit(1)});
|
||||
54
test/cleanup-performance-smoke-test.js
Normal file
54
test/cleanup-performance-smoke-test.js
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
'use strict';
|
||||
const assert=require('assert/strict');
|
||||
const fs=require('fs');
|
||||
const path=require('path');
|
||||
const {root,app,html,css,functionSource,read}=require('./helpers/app-source');
|
||||
const server=read('server.js'),realtime=read('realtime-server.js'),catalog=JSON.parse(read('store-catalog.json'));
|
||||
|
||||
// Retired difficulty-adjustment system must be absent from every runtime layer.
|
||||
assert.equal(catalog.some(item=>item.id==='level-min-10'||item.id==='level-max-10'||item.targetMode||item.fieldRadius),false);
|
||||
for(const [name,source] of [['app',app],['server',server],['realtime',realtime],['html',html]]){
|
||||
assert(!source.includes('fieldOverlayCanvas'),`${name}: field overlay remains`);
|
||||
assert(!source.includes('level-min-10')&&!source.includes('level-max-10'),`${name}: retired item id remains`);
|
||||
assert(!source.includes('fieldAdjusted')&&!source.includes('generatedDifficulty'),`${name}: retired difficulty metadata remains`);
|
||||
}
|
||||
assert(!server.includes("url.pathname==='/api/player/place-field'")&&!realtime.includes('broadcastFieldEffect'));
|
||||
assert(!app.includes('invalidateFieldOverlay')&&!app.includes('invalidateStoreEffectCache'));
|
||||
assert.equal(catalog.length>0,true);assert.equal(catalog.filter(item=>item.id==='score-lens').length,1);
|
||||
|
||||
// Clicking a board must select/input only; camera navigation is explicit elsewhere.
|
||||
assert(functionSource('shouldTeleportToUnsolvedBoard').includes('return false'));
|
||||
assert(!functionSource('bindBoard').includes('centerMeta('));
|
||||
assert(!app.includes('function promoteStaticBoard('));
|
||||
assert(functionSource('makeStaticBoard').includes('card.tabIndex=-1')&&!functionSource('makeStaticBoard').includes("setAttribute('role','button')"));
|
||||
|
||||
// Every hydrated puzzle in the visible field remains fully detailed.
|
||||
assert(functionSource('desiredInteractiveBoardIds').includes('for(const id of ids)'));
|
||||
assert(!app.includes('INTERACTIVE_DETAIL_CELL_BUDGET')&&!functionSource('ensureBoards').includes('makeStaticBoard(meta)'));
|
||||
const makeBoard=functionSource('makeBoard');
|
||||
assert(makeBoard.includes('boardCellsPath(p.valid)')&&makeBoard.includes("class:'cell-shape'"));
|
||||
assert(!makeBoard.includes('cellHits.set')&&!makeBoard.includes("class:'cell-hit'"));
|
||||
assert(functionSource('drawOuterEdges').includes("svgEl('path'"));
|
||||
assert(functionSource('flashConfirmedCell').includes("svgEl('rect'")&&functionSource('flashConfirmedCell').includes('node.remove()'));
|
||||
|
||||
// Work and memory are bounded without hiding visible boards.
|
||||
assert(app.includes('LOD_CHANGES_PER_PASS=1')&&app.includes('BOARD_RENDERS_PER_FRAME=6')&&app.includes('HYDRATE_CONCURRENCY=4'));
|
||||
assert(functionSource('evictHydratedBoardDetails').includes('hydratedBoardLru.size>32'));
|
||||
assert(functionSource('evictHydratedBoardDetails').includes('hydratedBoardBytes>8*1024*1024'));
|
||||
assert(functionSource('drawPresenceLayer').includes('dpr=1')&&functionSource('drawReactionLayer').includes('dpr=1'));
|
||||
assert(functionSource('scheduleNoiseBackground').includes('noisePainted')&&!functionSource('scheduleNoiseBackground').includes('setTimeout'));
|
||||
assert(!functionSource('applyCamera').includes('FRAME_INTERVAL')&&app.includes('AUXILIARY_FPS=30')&&app.includes('DRAG_TARGET_FPS=60'));
|
||||
|
||||
// Lightweight diagnostics and larger/longer completion reward visuals.
|
||||
assert(html.includes('id="fpsCounter"')&&functionSource('refreshFpsCounter').includes('FPS ${fpsLastValue}'));
|
||||
assert(css.includes('.gem-particle{position:fixed')&&css.includes('width:28px;height:28px'));
|
||||
assert(functionSource('completionEffect').includes('1800'));
|
||||
assert(functionSource('playGemCollectionAnimation').includes('duration=reduced?520:1450'));
|
||||
assert(!functionSource('bindBoard').includes('skipCompletionVisuals'));
|
||||
|
||||
// Purchase normalization must remain functional after deleting field purchases.
|
||||
assert(server.includes('function cleanId(')&&server.includes('function storeItem('));
|
||||
assert.equal(fs.existsSync(path.join(root,'docs','v47.71-input-ui-performance.md')),false);
|
||||
const internalSpec=fs.readFileSync(path.join(root,'docs','internal-system.md'),'utf8');
|
||||
assert(internalSpec.includes('Do not add changelog')&&internalSpec.includes('update the relevant current specification in place'));
|
||||
console.log('Difficulty removal, no-click-teleport, rendering, memory, FPS, and completion visual guards passed');
|
||||
235
test/concurrency-smoke-test.js
Normal file
235
test/concurrency-smoke-test.js
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
'use strict';
|
||||
const {vm,app,assert,functionSource}=require('./helpers/app-source');
|
||||
|
||||
const persistSource=functionSource('persistDirtyToDb');
|
||||
const startupSource=functionSource('loadSnapshotFromDb');
|
||||
const startupV2Source=functionSource('loadV2SnapshotFromDb');
|
||||
const initialSource=functionSource('readInitialDataAsync');
|
||||
const replaceSource=functionSource('beginWorldReplacement');
|
||||
const clearSource=functionSource('clearDatabaseWorld');
|
||||
|
||||
assert(startupSource.includes('loadV2SnapshotFromDb')&&startupV2Source.includes("db.transaction(['worlds','boardIndex','outboxV2','recoveryV2','tombstonesV2'],'readonly')"),
|
||||
'Startup does not read the snapshot, recovery coverage, and tombstones atomically');
|
||||
assert(startupV2Source.includes("objectStore('recoveryV2').getAll")&&startupV2Source.includes("objectStore('tombstonesV2').getAll"),
|
||||
'Startup omits database recovery journals or tombstones');
|
||||
assert(!initialSource.includes('loadRecoveryCoverage('),
|
||||
'Startup still performs the recovery coverage read in a second transaction');
|
||||
assert(replaceSource.includes('await preserveRecoveryDurably('),
|
||||
'World replacement does not await a verified recovery backup');
|
||||
assert(functionSource('retryRecovery').includes('readRecoveryEnvelope')&&functionSource('retryRecovery').includes('stageRecoverySnapshotV2')&&functionSource('retryRecovery').includes("kind:'recovery'"),
|
||||
'Recovery backup is not restored through the validated staged-world path');
|
||||
assert(clearSource.includes("activateReadyWorldV2({epoch:newEpoch,expected,world},{kind:'reset'})"),
|
||||
'Fresh-world reset does not use atomic epoch activation');
|
||||
assert(!app.includes('worldMutationLockDepth')&&functionSource('withWorldMutationLock').includes("mode:'exclusive'"),
|
||||
'World mutation locking still bypasses unrelated asynchronous callers');
|
||||
assert(functionSource('persistNow').includes('if(options.lockHeld===true)return run()')&&functionSource('expandMetaNow').includes('lockHeld:true'),
|
||||
'Nested expansion persistence can deadlock behind a queued lock waiter');
|
||||
assert(app.includes('if(!worldInitReady){deferredWorldSignals.push(signal)'),
|
||||
'Cross-tab messages are not buffered until initialization is complete');
|
||||
assert(functionSource('initCloudSync').includes('if(cloudApiEnabled&&!cloudOutboxReady)'),
|
||||
'Cloud synchronization is not fail-closed when IndexedDB health is uncertain');
|
||||
|
||||
const request=result=>({result});
|
||||
function memoryStore(initial=[],keyOf=row=>row.id){
|
||||
const normalizeKey=key=>Array.isArray(key)?key.join(':'):key,rows=new Map(initial.map(row=>[normalizeKey(keyOf(row)),structuredClone(row)]));
|
||||
return{
|
||||
rows,
|
||||
get:key=>request(rows.has(normalizeKey(key))?structuredClone(rows.get(normalizeKey(key))):null),
|
||||
put:row=>{rows.set(normalizeKey(keyOf(row)),structuredClone(row));return request(keyOf(row))},
|
||||
delete:key=>{rows.delete(normalizeKey(key));return request(undefined)},
|
||||
clear:()=>{rows.clear();return request(undefined)}
|
||||
};
|
||||
}
|
||||
function persistenceContext({localMeta=null,localState=null,existingMeta=null,existingState=null,deletion=null,identityEpoch='world:test'}={}){
|
||||
const existingId=existingMeta?.id||'B0',existingIndex=existingMeta?{epoch:'world:test',id:existingId,metaRev:existingMeta.rev||0,stateRev:existingState?.rev||0}:null;
|
||||
const remoteGlobal={worldEpoch:'world:test',globalRev:20,globalRevAuthor:'remote',nextId:9,cursorStyle:'remote'};
|
||||
const stores={
|
||||
control:memoryStore([{key:'active',activeFormat:2,activeEpoch:identityEpoch}],row=>row.key),
|
||||
worlds:memoryStore([{epoch:'world:test',status:'active',createdAt:1,source:{kind:'fresh'},global:remoteGlobal}],row=>row.epoch),
|
||||
boardIndex:memoryStore(existingIndex?[existingIndex]:[],row=>`${row.epoch}:${row.id}`),
|
||||
boardPuzzles:memoryStore(existingMeta?[{epoch:'world:test',id:existingId,metaRev:existingMeta.rev||0,meta:existingMeta}]:[],row=>`${row.epoch}:${row.id}`),
|
||||
boardStates:memoryStore(existingState?[{epoch:'world:test',id:existingId,stateRev:existingState.rev||0,value:existingState}]:[],row=>`${row.epoch}:${row.id}`),
|
||||
outboxV2:memoryStore([],row=>`${row.epoch}:${row.key}`),recoveryV2:memoryStore([],row=>`${row.epoch}:${row.key}`),tombstonesV2:memoryStore([],row=>`${row.epoch}:${row.id}`)
|
||||
};
|
||||
const id=localMeta?.id||existingMeta?.id||'B0';
|
||||
const data={
|
||||
worldEpoch:'world:test',globalRev:10,globalRevAuthor:'local',nextId:2,cursorStyle:'local',
|
||||
metas:localMeta?{[id]:structuredClone(localMeta)}:{},
|
||||
states:localState?{[id]:structuredClone(localState)}:{},
|
||||
updatedAt:0,clockFloor:0
|
||||
};
|
||||
const context={
|
||||
console,structuredClone,sessionId:'local',lastRevision:100,data,
|
||||
dirtyMetaIds:new Set(localMeta?[id]:[]),dirtyStateIds:new Set(localState?[id]:[]),
|
||||
deletedBoardIds:new Set(deletion?[id]:[]),
|
||||
deletedBoardRevisions:new Map(deletion?[[id,deletion.rev]]:[]),
|
||||
deletedBoardAuthors:new Map(deletion?[[id,deletion.revAuthor]]:[]),
|
||||
recoveryJournalsToCover:[],recoveryJournalSeq:-1,recoveryWalPromise:Promise.resolve(),
|
||||
cloudOutboxDeleteKeys:new Set(),cloudJournalMetaIds:new Set(),cloudJournalStateIds:new Set(),
|
||||
cloudJournalDeletedIds:new Set(deletion?[id]:[]),cloudApiEnabled:false,
|
||||
globalDirty:true,globalChangeSeq:1,cloudJournalGlobalChanged:true,worldSignalSeq:0,
|
||||
idbAvailable:true,activeStorageFormat:2,FIELD_STORAGE_FORMAT:2,storageAccessError:null,normalizedStateObjects:new WeakSet(),
|
||||
boardIndexSummaries:new Map(),
|
||||
statsDirty:false,resetHistory:[],
|
||||
pruneAndCount:()=>{},
|
||||
hasPendingPersistence:()=>context.globalDirty||context.dirtyMetaIds.size>0||context.dirtyStateIds.size>0||context.deletedBoardIds.size>0,
|
||||
validWorldEpoch:value=>typeof value==='string'&&value.startsWith('world:'),
|
||||
storedWorldEpoch:()=>null,createWorldEpoch:()=> 'world:test',rememberWorldEpoch:()=>true,
|
||||
revisionVersion:value=>({rev:value?.rev||0,revAuthor:value?.revAuthor||value?.author||''}),
|
||||
compareRevisionVersions:(a,b)=>(a?.rev||0)-(b?.rev||0)||String(a?.revAuthor||a?.author||'').localeCompare(String(b?.revAuthor||b?.author||'')),
|
||||
newerRevisionValue:(a,b)=>context.compareRevisionVersions(a,b)>=0?a:b,
|
||||
trustedNow:()=>1234,
|
||||
globalForStorage:(source,updatedAt)=>({...source,metas:undefined,states:undefined,updatedAt,clockFloor:updatedAt}),
|
||||
mergeGlobalRecords:(stored,incoming)=>context.compareRevisionVersions(
|
||||
{rev:incoming.globalRev,revAuthor:incoming.globalRevAuthor},
|
||||
{rev:stored?.globalRev,revAuthor:stored?.globalRevAuthor}
|
||||
)>=0?incoming:stored,
|
||||
applyGlobalRecordToData:record=>Object.assign(data,structuredClone(record)),
|
||||
staleWorldEpochError:()=>Object.assign(new Error('stale world'),{code:'STALE_WORLD_EPOCH'}),
|
||||
metaRowsForStorage:ids=>ids.map(rowId=>data.metas[rowId]&&structuredClone(data.metas[rowId])).filter(Boolean),
|
||||
stateRowsForStorage:ids=>ids.map(rowId=>data.states[rowId]&&({id:rowId,value:structuredClone(data.states[rowId])})).filter(Boolean),
|
||||
snapshotForStorage:()=>({}),writeCompactMirror:()=>true,
|
||||
openWorldDb:async()=>({transaction:()=>({objectStore:name=>stores[name],abort(){this.aborted=true}})}),
|
||||
transactionDone:async()=>{},requestValue:async req=>req.result,
|
||||
updateStorageRevision:()=>{},scheduleMirrorCheckpoint:()=>{},
|
||||
normalizeMeta:(_id,value)=>structuredClone(value),normalizeState:value=>structuredClone(value||{paths:[],rev:0,revAuthor:''}),
|
||||
metaFromV2Records:(_index,puzzle)=>puzzle?.meta?structuredClone(puzzle.meta):null,
|
||||
stateFromV2Record:(_index,state)=>state?.value?structuredClone(state.value):null,
|
||||
puzzleRecordV2:(meta,epoch)=>({epoch,id:meta.id,metaRev:meta.rev||0,meta:structuredClone(meta)}),
|
||||
stateRecordV2:(rowId,state,epoch)=>({epoch,id:rowId,stateRev:state?.rev||0,value:structuredClone(state)}),
|
||||
summarizeBoardV2:(meta,state,epoch)=>({epoch,id:meta.id,metaRev:meta.rev||0,stateRev:state?.rev||0}),
|
||||
fieldBoundsFromMetas:()=>({minX:0,minY:0,maxX:1,maxY:1}),SAVE_SCHEMA:31,WORLD_GENERATION:'current-only-test',
|
||||
clearRecoveryJournalIfCovered:()=>{},broadcastWorldSignal:()=>{},scheduleCloudPush:()=>{},
|
||||
refreshWorldView:()=>{context.refreshes++},refreshes:0,
|
||||
perfStart:()=>0,perfEnd:()=>{},perfGauge:()=>{}
|
||||
};
|
||||
vm.createContext(context);
|
||||
vm.runInContext(`${persistSource}\nthis.persistDirtyToDb=persistDirtyToDb;`,context);
|
||||
return{context,stores,id};
|
||||
}
|
||||
|
||||
async function verifyPersistenceConflicts(){
|
||||
{
|
||||
const localMeta={id:'B0',x:1,rev:5,revAuthor:'local'},localState={paths:[],rev:5,revAuthor:'local'};
|
||||
const existingMeta={id:'B0',x:7,rev:10,revAuthor:'remote'},existingState={paths:[[1]],rev:11,revAuthor:'remote'};
|
||||
const {context,stores}=persistenceContext({localMeta,localState,existingMeta,existingState});
|
||||
await context.persistDirtyToDb();
|
||||
assert(stores.boardPuzzles.rows.get('world:test:B0').meta.x===7&&stores.boardStates.rows.get('world:test:B0').value.rev===11,
|
||||
'A stale tab overwrote a newer database row');
|
||||
assert(context.data.metas.B0.x===7&&context.data.states.B0.rev===11&&context.refreshes===1,
|
||||
'A stale in-memory board was not reconciled to the committed winner');
|
||||
assert(context.data.cursorStyle==='remote'&&context.data.nextId===9,
|
||||
'A stale global record overwrote the newer global winner');
|
||||
}
|
||||
{
|
||||
const existingMeta={id:'B0',x:4,rev:20,revAuthor:'remote'},existingState={paths:[],rev:21,revAuthor:'remote'};
|
||||
const {context,stores}=persistenceContext({
|
||||
existingMeta,existingState,deletion:{rev:15,revAuthor:'local'}
|
||||
});
|
||||
await context.persistDirtyToDb();
|
||||
assert(stores.boardPuzzles.rows.has('world:test:B0')&&stores.boardStates.rows.has('world:test:B0')&&!stores.tombstonesV2.rows.has('world:test:B0'),
|
||||
'A stale deletion removed a newer database board');
|
||||
assert(context.data.metas.B0?.rev===20&&context.data.states.B0?.rev===21&&context.refreshes===1,
|
||||
'A rejected stale deletion was not restored in the losing tab');
|
||||
}
|
||||
{
|
||||
const existingMeta={id:'B0',rev:20,revAuthor:'remote'},existingState={paths:[],rev:20,revAuthor:'remote'};
|
||||
const {context,stores}=persistenceContext({
|
||||
existingMeta,existingState,deletion:{rev:25,revAuthor:'local'}
|
||||
});
|
||||
await context.persistDirtyToDb();
|
||||
assert(!stores.boardPuzzles.rows.has('world:test:B0')&&!stores.boardStates.rows.has('world:test:B0'),
|
||||
'A newer deletion did not remove the older board rows');
|
||||
assert(stores.tombstonesV2.rows.get('world:test:B0')?.rev===25,
|
||||
'A committed deletion did not leave a durable tombstone');
|
||||
}
|
||||
{
|
||||
const localMeta={id:'B0',rev:5,revAuthor:'local'};
|
||||
const {context}=persistenceContext({localMeta,identityEpoch:'world:replacement'});
|
||||
let error=null;try{await context.persistDirtyToDb()}catch(caught){error=caught}
|
||||
assert(error?.code==='STALE_WORLD_EPOCH'&&context.dirtyMetaIds.has('B0'),
|
||||
'A stale tab was allowed to write into a replaced world epoch');
|
||||
}
|
||||
}
|
||||
|
||||
function verifyGlobalMerge(){
|
||||
const context={
|
||||
deepClone:value=>structuredClone(value),structuredClone,
|
||||
validWorldEpoch:value=>typeof value==='string'&&value.startsWith('world:'),
|
||||
compareRevisionVersions:(a,b)=>(a?.rev||0)-(b?.rev||0)||String(a?.revAuthor||'').localeCompare(String(b?.revAuthor||'')),
|
||||
bonusEventTotal:events=>Object.values(events||{}).reduce((sum,value)=>sum+(value||0),0)
|
||||
};
|
||||
vm.createContext(context);
|
||||
vm.runInContext(`${functionSource('mergeGlobalRecords')}\nthis.mergeGlobalRecords=mergeGlobalRecords;`,context);
|
||||
const merged=context.mergeGlobalRecords(
|
||||
{worldEpoch:'world:test',globalRev:30,globalRevAuthor:'remote',cursorStyle:'remote',nextId:8,clockFloor:11,cloudRevision:3,bonusEvents:{A:4},quarantine:{B:{failedAt:5}},lastSolveAt:10,specialMechanicsSeen:['warp'],combo:2},
|
||||
{worldEpoch:'world:test',globalRev:20,globalRevAuthor:'local',cursorStyle:'local',nextId:12,clockFloor:15,cloudRevision:9,bonusEvents:{A:2,C:7},quarantine:{B:{failedAt:8}},lastSolveAt:14,specialMechanicsSeen:['lock'],combo:4}
|
||||
);
|
||||
assert(merged.cursorStyle==='remote'&&merged.nextId===12&&merged.clockFloor===15&&merged.cloudRevision===9,
|
||||
'Global LWW and monotonic fields were not merged independently');
|
||||
assert(merged.bonusEvents.A===4&&merged.bonusEvents.C===7&&merged.bonusScore===11,
|
||||
'Global additive bonus records were lost during a concurrent merge');
|
||||
assert(merged.quarantine.B.failedAt===8&&merged.lastSolveAt===14&&!Object.prototype.hasOwnProperty.call(merged,'combo')&&merged.specialMechanicsSeen.join(',')==='lock,warp',
|
||||
'Global timestamped records, encounter memory, or retired combo data were merged incorrectly');
|
||||
}
|
||||
|
||||
async function verifySignalRetry(){
|
||||
const timers=[];let attempts=0,remembered=0;
|
||||
const context={
|
||||
console:{warn:()=>{}},sessionId:'self',worldInitReady:false,deferredWorldSignals:[],
|
||||
seenWorldCommitIds:new Set(),pendingWorldCommitIds:new Set(),syncQueue:Promise.resolve(),
|
||||
worldCommitId:signal=>signal.commitId||'',applyWorldSignal:async()=>{attempts++;if(attempts===1)throw new Error('transient')},
|
||||
rememberWorldCommit:()=>remembered++,
|
||||
setTimeout:callback=>{timers.push(callback);return timers.length}
|
||||
};
|
||||
vm.createContext(context);
|
||||
vm.runInContext(`${functionSource('queueWorldSignal')}\n${functionSource('drainWorldSignals')}\nthis.queueWorldSignal=queueWorldSignal;this.drainWorldSignals=drainWorldSignals;`,context);
|
||||
const signal={commitId:'remote:1',sessionId:'remote'};
|
||||
context.queueWorldSignal(signal);
|
||||
assert(context.deferredWorldSignals.length===1&&attempts===0,'A startup signal ran before initialization');
|
||||
context.drainWorldSignals();context.queueWorldSignal(signal);
|
||||
await context.syncQueue;
|
||||
assert(attempts===1&&remembered===0&&timers.length===1,
|
||||
'A failed signal was deduplicated as if it had succeeded');
|
||||
timers.shift()();await Promise.resolve();await context.syncQueue;
|
||||
assert(attempts===2&&remembered===1,'A transient signal failure was not retried and committed once');
|
||||
}
|
||||
|
||||
async function verifyDurableBackup(){
|
||||
const source=functionSource('preserveRecoveryDurably');
|
||||
const failing={
|
||||
Date,safeLocalSet:()=>false,sessionStorage:{setItem(){throw new Error('blocked')}},
|
||||
persistRecoveryEnvelope:async()=>false,volatileRecovery:null,recoveryStorageKey:'recovery'
|
||||
};
|
||||
vm.createContext(failing);vm.runInContext(`${source}\nthis.preserveRecoveryDurably=preserveRecoveryDurably;`,failing);
|
||||
let rejected=false;try{await failing.preserveRecoveryDurably('save','reset')}catch(_){rejected=true}
|
||||
assert(rejected,'Destructive replacement proceeded without any durable backup');
|
||||
const database={
|
||||
Date,safeLocalSet:()=>false,sessionStorage:{setItem(){throw new Error('blocked')}},
|
||||
persistRecoveryEnvelope:async()=>true,volatileRecovery:null,recoveryStorageKey:'recovery'
|
||||
};
|
||||
vm.createContext(database);vm.runInContext(`${source}\nthis.preserveRecoveryDurably=preserveRecoveryDurably;`,database);
|
||||
const result=await database.preserveRecoveryDurably('save','reset');
|
||||
assert(!result.browserCopy&&result.databaseCopy,'A verified database-only backup was not accepted');
|
||||
}
|
||||
|
||||
function verifyRevisionSeed(){
|
||||
const context={
|
||||
lastRevision:3,data:{},recoveredDeletionTombstones:new Map([['B9',{rev:81}]])
|
||||
};
|
||||
vm.createContext(context);
|
||||
vm.runInContext(`${functionSource('seedRevisionClock')}\nthis.seedRevisionClock=seedRevisionClock;`,context);
|
||||
const maximum=context.seedRevisionClock({
|
||||
globalRev:50,clockFloor:0,metas:{B0:{rev:60}},states:{B0:{rev:70}}
|
||||
});
|
||||
assert(maximum===81&&context.lastRevision===81,'The revision clock was not seeded from all persisted row types');
|
||||
}
|
||||
|
||||
(async()=>{
|
||||
await verifyPersistenceConflicts();
|
||||
verifyGlobalMerge();
|
||||
await verifySignalRetry();
|
||||
await verifyDurableBackup();
|
||||
verifyRevisionSeed();
|
||||
console.log('Concurrency and replacement safety test passed');
|
||||
})().catch(error=>{console.error(error);process.exitCode=1});
|
||||
22
test/economy-simulation-test.js
Normal file
22
test/economy-simulation-test.js
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
'use strict';
|
||||
const {assert,loadAppLogic}=require('./helpers/app-source');
|
||||
const AppLogic=loadAppLogic(),samples=[],storeCoefficients=[];
|
||||
for(let seed=1;seed<=5000;seed++){
|
||||
const level=1+(seed%10),baseScore=20+level*level*4,meta={id:`B${seed}`,seed,generatorVersion:5},
|
||||
state={paths:[{startGate:0,endGate:1,cells:[[0,0],[0,1],[1,1],[1,2]]}]},
|
||||
reward=AppLogic.deterministicBoardReward(baseScore,{worldSeed:0x51a7f00d,meta,state});
|
||||
assert(reward.coefficient>=.8&&reward.coefficient<=1.2,'Board coefficient escaped its bound');
|
||||
samples.push(reward.award);
|
||||
const prices=[700,1400,1800,2400].map(base=>AppLogic.deterministicStorePrice(base,0x51a7f00d,seed%97,Math.floor(seed/97),1));
|
||||
assert(prices.every(entry=>entry.coefficient===prices[0].coefficient),'Products in one store received different coefficients');
|
||||
storeCoefficients.push(prices[0].coefficient);
|
||||
}
|
||||
const mean=values=>values.reduce((sum,value)=>sum+value,0)/values.length,
|
||||
rewardMean=mean(samples),storeMean=mean(storeCoefficients);
|
||||
assert(storeMean>.99&&storeMean<1.01,'Store coefficient distribution is materially biased');
|
||||
assert(rewardMean>100&&rewardMean<230,'Reward rebalance left the measured progression band');
|
||||
assert(700<1400&&1400<1800&&1800<2400,'Cosmetics/lens/field price ladder is invalid');
|
||||
assert(1400/rewardMean>5&&1400/rewardMean<15,'Score lens is either trivial or effectively mandatory');
|
||||
assert(2400/rewardMean<24,'MAX field is outside a meaningful attainable score-sink range');
|
||||
assert(AppLogic.deterministicBoardReward(100,{meta:{id:'B1',seed:1,generatorVersion:5},state:{paths:[]},scoreLensCount:100}).lensMultiplier===1.5,'Repeated lenses create unbounded score inflation');
|
||||
console.log(`Economy simulation passed: mean reward ${rewardMean.toFixed(2)}, mean coefficient ${storeMean.toFixed(4)}`);
|
||||
38
test/expansion-repair-smoke-test.js
Normal file
38
test/expansion-repair-smoke-test.js
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
'use strict';
|
||||
const {vm,assert,functionSource}=require('./helpers/app-source');
|
||||
|
||||
const state={solved:true,expanded:false,expansionRetryRound:0,rev:0};
|
||||
const meta={id:'B0',puzzle:null,x:0,y:0};
|
||||
const chosen=[],attemptBases=[];
|
||||
let revision=0,missing=true,hydrations=0;
|
||||
const context={
|
||||
metaState:()=>state,
|
||||
hydrateMeta:async target=>{hydrations++;target.puzzle={}},
|
||||
hydrateAdjacentMetas:async()=>{},
|
||||
rebuildOccupancy:()=>{},
|
||||
repairFacingGateConnections:()=>{},
|
||||
unresolvedExpansionCandidates:()=>[{name:'north'},{name:'east'}],
|
||||
placeChildAtFrontierAttempt:async(_meta,frontier,attemptBase)=>{
|
||||
chosen.push(frontier.name);attemptBases.push(attemptBase);return null;
|
||||
},
|
||||
missingGateConnections:()=>missing?[1]:[],
|
||||
nextRevision:()=>++revision,
|
||||
markStateDirty:()=>{}
|
||||
};
|
||||
vm.createContext(context);
|
||||
vm.runInContext(`${functionSource('repairMetaFrontierNow')}\nthis.repairMetaFrontierNow=repairMetaFrontierNow;`,context);
|
||||
|
||||
(async()=>{
|
||||
await context.repairMetaFrontierNow(meta);
|
||||
await context.repairMetaFrontierNow(meta);
|
||||
await context.repairMetaFrontierNow(meta);
|
||||
assert(chosen.join(',')==='north,east,north','Expansion repair stayed on one linear frontier instead of rotating fairly');
|
||||
assert(hydrations===1,'Expansion repair skipped or repeatedly hydrated an off-screen compact board');
|
||||
assert(new Set(attemptBases).size===3,'Expansion repair repeated a deterministic generation seed');
|
||||
assert(state.expansionRetryRound===3&&!state.expanded,'Incomplete expansion repair did not persist its retry round');
|
||||
|
||||
missing=false;
|
||||
await context.repairMetaFrontierNow(meta);
|
||||
assert(state.expanded&&state.expansionRetryRound===0,'Completed expansion repair did not clear its retry state');
|
||||
console.log('Fair expansion-frontier retry test passed');
|
||||
})().catch(error=>{console.error(error);process.exitCode=1});
|
||||
91
test/expansion-smoke-test.js
Normal file
91
test/expansion-smoke-test.js
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
'use strict';
|
||||
const {vm,assert,functionSource,loadBendPuzzle,loadAppLogic,starterPuzzle}=require('./helpers/app-source');
|
||||
const BendPuzzle=loadBendPuzzle(),AppLogic=loadAppLogic();
|
||||
const calls={refresh:0,render:0,hud:0,paint:0,save:0,sync:0};
|
||||
const testWatchdog=setTimeout(()=>{console.error('Expansion integration exceeded 45 seconds');process.exit(1)},45000);testWatchdog.unref?.();
|
||||
const context={console,BendPuzzle,AppLogic,GENERATOR_VERSION:5,UNIQUE_SOLUTION_MIN_LEVEL:6,deepClone:value=>structuredClone(value),structuredClone,hash32:BendPuzzle.hash32,rngFrom:BendPuzzle.rngFrom,shuffle:BendPuzzle.shuffle,generatedShapeKey:AppLogic.generatedShapeKey,generatedShapeFamilyKey:AppLogic.generatedShapeFamilyKey,balancedShapeCandidates:AppLogic.balancedShapeCandidates,growConnectedShape:AppLogic.growConnectedShape,macroDifficulty:BendPuzzle.macroDifficulty,difficultyFitsRegion:BendPuzzle.difficultyFitsRegion,solverDifficulty:BendPuzzle.solverDifficulty,SHAPES:BendPuzzle.SHAPES,H_PORT_PROFILES:BendPuzzle.H_PORT_PROFILES,V_PORT_PROFILES:BendPuzzle.V_PORT_PROFILES,horizontalBoundaryKey:BendPuzzle.horizontalBoundaryKey,verticalBoundaryKey:BendPuzzle.verticalBoundaryKey,key2:(x,y)=>`${x},${y}`,ckey:(r,c)=>`${r},${c}`,SIDE_D:{N:[-1,0],S:[1,0],W:[0,-1],E:[0,1]},OPP:{N:'S',S:'N',W:'E',E:'W'},data:{metas:{},states:{},nextId:1,specialMechanicsSeen:[]},occupancy:new Map(),closedVoidKeys:new Set(),adjacencyCache:new Map(),areaStoreEffects:()=>({forcedLevel:null,scoreLens:false}),targetLevelForNewBoard:(x,y)=>context.macroDifficulty(x,y),collectFieldEffectSources:()=>[],sleep:async()=>{},addObstaclePattern:p=>p,addSpecialCellPattern:p=>p,generatedPuzzleIssue:()=>null,generatePuzzleAsync:async(chunks,seed,level,x,y,timeout,options)=>BendPuzzle.generatePuzzle(chunks,seed,level,x,y,options),verifyPuzzleUniquenessAsync:async()=>({status:'unique',signature:'test',ruleVersion:2,nodes:1,quality:{score:80,accepted:true}}),mechanicTypesForPuzzle:()=>[],nextRevision:(()=>{let n=1;return()=>++n})(),markMetaDirty:()=>{},markStateDirty:()=>{},ensureBoards:()=>{},renderAll:()=>{calls.render++},updateHud:()=>{calls.hud++},nextPaint:async()=>{calls.paint++},save:async()=>{calls.save++;return true},hideStatus:()=>{},showStatus:()=>{},hydrateAdjacentMetas:async()=>{},repairFacingGateConnections:()=>0,syncBoundaryConnections:()=>{calls.sync++;return 0},puzzleOf:meta=>meta.puzzle,matchingNeighborGate:()=>null,metaAtGlobalCell:(r,c)=>{const id=context.occupancy.get(context.key2(Math.floor(c/5),Math.floor(r/5)));return id?context.data.metas[id]||null:null},globalCell:(meta,cell)=>[meta.y*5+cell[0],meta.x*5+cell[1]],sectionCountRange:AppLogic.sectionCountRange};
|
||||
context.addMetaToOccupancy=meta=>{for(const[dx,dy]of meta.chunks)context.occupancy.set(context.key2(meta.x+dx,meta.y+dy),meta.id);context.adjacencyCache.clear();return true};
|
||||
context.refreshWorldView=options=>{calls.refresh++;if(options?.syncConnections!==false)context.syncBoundaryConnections();context.renderAll();context.updateHud();if(options?.hide)context.hideStatus();return options?.persist?context.save(options.immediate):true};
|
||||
context.rebuildOccupancy=()=>{context.occupancy=new Map();context.closedVoidKeys=new Set();for(const meta of Object.values(context.data.metas))for(const[dx,dy]of meta.chunks)context.occupancy.set(context.key2(meta.x+dx,meta.y+dy),meta.id);const candidates=new Set();for(const key of context.occupancy.keys()){const[x,y]=key.split(',').map(Number);for(const[dr,dc]of Object.values(context.SIDE_D))candidates.add(context.key2(x+dc,y+dr))}for(const key of candidates){const[x,y]=key.split(',').map(Number);if(!context.occupancy.has(key)&&[...Object.values(context.SIDE_D)].every(([dr,dc])=>context.occupancy.has(context.key2(x+dc,y+dr))))context.closedVoidKeys.add(key)}};
|
||||
context.metaState=id=>context.data.states[id]||(context.data.states[id]={solved:false,expanded:false,paths:[],specialProgress:{crossings:[]},rev:0});
|
||||
context.unsolvedBoardCount=()=>Object.keys(context.data.metas).filter(id=>!context.metaState(id).solved).length;
|
||||
vm.createContext(context);
|
||||
vm.runInContext(`
|
||||
const SHAPES_BY_SIZE=new Map();for(const shape of SHAPES){const size=shape.length;if(!SHAPES_BY_SIZE.has(size))SHAPES_BY_SIZE.set(size,[]);SHAPES_BY_SIZE.get(size).push(shape)}
|
||||
${['cellSet','gateAtCell','gateConnectionAllowed','matchingNeighborGate','shapeCandidatesForLevel','nearbyShapeFamilyCounts','shapeCandidatesForArea','occupiedNeighborCount','isClosedVoidUnit','fitsMeta','placedShapeKeys','unitOccupiedWithExtra','isClosedVoidWithExtra','openUnitFrontiers','shapeFitsWithExtra','viableFuturePlacementAt','viableExpansionExists','prospectiveShapeHasFrontier','placementPreservesUnsolvedFrontiers','frontierCandidates','gateFrontierCandidates','unresolvedExpansionCandidates','closedVoidRepairCandidates','missingGateConnections','placementConnectionRequirements','puzzleSupportsConnectionRequirements','sameNumberList','fixedPortProfilesForRequirements','installPreparedChild','placeChildAtFrontierAttempt','frontierGeometryStillViable','placeChildAtFrontier','expandMetaNow'].map(name=>functionSource(name)).join('\n')}
|
||||
const boundedPlaceChildAtFrontier=async(source,frontier,attemptBase=0)=>{for(let cycle=0;cycle<3;cycle++){const made=await placeChildAtFrontierAttempt(source,frontier,attemptBase+cycle*2000003);if(made)return made}return null};
|
||||
placeChildAtFrontier=boundedPlaceChildAtFrontier;
|
||||
this.logic={gateFrontierCandidates,unresolvedExpansionCandidates,closedVoidRepairCandidates,missingGateConnections,placementConnectionRequirements,puzzleSupportsConnectionRequirements,openUnitFrontiers,viableExpansionExists,placementPreservesUnsolvedFrontiers,placeChildAtFrontierAttempt,placeChildAtFrontier,expandMetaNow};`,context);
|
||||
const starter=starterPuzzle();
|
||||
const origin={id:'B0',x:0,y:0,chunks:[[0,0]],seed:0x51a7f00d,puzzle:starter,sealedSides:[],rev:1};context.data.metas.B0=origin;context.data.states.B0={solved:true,expanded:false,paths:starter.solution,specialProgress:{crossings:[]},rev:1};context.rebuildOccupancy();
|
||||
(async()=>{
|
||||
const targets=context.logic.gateFrontierCandidates(origin);assert(targets.length===4,'Origin does not expose four procedural directions');
|
||||
const started=Date.now(),made=await context.logic.expandMetaNow(origin),elapsed=Date.now()-started,remaining=context.logic.unresolvedExpansionCandidates(origin);
|
||||
assert(made>=4,`Full expansion generated only ${made} fields`);assert(remaining.length===0,`Full expansion left ${remaining.length} frontiers`);assert(context.data.states.B0.expanded===true,'Origin was not marked fully expanded');
|
||||
const metaAtUnit=(unitX,unitY)=>Object.values(context.data.metas).find(meta=>meta.chunks.some(([dx,dy])=>meta.x+dx===unitX&&meta.y+dy===unitY));
|
||||
for(const gate of origin.puzzle.g){
|
||||
const[dr,dc]=context.SIDE_D[gate[2]],globalRow=origin.y*5+gate[0],globalCol=origin.x*5+gate[1],neighborRow=globalRow+dr,neighborCol=globalCol+dc,
|
||||
neighbor=metaAtUnit(Math.floor(neighborCol/5),Math.floor(neighborRow/5));
|
||||
assert(neighbor&&neighbor.id!==origin.id,`Origin gate ${gate.join(',')} has no generated neighbor`);
|
||||
const localRow=neighborRow-neighbor.y*5,localCol=neighborCol-neighbor.x*5,
|
||||
matching=neighbor.puzzle.g.find(candidate=>candidate[0]===localRow&&candidate[1]===localCol&&candidate[2]===context.OPP[gate[2]]);
|
||||
assert(matching,`Origin gate ${gate.join(',')} generated a board without the matching opposite gate`);
|
||||
assert(!(neighbor.sealedSides||[]).includes(context.OPP[gate[2]]),`Matching gate for ${gate.join(',')} was sealed`);
|
||||
}
|
||||
assert(context.logic.missingGateConnections(origin).length===0,'Origin was marked expanded without an actual facing board for every gate');
|
||||
for(const meta of Object.values(context.data.metas).filter(meta=>meta.id!=='B0')){const range=AppLogic.sectionCountRange(meta.level);assert(meta.chunks.length>=range.min&&meta.chunks.length<=range.max,`${meta.id}: level ${meta.level} has ${meta.chunks.length} sections outside ${range.min}-${range.max}`);assert(context.logic.viableExpansionExists(meta),`${meta.id}: newly generated board has no viable future expansion placement`)}
|
||||
assert(elapsed<8000,`Origin expansion took ${elapsed} ms`);
|
||||
assert(calls.refresh===1&&calls.render===1&&calls.hud===1&&calls.save===1&&calls.paint===1,`Expansion was not committed once: ${JSON.stringify(calls)}`);
|
||||
const branch=Object.values(context.data.metas).find(meta=>meta.id!=='B0');context.data.states[branch.id].solved=true;context.data.states[branch.id].expanded=false;const beforeBranchIds=new Set(Object.keys(context.data.metas)),branchStarted=Date.now(),branchMade=await context.logic.expandMetaNow(branch),branchElapsed=Date.now()-branchStarted;
|
||||
assert(branchMade>0,`${branch.id}: solved child did not generate a branch`);const branchRemaining=context.logic.gateFrontierCandidates(branch);assert(context.data.states[branch.id].expanded===(branchRemaining.length===0),`${branch.id}: expanded flag does not match remaining gate targets`);assert(Object.keys(context.data.metas).some(id=>!beforeBranchIds.has(id)),`${branch.id}: no adjacent field was added`);
|
||||
for(const meta of Object.values(context.data.metas))if(!context.metaState(meta.id).solved)assert(context.logic.viableExpansionExists(meta),`${meta.id}: branch expansion stranded an unsolved board`);
|
||||
assert(branchElapsed<15000,`${branch.id}: branch expansion took ${branchElapsed} ms`);
|
||||
let chained=0;
|
||||
for(let step=0;step<8;step++){
|
||||
const candidates=Object.values(context.data.metas).filter(meta=>!context.metaState(meta.id).solved&&context.logic.viableExpansionExists(meta));
|
||||
assert(candidates.length,`Expansion chain stopped at step ${step}`);let generated=0,candidate=null;
|
||||
for(const option of candidates){context.metaState(option.id).solved=true;context.metaState(option.id).expanded=false;generated=await context.logic.expandMetaNow(option);candidate=option;if(generated>0)break}
|
||||
assert(candidate,`Expansion chain had no candidate at step ${step}`);chained+=generated;if(context.metaState(candidate.id).expanded)assert(context.logic.missingGateConnections(candidate).length===0,`${candidate.id}: expanded despite a missing facing gate at step ${step}`);
|
||||
for(const meta of Object.values(context.data.metas))if(!context.metaState(meta.id).solved)assert(context.logic.viableExpansionExists(meta),`${meta.id}: expansion chain created a non-expandable board`);
|
||||
}
|
||||
const source100=BendPuzzle.generatePuzzle([[0,0]],0x7a1100,6,100,100);context.data={metas:{S:{id:'S',x:100,y:100,chunks:[[0,0]],seed:0x7a1100,puzzle:source100,sealedSides:[],rev:1}},states:{S:{solved:true,expanded:false,paths:source100.solution,specialProgress:{crossings:[]},rev:1}},nextId:100};context.rebuildOccupancy();context.macroDifficulty=()=>6;
|
||||
const child=await context.logic.placeChildAtFrontier(context.data.metas.S,{unitX:101,unitY:100,side:'E',targetSide:'W',contacts:[]},0);
|
||||
assert(child,'Level-6 normal frontier could not generate a board');const range=AppLogic.sectionCountRange(child.targetLevel);assert(child.chunks.length>=range.min&&child.chunks.length<=range.max,'Higher-level board violates its authoritative regional section range');assert(child.chunks.length>1,'Higher-level multi-section generation still depends on a retired special mode');assert(!('labyrinth' in child)&&child.anomaly!=='giant','Retired special-board metadata was generated');assert(context.logic.viableExpansionExists(child),'Higher-level child was generated as a dead end');
|
||||
const originalGenerate=context.generatePuzzleAsync;
|
||||
vm.runInContext('this.originalShapeCandidatesForLevel=shapeCandidatesForLevel;this.originalShapes=SHAPES;shapeCandidatesForLevel=()=>[[[0,0],[1,0]]];SHAPES=[[[0,0],[1,0]]];',context);
|
||||
const fallbackPuzzle=BendPuzzle.generatePuzzle([[0,0]],0x4187,3,300,300),fallbackSource={id:'F',x:300,y:300,chunks:[[0,0]],seed:0x4187,puzzle:fallbackPuzzle,sealedSides:[],rev:1};context.data={metas:{F:fallbackSource},states:{F:{solved:true,expanded:false,paths:fallbackPuzzle.solution,specialProgress:{crossings:[]},rev:1}},nextId:700};context.rebuildOccupancy();context.macroDifficulty=()=>6;context.generatePuzzleAsync=async(chunks,seed,level,x,y,timeout,options)=>{if(chunks.length>1)throw new Error('synthetic unsupported shape');return BendPuzzle.generatePuzzle(chunks,seed,level,x,y,options)};
|
||||
const fallbackEast=fallbackPuzzle.g.findIndex(g=>g[2]==='E'),fallbackGate=fallbackPuzzle.g[fallbackEast],[fdr,fdc]=context.SIDE_D.E,fgr=fallbackSource.y*5+fallbackGate[0],fgc=fallbackSource.x*5+fallbackGate[1],fallbackChild=await context.logic.placeChildAtFrontierAttempt(fallbackSource,{unitX:Math.floor((fgc+fdc)/5),unitY:Math.floor((fgr+fdr)/5),side:'E',targetSide:'W',contacts:[{gateIndex:fallbackEast}],gateDriven:true},0);
|
||||
assert(fallbackChild===null,'Invalid single-section fallback was forced into a level requiring multiple sections');assert(Object.keys(context.data.metas).length===1&&context.data.metas.F===fallbackSource,'Rejected fallback mutated the world');assert(context.data.nextId===700,'Rejected fallback consumed a board id');
|
||||
context.generatePuzzleAsync=originalGenerate;vm.runInContext('shapeCandidatesForLevel=originalShapeCandidatesForLevel;SHAPES=originalShapes;',context);
|
||||
const multiShape=[[0,0],[1,0]],multiPuzzle=BendPuzzle.generatePuzzle(multiShape,987654,3,12,-9),multiSource={id:'M',x:12,y:-9,chunks:multiShape,seed:987654,puzzle:multiPuzzle,sealedSides:['N','S','W'],rev:1};context.data={metas:{M:multiSource},states:{M:{solved:true,expanded:false,paths:multiPuzzle.solution,specialProgress:{crossings:[]},rev:1}},nextId:800};context.rebuildOccupancy();context.macroDifficulty=()=>3;
|
||||
const multiMade=await context.logic.expandMetaNow(multiSource);assert(multiMade>0,'Clearing a multi-section board did not unlock any new board');assert(context.unsolvedBoardCount()>0,'Multi-section clear left no playable board');if(context.metaState('M').expanded)assert(context.logic.missingGateConnections(multiSource).length===0,'Multi-section board was marked expanded with missing gate neighbors');
|
||||
const requirementPuzzle=BendPuzzle.generatePuzzle([[0,0]],77,1,50,50),requirementSource={id:'R',x:50,y:50,chunks:[[0,0]],seed:77,puzzle:requirementPuzzle,sealedSides:[],rev:1};context.data={metas:{R:requirementSource},states:{R:{solved:true,expanded:false,paths:requirementPuzzle.solution,specialProgress:{crossings:[]},rev:1}},nextId:500};context.rebuildOccupancy();
|
||||
const eastGateIndex=requirementPuzzle.g.findIndex(g=>g[2]==='E'),eastGate=requirementPuzzle.g[eastGateIndex],[edr,edc]=context.SIDE_D.E,egr=requirementSource.y*5+eastGate[0],egc=requirementSource.x*5+eastGate[1],targetX=Math.floor((egc+edc)/5),targetY=Math.floor((egr+edr)/5),requirements=context.logic.placementConnectionRequirements(targetX,targetY,[[0,0]]),validChild=BendPuzzle.generatePuzzle([[0,0]],123,1,targetX,targetY),invalidChild=JSON.parse(JSON.stringify(validChild));invalidChild.g=invalidChild.g.filter(g=>!(g[0]===egr+edr-targetY*5&&g[1]===egc+edc-targetX*5&&g[2]==='W'));
|
||||
assert(requirements.some(req=>req.metaId==='R'&&req.gateIndex===eastGateIndex),'Proposed placement did not capture the source gate requirement');assert(context.logic.puzzleSupportsConnectionRequirements(validChild,targetX,targetY,requirements),'A deterministic matching child was rejected');assert(!context.logic.puzzleSupportsConnectionRequirements(invalidChild,targetX,targetY,requirements),'A child without the facing gate was accepted');
|
||||
const regenPuzzle=BendPuzzle.generatePuzzle([[0,0]],0x471100,1,600,600),regenSource={id:'Q',x:600,y:600,chunks:[[0,0]],seed:0x471100,puzzle:regenPuzzle,sealedSides:[],rev:1};context.data={metas:{Q:regenSource},states:{Q:{solved:true,expanded:false,paths:regenPuzzle.solution,specialProgress:{crossings:[]},rev:1}},nextId:900};context.rebuildOccupancy();context.macroDifficulty=()=>1;
|
||||
const regenEast=regenPuzzle.g.findIndex(g=>g[2]==='E'),regenGate=regenPuzzle.g[regenEast],[rdr,rdc]=context.SIDE_D.E,rgr=regenSource.y*5+regenGate[0],rgc=regenSource.x*5+regenGate[1],regenOptions=[],regenSpecialSeeds=[];let integrityChecks=0;
|
||||
const regenOriginalGenerate=context.generatePuzzleAsync,regenOriginalSpecial=context.addSpecialCellPattern,regenOriginalIssue=context.generatedPuzzleIssue;
|
||||
context.generatePuzzleAsync=async(chunks,seed,level,x,y,timeout,options)=>{regenOptions.push(options||null);return BendPuzzle.generatePuzzle(chunks,seed,level,x,y,options)};context.addSpecialCellPattern=(puzzle,seed)=>{regenSpecialSeeds.push(seed);return puzzle};context.generatedPuzzleIssue=()=>integrityChecks++===0?'synthetic impossible board':null;
|
||||
vm.runInContext('this.regenOriginalShapeCandidates=shapeCandidatesForLevel;shapeCandidatesForLevel=()=>[[[0,0]]];',context);
|
||||
const regenerated=await context.logic.placeChildAtFrontier(regenSource,{unitX:Math.floor((rgc+rdc)/5),unitY:Math.floor((rgr+rdr)/5),side:'E',targetSide:'W',contacts:[{gateIndex:regenEast}],gateDriven:true},0);
|
||||
assert(regenerated,'Whole-board regeneration did not recover after a rejected completed candidate');assert(regenOptions.length>=2&®enSpecialSeeds.length>=2,'Rejected board did not rerun routing and special-cell generation');assert(regenOptions[0]?.portSeed==null&&Number.isInteger(regenOptions[1]?.portSeed),'Unconfirmed gate positions were not rerolled after rejection');assert(regenSpecialSeeds[0]!==regenSpecialSeeds[1],'Special-cell seed was not rerolled after rejection');
|
||||
context.generatePuzzleAsync=regenOriginalGenerate;context.addSpecialCellPattern=regenOriginalSpecial;context.generatedPuzzleIssue=regenOriginalIssue;vm.runInContext('shapeCandidatesForLevel=regenOriginalShapeCandidates;',context);
|
||||
context.data.metas.U={id:'U',x:200,y:200,chunks:[[0,0]],seed:9,puzzle:starter,sealedSides:[],rev:1};context.data.states.U={solved:false,expanded:false,paths:[],specialProgress:{crossings:[]},rev:1};context.data.metas.N={id:'N',x:199,y:200,chunks:[[0,0]],seed:10,puzzle:starter,sealedSides:[],rev:1};context.data.states.N={solved:true,expanded:true,paths:[],specialProgress:{crossings:[]},rev:1};context.data.metas.S2={id:'S2',x:200,y:201,chunks:[[0,0]],seed:11,puzzle:starter,sealedSides:[],rev:1};context.data.states.S2={solved:true,expanded:true,paths:[],specialProgress:{crossings:[]},rev:1};context.data.metas.W2={id:'W2',x:200,y:199,chunks:[[0,0]],seed:12,puzzle:starter,sealedSides:[],rev:1};context.data.states.W2={solved:true,expanded:true,paths:[],specialProgress:{crossings:[]},rev:1};context.rebuildOccupancy();assert(context.logic.openUnitFrontiers(context.data.metas.U).length===1,'Synthetic board does not have exactly one remaining frontier');assert(!context.logic.placementPreservesUnsolvedFrontiers(201,200,[[0,0]]),"Placement was allowed to consume an unsolved board's final frontier");
|
||||
const closedPuzzle=BendPuzzle.generatePuzzle([[0,0]],0x47c105ed,3,900,900),closedSource={id:'C',x:900,y:900,chunks:[[0,0]],seed:0x47c105ed,puzzle:closedPuzzle,sealedSides:[],rev:1},closedGateIndex=0,closedGate=closedPuzzle.g[closedGateIndex],[cdr,cdc]=context.SIDE_D[closedGate[2]],closedGlobalRow=closedSource.y*5+closedGate[0],closedGlobalCol=closedSource.x*5+closedGate[1],closedX=Math.floor((closedGlobalCol+cdc)/5),closedY=Math.floor((closedGlobalRow+cdr)/5);
|
||||
context.data={metas:{C:closedSource},states:{C:{solved:true,expanded:false,paths:closedPuzzle.solution,specialProgress:{crossings:[]},rev:1}},nextId:1000,specialMechanicsSeen:[]};
|
||||
let blockerIndex=0;for(const[dx,dy]of[[0,-1],[0,1],[-1,0],[1,0]]){const x=closedX+dx,y=closedY+dy;if(x===closedSource.x&&y===closedSource.y)continue;const id=`V${blockerIndex++}`;context.data.metas[id]={id,x,y,chunks:[[0,0]],seed:blockerIndex,puzzle:null,sealedSides:[],rev:1};context.data.states[id]={solved:true,expanded:true,paths:[],specialProgress:{crossings:[]},rev:1}}
|
||||
context.rebuildOccupancy();context.macroDifficulty=()=>10;
|
||||
const closedFrontier=context.logic.gateFrontierCandidates(closedSource).find(candidate=>candidate.unitX===closedX&&candidate.unitY===closedY);
|
||||
assert(closedFrontier?.terminalFill,'A missing gate inside a one-section enclosed gap was still discarded');
|
||||
const closedChild=await context.logic.placeChildAtFrontier(closedSource,closedFrontier,0);
|
||||
assert(closedChild&&closedChild.chunks.length===1&&context.occupancy.get(context.key2(closedX,closedY))===closedChild.id,'The enclosed field gap was not repaired with a generated puzzle');
|
||||
const ungatedX=1201,ungatedY=1200,ungatedSource={id:'G',x:1200,y:1200,chunks:[[0,0]],seed:0x47c105ee,puzzle:BendPuzzle.generatePuzzle([[0,0]],0x47c105ee,3,1200,1200),sealedSides:['E'],rev:1};
|
||||
context.data={metas:{G:ungatedSource},states:{G:{solved:true,expanded:true,paths:ungatedSource.puzzle.solution,specialProgress:{crossings:[]},rev:1}},nextId:1100,specialMechanicsSeen:[]};
|
||||
blockerIndex=0;for(const[dx,dy]of[[0,-1],[0,1],[-1,0],[1,0]]){const x=ungatedX+dx,y=ungatedY+dy;if(x===ungatedSource.x&&y===ungatedSource.y)continue;const id=`W${blockerIndex++}`;context.data.metas[id]={id,x,y,chunks:[[0,0]],seed:blockerIndex,puzzle:null,sealedSides:[],rev:1};context.data.states[id]={solved:true,expanded:true,paths:[],specialProgress:{crossings:[]},rev:1}}
|
||||
context.rebuildOccupancy();context.macroDifficulty=()=>8;
|
||||
const ungatedRepair=context.logic.closedVoidRepairCandidates().find(candidate=>candidate.frontier.unitX===ungatedX&&candidate.frontier.unitY===ungatedY);
|
||||
assert(ungatedRepair?.frontier.closedVoidRepair&&!ungatedRepair.frontier.gateDriven,'A closed occupancy hole without an open gate was not detected');
|
||||
const ungatedChild=await context.logic.placeChildAtFrontier(ungatedRepair.source,ungatedRepair.frontier,0);
|
||||
assert(ungatedChild&&ungatedChild.chunks.length===1&&ungatedChild.sealedSides.includes('N')&&ungatedChild.sealedSides.includes('S')&&ungatedChild.sealedSides.includes('W')&&ungatedChild.sealedSides.includes('E'),`Ungated enclosed gap was not generated as a sealed puzzle: ${JSON.stringify(ungatedChild&&{chunks:ungatedChild.chunks,sealedSides:ungatedChild.sealedSides})}`);
|
||||
console.log(`Expansion integration passed: ${made} origin fields in ${elapsed} ms; ${branchMade} branch fields; ${chained} chained fields; level ${child.level} normal board has ${child.chunks.length} sections`);
|
||||
})().catch(error=>{console.error(error);process.exitCode=1}).finally(()=>clearTimeout(testWatchdog));
|
||||
32
test/field-persistence-browser-test.html
Normal file
32
test/field-persistence-browser-test.html
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
<!doctype html>
|
||||
<meta charset="utf-8">
|
||||
<title>Field persistence browser test</title>
|
||||
<body data-ready="running"></body>
|
||||
<script src="../field-persistence.js"></script>
|
||||
<script>
|
||||
(async()=>{
|
||||
const api=globalThis.BendFieldPersistence,assert=(condition,message)=>{if(!condition)throw new Error(message)};
|
||||
const chunks=[];let closed=false;
|
||||
const writable=new WritableStream({write(chunk){chunks.push(new Uint8Array(chunk).slice())},close(){closed=true}});
|
||||
const boards=[
|
||||
{id:'B0',meta:{id:'B0',seed:1},state:{solved:true}},
|
||||
{id:'B2',meta:{id:'B2',seed:2},state:{solved:false}}
|
||||
];
|
||||
const written=await api.writeArchive({
|
||||
writable,
|
||||
manifest:{saveSchema:31,gameplayVersion:3,worldGeneration:'test-world',appVersion:'test',generatorVersion:1,exportedAt:'2026-01-01T00:00:00.000Z',boardCount:boards.length,estimatedRawBytes:512,encoding:'ndjson',compression:'identity'},
|
||||
globalState:{bonusEvents:{}},
|
||||
boards,
|
||||
onProgress(){}
|
||||
});
|
||||
assert(closed,'archive output did not close');assert(written.boardCount===2,'archive board count is wrong');
|
||||
const file=new File(chunks,'roundtrip.bfsave',{type:'application/x-bend-field-save'}),seen=[];
|
||||
const summary=await api.inspectArchive(file);assert(summary.boardCount===2&&summary.compression==='identity','archive inspection failed');
|
||||
const read=await api.readArchive(file,{onBoard(record){seen.push(record.id)}});
|
||||
assert(read.boardCount===2&&seen.join(',')==='B0,B2','archive round trip failed');
|
||||
const bytes=new Uint8Array(await file.arrayBuffer()),text=new TextDecoder().decode(bytes),tampered=text.replace('"crc32":"','"crc32":"f');
|
||||
let corruptRejected=false;try{await api.readArchive(new File([tampered],'corrupt.bfsave'))}catch(error){corruptRejected=error.code==='CHECKSUM_MISMATCH'}
|
||||
assert(corruptRejected,'corrupt archive was accepted');
|
||||
document.body.dataset.ready='pass';document.body.textContent=JSON.stringify({written,seen});
|
||||
})().catch(error=>{document.body.dataset.ready='fail';document.body.textContent=error.stack||String(error)});
|
||||
</script>
|
||||
74
test/field-persistence-smoke-test.js
Normal file
74
test/field-persistence-smoke-test.js
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
'use strict';
|
||||
const path=require('path');
|
||||
const vm=require('vm');
|
||||
const {assert,root,read}=require('./helpers/app-source');
|
||||
|
||||
delete global.BendFieldPersistence;
|
||||
require(path.join(root,'field-persistence.js'));
|
||||
const persistence=global.BendFieldPersistence;
|
||||
assert(persistence&&persistence.ARCHIVE_VERSION===2,'Archive persistence module did not initialize');
|
||||
|
||||
const manifest={
|
||||
saveSchema:31,gameplayVersion:3,worldGeneration:'test-world',appVersion:'47.36',generatorVersion:1,
|
||||
exportedAt:'2026-07-28T00:00:00.000Z',boardCount:2,estimatedRawBytes:4096,encoding:'ndjson',compression:'identity'
|
||||
};
|
||||
const globalState={gameplayVersion:3,bonusEvents:{},fieldEffects:[],specialMechanicsSeen:[],clockFloor:0,lastSolveAt:0,timeAttack:null,timeAttackCooldowns:{3:0,5:0,10:0},lastTimeAttack:null};
|
||||
const boardRows=[
|
||||
{id:'B0',meta:{id:'B0',x:0,y:0,chunks:[[0,0]],puzzle:{g:[[0,0,0],[0,1,2]],n:[],valid:[[0,0],[0,1]],solution:[]}},state:{paths:[],specialProgress:{crossings:[]},solved:false,expanded:false,scoreAwarded:0}},
|
||||
{id:'B1',meta:{id:'B1',x:1,y:0,chunks:[[0,0]],puzzle:{g:[[0,0,0],[0,1,2]],n:[],valid:[[0,0],[0,1]],solution:[]}},state:{paths:[],specialProgress:{crossings:[]},solved:false,expanded:false,scoreAwarded:0}}
|
||||
];
|
||||
async function* boards(){for(const row of boardRows)yield structuredClone(row)}
|
||||
async function encode(compression,module=persistence){
|
||||
const chunks=[];
|
||||
const writable=new WritableStream({write(chunk){chunks.push((chunk instanceof Uint8Array?chunk:new Uint8Array(chunk)).slice())}});
|
||||
const result=await module.writeArchive({writable,manifest:{...manifest,compression},globalState:structuredClone(globalState),boards:boards()});
|
||||
return{result,blob:new Blob(chunks,{type:'application/x-bend-field-save'}),bytes:Buffer.concat(chunks.map(chunk=>Buffer.from(chunk)))};
|
||||
}
|
||||
async function decode(blob,module=persistence){
|
||||
const seen=[];
|
||||
const result=await module.readArchive(blob,{onBoard:record=>seen.push(record)});
|
||||
return{result,seen};
|
||||
}
|
||||
(async()=>{
|
||||
for(const compression of ['identity','gzip']){
|
||||
if(compression==='gzip'&&typeof CompressionStream!=='function')continue;
|
||||
const first=await encode(compression),second=await encode(compression),decoded=await decode(first.blob);
|
||||
assert(first.result.boardCount===2&&decoded.result.boardCount===2&&decoded.seen.map(row=>row.id).join(',')==='B0,B1',`${compression} archive did not round-trip`);
|
||||
assert(first.result.crc32===decoded.result.footer.crc32,`${compression} archive checksum changed during round-trip`);
|
||||
assert(first.bytes.equals(second.bytes),`${compression} archive output is not deterministic for identical records`);
|
||||
}
|
||||
const identity=await encode('identity'),text=identity.bytes.toString('utf8'),modified=text.replace('"x":1','"x":2');
|
||||
assert(modified!==text,'Archive corruption fixture did not modify a board record');
|
||||
let checksumRejected=false;
|
||||
try{await decode(new Blob([modified]))}catch(error){checksumRejected=error?.code==='CHECKSUM_MISMATCH'}
|
||||
assert(checksumRejected,'Archive checksum corruption was not rejected');
|
||||
let truncationRejected=false;
|
||||
try{await decode(new Blob([text.split('\n').slice(0,-2).join('\n')+'\n']))}catch(error){truncationRejected=/footer|incomplete/i.test(error?.message||'')}
|
||||
assert(truncationRejected,'Truncated archive was not rejected');
|
||||
let manifestRejected=false;
|
||||
try{await persistence.inspectArchive(new Blob([text.replace('\"encoding\":\"ndjson\"','\"encoding\":\"json\"')]))}catch(error){manifestRejected=error?.code==='UNSUPPORTED_ARCHIVE'}
|
||||
assert(manifestRejected,'Unsupported archive encoding was not rejected at inspection time');
|
||||
const lines=text.trimEnd().split('\n');
|
||||
const expectArchiveFailure=async(candidate,pattern,label)=>{let rejected=false;try{await decode(new Blob([candidate]))}catch(error){rejected=pattern.test(`${error?.code||''} ${error?.message||''}`)}assert(rejected,label)};
|
||||
const reordered=[...lines];[reordered[2],reordered[3]]=[reordered[3],reordered[2]];await expectArchiveFailure(`${reordered.join('\n')}\n`,/order|B0/i,'Reordered archive boards were accepted');
|
||||
const duplicate=[...lines];duplicate[3]=duplicate[2];await expectArchiveFailure(`${duplicate.join('\n')}\n`,/duplicated|order/i,'Duplicate archive board IDs were accepted');
|
||||
const unknown=[...lines],unknownRecord=JSON.parse(unknown[3]);unknownRecord.type='mystery';unknown[3]=JSON.stringify(unknownRecord);await expectArchiveFailure(`${unknown.join('\n')}\n`,/unknown|misplaced/i,'Unknown archive records were accepted');
|
||||
const wrongFooter=[...lines],footer=JSON.parse(wrongFooter[4]);footer.rawBytes++;wrongFooter[4]=JSON.stringify(footer);await expectArchiveFailure(`${wrongFooter.join('\n')}\n`,/CHECKSUM|integrity/i,'Incorrect footer byte counts were accepted');
|
||||
const wrongCount=[...lines],declared=JSON.parse(wrongCount[0]);declared.boardCount=1;wrongCount[0]=JSON.stringify(declared);await expectArchiveFailure(`${wrongCount.join('\n')}\n`,/more boards|count/i,'Incorrect manifest board counts were accepted');
|
||||
const aborted=new AbortController();aborted.abort();let canceled=false;try{await persistence.readArchive(identity.blob,{signal:aborted.signal})}catch(error){canceled=error?.name==='AbortError'}assert(canceled,'Archive cancellation was not acknowledged');
|
||||
let writeFailed=false;try{await persistence.writeArchive({writable:new WritableStream({write(){throw new Error('injected destination failure')}}),manifest:{...manifest},globalState:structuredClone(globalState),boards:boards()})}catch(_){writeFailed=true}assert(writeFailed,'Destination write failure did not abort export');
|
||||
if(typeof CompressionStream==='function'&&typeof DecompressionStream==='function'){const compressed=await encode('gzip'),damaged=Buffer.from(compressed.bytes);damaged[Math.max(10,Math.floor(damaged.length/2))]^=0xff;let gzipRejected=false;try{await decode(new Blob([damaged]))}catch(_){gzipRejected=true}assert(gzipRejected,'Corrupt gzip archive was accepted')}
|
||||
const worker=read('field-persistence-worker.js'),moduleSource=read('field-persistence.js');
|
||||
class FakeWorker{
|
||||
constructor(){this.onmessage=null;this.onerror=null;const owner=this,context={TextEncoder,Uint8Array,Uint32Array,JSON,Error,self:{postMessage(data){queueMicrotask(()=>owner.onmessage?.({data}))}}};vm.createContext(context);vm.runInContext(worker,context);this.workerSelf=context.self}
|
||||
postMessage(data){queueMicrotask(()=>{try{this.workerSelf.onmessage({data:structuredClone(data)})}catch(error){this.onerror?.({message:error.message})}})}
|
||||
terminate(){}
|
||||
}
|
||||
global.Worker=FakeWorker;delete global.BendFieldPersistence;delete require.cache[require.resolve(path.join(root,'field-persistence.js'))];require(path.join(root,'field-persistence.js'));const workerPersistence=global.BendFieldPersistence;
|
||||
const workerEncoded=await encode('identity',workerPersistence),workerDecoded=await decode(workerEncoded.blob,workerPersistence);
|
||||
assert(workerEncoded.result.worker===true&&workerDecoded.result.worker===true&&workerDecoded.seen.length===2,'Archive worker execution path did not round-trip records');
|
||||
delete global.Worker;global.BendFieldPersistence=persistence;
|
||||
assert(worker.includes('MAX_CHUNK_BYTES=1024*1024')&&worker.includes('postMessage({id,chunks,rawBytes,crc32:crc32Hex()},chunks)'),'Archive worker does not enforce transferable 1 MiB output chunks');
|
||||
assert(moduleSource.includes("new Worker('field-persistence-worker.js?v=47.77')")&&moduleSource.includes('item.byteLength>MAX_ARCHIVE_LINE_BYTES')&&moduleSource.includes('WORKER_TARGET_BYTES=1024*1024')&&moduleSource.includes('pendingLineBytes'),'Archive worker path, 1 MiB batching, or worker line limit is missing');
|
||||
console.log('Field archive round-trip and corruption checks passed');
|
||||
})().catch(error=>{console.error(error);process.exitCode=1});
|
||||
38
test/field-save-load-v2-smoke-test.js
Normal file
38
test/field-save-load-v2-smoke-test.js
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
'use strict';
|
||||
const {assert,functionSource,app,read}=require('./helpers/app-source');
|
||||
const openDb=functionSource('openWorldDb');
|
||||
const loadV2=functionSource('loadV2SnapshotFromDb');
|
||||
const persistence=functionSource('persistDirtyToDb');
|
||||
const stageArchive=functionSource('stageArchiveV2');
|
||||
const stageRecovery=functionSource('stageRecoverySnapshotV2');
|
||||
const activation=functionSource('activateReadyWorldV2');
|
||||
const reset=functionSource('clearDatabaseWorld');
|
||||
const verify=functionSource('verifyActiveWorldActivation');
|
||||
const readiness=functionSource('validateActiveWorldReadiness');
|
||||
const validation=functionSource('validateStagedWorldV2');
|
||||
const collect=functionSource('collectV2Garbage');
|
||||
const batchDelete=functionSource('deleteV2EpochBatch');
|
||||
const persistNow=functionSource('persistNow');
|
||||
const init=functionSource('init');
|
||||
const importSave=functionSource('importSaveFile');
|
||||
const activateStaged=functionSource('activateStagedWorldV2');
|
||||
const postflight=functionSource('verifyStagedStorageEstimate');
|
||||
|
||||
assert(openDb.includes('activeFormat:FIELD_STORAGE_FORMAT')&&openDb.includes("source:{kind:'fresh'}")&&!openDb.includes('activeFormat:1'),'Fresh storage does not start directly in the current V2 format');
|
||||
assert(openDb.includes("for(const retired of['metas','states','global','recovery','tombstones','outbox'])")&&openDb.includes('db.deleteObjectStore(retired)'),'Retired object stores are not deleted during the current-only upgrade');
|
||||
assert(!persistence.includes("objectStore('metas')")&&!persistence.includes("objectStore('states')")&&!persistence.includes("objectStore('outbox')")&&!persistence.includes("objectStore('recovery')")&&!persistence.includes("objectStore('tombstones')"),'Persistence still dual-writes the retired storage format');
|
||||
assert(!init.includes('migrateLoadedWorldToV2')&&!importSave.includes('migrateLoadedWorldToV2'),'Runtime still invokes the retired storage migration');
|
||||
assert(!app.includes('legacyCost')&&!app.includes('migrateScoreAmount')&&!app.includes('loadedFromLegacy')&&!app.includes("kind:'migration'")&&!app.includes('full-resync'),'Retired score, startup, or cloud migration compatibility remains');
|
||||
assert(functionSource('assertCurrentArchive').includes('manifest?.saveSchema!==SAVE_SCHEMA')&&functionSource('assertCurrentArchive').includes('manifest?.worldGeneration!==WORLD_GENERATION'),'Archive import does not require the exact current format');
|
||||
assert(!app.includes('archiveMigrationRegistry')&&!app.includes('PREVIOUS_WORLD_GENERATION')&&!importSave.includes('looksJson')&&!importSave.includes('.json'),'Old-generation or legacy JSON import compatibility remains');
|
||||
assert(stageArchive.includes('validateStagedWorldV2')&&stageArchive.includes('verifyStagedStorageEstimate')&&stageArchive.includes('archive global record is not canonical')&&stageArchive.includes("status:'ready'")&&stageRecovery.includes('validateStagedWorldV2')&&stageRecovery.includes('verifyStagedStorageEstimate')&&stageRecovery.includes("status:'ready'"),'Current archive/recovery staging skips canonical, database, or storage validation');
|
||||
assert(validation.includes('boardIndexMatchesDetails')&&validation.includes('count(IDBKeyRange.only(epoch))')&&validation.includes('global aggregates')&&validation.includes('originOwnedByB0'),'Staged validation does not verify detail revisions, row counts, summaries, aggregates, and B0 origin ownership');
|
||||
assert(activation.includes("olderPrevious.status='garbage'")&&activation.includes("currentWorld.status='rollback'")&&activation.includes('activationVerified:false')&&reset.includes("activateReadyWorldV2({epoch:newEpoch,expected,world},{kind:'reset'})")&&reset.includes('deleteV2Epoch(newEpoch)')&&activateStaged.includes('deleteV2Epoch(staged?.epoch)'),'Import and reset do not share the atomic ready-world activation primitive or clean failed staging epochs');
|
||||
assert(readiness.includes("indexes[0].id!=='B0'")&&readiness.includes('B0 revision check')&&readiness.includes('meta.x+dx===0')&&init.includes('rollbackUnverifiedWorld'),'Startup does not validate B0 origin ownership and roll back an unverified activation');
|
||||
assert(persistNow.includes('result.count>0')&&persistNow.includes('verifyActiveWorldActivation')&&verify.includes("previous.status='garbage'"),'Previous epoch is not retained until the first durable board checkpoint');
|
||||
assert(batchDelete.includes('getAllKeys(epochKeyRange(epoch),limit)')&&batchDelete.includes("phase:'cleanup'")&&app.includes('GC_BATCH_ROWS=500'),'Epoch garbage collection is not bounded and resumable');
|
||||
assert(collect.includes('control?.previousEpoch')&&collect.includes("startsWith('pin:')")&&collect.includes("world.status==='ready'")&&collect.includes('cleanupTemporaryExports'),'Garbage collection does not protect rollback/recovery epochs or clean stale ready/export artifacts');
|
||||
assert(postflight.includes('QUOTA_POSTFLIGHT'),'Import postflight does not recheck browser storage headroom');
|
||||
assert(app.includes('cameraAnchor')&&app.includes('selectedBoardId')&&init.includes('restoreSavedCamera')&&loadV2.includes('selectedIndex'),'Saved viewport and selected-board startup hints are not restored before the full index scan');
|
||||
assert(persistence.includes('v2Active')&&read('test/browser-field-storage-benchmark.js').includes("'10000,100000,200000'"),'Current V2 persistence or large-field benchmark profiles are missing');
|
||||
console.log('Current-only field save/load V2 integration guards passed');
|
||||
143
test/gameplay-simplification-smoke-test.js
Normal file
143
test/gameplay-simplification-smoke-test.js
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
'use strict';
|
||||
const {vm,app,html,css,worker,assert,functionSource,loadBendPuzzle,loadAppLogic}=require('./helpers/app-source');
|
||||
const AppLogic=loadAppLogic(),BendPuzzle=loadBendPuzzle();
|
||||
|
||||
// 1. Combo/undo removal and legacy omission.
|
||||
const retiredFieldCleanup=functionSource('mergeGlobalRecords'),activeGameplaySource=app.replace(retiredFieldCleanup,'');
|
||||
assert(!/\bcombo\b/i.test(activeGameplaySource)&&!/\bundo\b/i.test(activeGameplaySource)&&!/\b(combo|undo)\b/i.test(html),'Combo or visible undo remains in active gameplay/UI source');
|
||||
assert(/\bcombo\b/.test(retiredFieldCleanup)&&/\bundo\b/.test(retiredFieldCleanup),'Legacy combo/undo fields are not explicitly removed during synchronization');
|
||||
assert(!functionSource('globalForStorage').includes('combo')&&!functionSource('globalForStorage').includes('undo'),'Retired fields are still written');
|
||||
|
||||
// 2. Seeded score/store economy invariants.
|
||||
for(let seed=0;seed<500;seed++){
|
||||
const coefficient=AppLogic.deterministicStorePrice(1000,seed,4,-7,1).coefficient;
|
||||
assert(coefficient>=.8&&coefficient<=1.2,'Seeded coefficient escaped 0.8-1.2');
|
||||
const first=AppLogic.deterministicStorePrice(1000,seed,4,-7,1),second=AppLogic.deterministicStorePrice(1000,seed,4,-7,1);
|
||||
assert(JSON.stringify(first)===JSON.stringify(second),'Store price rerolled for one location');
|
||||
}
|
||||
const lensReward=count=>AppLogic.deterministicBoardReward(100,{meta:{id:'B1',seed:1,generatorVersion:5},state:{paths:[]},scoreLensCount:count}).lensMultiplier;
|
||||
assert(lensReward(0)===1&&lensReward(1)===1.25&&lensReward(2)===1.4&&lensReward(99)===1.5,'Score-lens diminishing cap is invalid');
|
||||
assert(functionSource('rewardDetailsForBoard').includes('timeAttackRewardModifier()')&&functionSource('recordTimeAttackScore').includes('baseCollected')&&functionSource('finishTimeAttack').includes('if(result.bonus>0)'),'Time-attack reward modifier is deferred or double-applied');
|
||||
|
||||
// 3. Difficulty-field feature removal and lightweight detailed-board rendering.
|
||||
assert(typeof AppLogic.winningLevelField==='undefined'&&typeof AppLogic.visibleFieldUnits==='undefined','Retired difficulty-field helpers remain exported');
|
||||
assert(!app.includes('level-min-10')&&!app.includes('level-max-10')&&!app.includes('fieldOverlayCanvas')&&!html.includes('fieldPreviewCanvas'),'Difficulty adjustment items or overlays remain active');
|
||||
assert(functionSource('makeBoard').includes('boardCellsPath')&&!functionSource('makeBoard').includes('cellHits')&&functionSource('desiredInteractiveBoardIds').includes('for(const id of ids)'),'Visible detailed boards are missing or still allocate per-cell hit nodes');
|
||||
|
||||
// 4-6. Center snapping without sticky hysteresis/speculative turns, and silent invalid motion.
|
||||
for(const marker of ['rawPointerPosition','currentConfirmedCell','candidateDirection','currentCandidateCell','renderedHandlePosition'])assert(functionSource('drawingForPath').includes(marker),`Pointer state is missing ${marker}`);
|
||||
assert(app.includes('POINTER_SNAP_THRESHOLD=CELL*.45')&&!app.includes('POINTER_SNAP_RELEASE')&&!app.includes('hysteresisDragPoint'),'Single-threshold pointer snapping or hysteresis removal is incomplete');
|
||||
assert(app.includes('POINTER_DOMINANT_RATIO=1.25')&&!app.includes('POINTER_BUFFER_MS')&&!app.includes('consumeBufferedPointerTurn'),'Speculative one-step turn extension remains active');
|
||||
assert(functionSource('pointerEventSamples').includes("pointerType!=='mouse'")&&functionSource('processBoardDragFrame').includes('const move=b.pendingPointerMove'),'Ordered pointer samples are not preserved through a drag frame');
|
||||
assert(!functionSource('extendOne').includes('toast(')&&functionSource('extendOne').includes("if(lock&&!pathHasLockKey(path,lock))return false"),'Invalid direction still produces error feedback');
|
||||
assert(functionSource('openTipMergePlan').includes('tipDistance!==0')&&!app.includes('function pickupJoinStepsAtPoint'),'Adjacent path pickups can still auto-connect');
|
||||
assert(functionSource('openTipMergePlan').includes('seen.has(key)')&&functionSource('joinTips').includes('cancelBoardDragFrame(b)')&&functionSource('joinTips').includes('safeRelease(b.svg,pointerId)'),'Pickup merging can duplicate an overlapping route or retain pointer work after joining');
|
||||
|
||||
// 7. Level schedule and introduction memory.
|
||||
for(let level=1;level<=4;level++)assert(AppLogic.specialSchedule(level,100,level).types.length===0,`Level ${level} scheduled a special`);
|
||||
for(const level of[5,6]){const schedule=AppLogic.specialSchedule(level,100,level,['warp','lock','crossing'],[]);assert(schedule.types.length===1&&schedule.setCount===1,`Level ${level} did not schedule exactly one minimum type`)}
|
||||
const introduction=AppLogic.specialSchedule(9,200,77,['warp'],[]);
|
||||
assert(introduction.types.length===1&&introduction.setCount===1&&introduction.introduction,'First encounter is not isolated at minimum density');
|
||||
assert(functionSource('addSpecialCellPattern').includes('AppLogic.specialSchedule')&&!app.includes('SPECIAL_CELL_PRODUCTION_CHANCE'),'Legacy independent special chance remains active');
|
||||
assert(functionSource('generatedPuzzleIssue').includes("crossingCoverage.has(key)?2:1"),'Generated crossing boards are rejected by single-owner coverage validation');
|
||||
|
||||
const plain={
|
||||
valid:[[0,0],[0,1]],g:[[0,0,'W'],[0,1,'E']],n:[[0,0,0]],obstacles:[],
|
||||
specialCells:{crossings:[],warps:[],locks:[]},solution:[{startGate:0,endGate:1,cells:[[0,0],[0,1]]}]
|
||||
};
|
||||
const multiple={
|
||||
valid:[[0,0],[0,1],[1,0],[1,1]],g:[[0,0,'S'],[0,1,'S'],[1,0,'N'],[1,1,'N']],n:[[0,0,2],[1,1,2]],obstacles:[],
|
||||
specialCells:{crossings:[],warps:[],locks:[]},solution:[{startGate:0,endGate:1,cells:[[0,0],[0,1]]},{startGate:2,endGate:3,cells:[[1,0],[1,1]]}]
|
||||
};
|
||||
const crossing={
|
||||
valid:[[1,0],[1,1],[1,2],[0,1],[2,1]],g:[[1,0,'W'],[1,2,'E'],[0,1,'N'],[2,1,'S']],n:[[1,0,0],[0,1,0]],obstacles:[],
|
||||
specialCells:{crossings:[[1,1]],warps:[],locks:[]},solution:[{startGate:0,endGate:1,cells:[[1,0],[1,1],[1,2]]},{startGate:2,endGate:3,cells:[[0,1],[1,1],[2,1]]}]
|
||||
};
|
||||
const warp={
|
||||
valid:[[0,0],[0,2]],g:[[0,0,'W'],[0,2,'E']],n:[[0,0,0]],obstacles:[],
|
||||
specialCells:{crossings:[],warps:[{a:[0,0],b:[0,2]}],locks:[]},solution:[{startGate:0,endGate:1,cells:[[0,0],[0,2]]}]
|
||||
};
|
||||
const lock={
|
||||
...multiple,
|
||||
specialCells:{crossings:[],warps:[],locks:[{key:[0,0],door:[0,1]}]}
|
||||
};
|
||||
|
||||
// 8. Worker-oriented uniqueness outcomes and deterministic fixtures.
|
||||
for(const fixture of[plain,crossing,warp,lock]){
|
||||
const first=BendPuzzle.verifyPuzzleUniqueness(fixture,{maxMs:1000,nodeCap:10000}),second=BendPuzzle.verifyPuzzleUniqueness(fixture,{maxMs:1000,nodeCap:10000});
|
||||
assert(first.status==='unique',`Expected unique ${JSON.stringify(first)}`);
|
||||
assert(first.status===second.status&&first.signature===second.signature,'Uniqueness result is not stable');
|
||||
}
|
||||
assert(BendPuzzle.verifyPuzzleUniqueness(multiple,{maxMs:1000,nodeCap:10000}).status==='multiple','Known multi-solution fixture was accepted');
|
||||
assert(BendPuzzle.verifyPuzzleUniqueness({...plain,n:[[0,0,1]]},{maxMs:1000,nodeCap:10000}).status==='unsolved','Known unsolved fixture was not reported');
|
||||
assert(BendPuzzle.verifyPuzzleUniqueness(multiple,{maxMs:1000,nodeCap:1}).status==='timeout','Node-cap timeout was not reported');
|
||||
assert(worker.includes("action === 'verify'")&&functionSource('verifyPuzzleUniquenessAsync').includes("action:'verify'")&&!functionSource('verifyPuzzleUniquenessAsync').includes('verifyPuzzleUniqueness('),'Uniqueness is not worker-isolated');
|
||||
|
||||
// 9. Session suggestion.
|
||||
assert(functionSource('scheduleTimeAttackSuggestionAfterCompletion').includes('sessionCompletionCount!==10')&&functionSource('tryShowTimeAttackSuggestion').includes('7000')&&html.includes('id="timeAttackSuggestion" role="status" aria-live="polite"'),'Completion-10 suggestion timing/live region is missing');
|
||||
|
||||
// 10. Minimap teleport and controls.
|
||||
assert(!html.includes('id="teleportBtn"')&&html.includes('id="minimapOriginBtn"')&&html.includes('id="minimapRandomBtn"')&&!html.includes('id="minimapUnsolvedBtn"')&&!html.includes('id="minimapCurrentBtn"'),'Minimap teleport controls do not match origin/random');
|
||||
assert(functionSource('centerRandomBoard').includes('getRandomValues')&&!app.includes('centerRandomUnsolved'),'Random minimap teleport still cycles through unsolved boards');
|
||||
assert(functionSource('beginMinimapPointer').includes('centerWorldUnit')&&functionSource('moveMinimapPointer').includes('centerWorldUnit')&&html.includes('id="minimapCanvas" width="210" height="132" role="application" tabindex="0"'),'Direct/keyboard minimap interaction is missing');
|
||||
assert(functionSource('centerWorldUnit').includes('worldNavigationBounds'),'Minimap teleport is not clamped to world navigation bounds');
|
||||
assert(functionSource('inWorldOverview').includes('cam.scale<=OVERVIEW_ZOOM_THRESHOLD')&&!app.includes('OVERVIEW_HYSTERESIS')&&css.includes('.static-summary-fill{fill:transparent!important'),'Zoomed board summaries retain sticky green/gray squares');
|
||||
assert(!functionSource('drawWorldOverview').includes('drawWorldOverviewPaths')&&functionSource('rebuildWorldOverviewCache').includes('drawMapLongLines'),'Far zoom does not use the minimap long-line renderer');
|
||||
assert(functionSource('makeStaticBoard').includes('renderedConnectedLineWidth(meta,index)')&&!functionSource('makeStaticBoard').includes('state.solved')&&css.includes('.static-summary-path{')&&css.includes('vector-effect:non-scaling-stroke;opacity:.9'),'Line thickness disappears from an unsolved zoomed-in board');
|
||||
assert(functionSource('rebuildWorldOverviewCache').includes('drawMapBoardCells')&&functionSource('rebuildWorldOverviewCache').includes('drawMapLongLines')&&!functionSource('rebuildWorldOverviewCache').includes('storeCellForMeta'),'Zoomed-out view no longer matches the minimap renderer');
|
||||
assert(functionSource('leftFieldPanAllowed').includes("classList?.contains('solved')")&&functionSource('beginPan').includes('leftFieldPanAllowed(e)'),'Solved/undiscovered left-drag field panning is missing');
|
||||
|
||||
const priceContext={MIN_STORE_ITEM_PRICE:3000,storePriceDetails:()=>({coefficient:.8})};
|
||||
vm.createContext(priceContext);
|
||||
vm.runInContext(`${functionSource('storeItemPrice')}\nthis.storeItemPrice=storeItemPrice;`,priceContext);
|
||||
assert(priceContext.storeItemPrice({},null,{cost:2500})===3000&&priceContext.storeItemPrice({},null,{cost:10000})===8000,'Store prices do not enforce a 3,000 minimum while retaining higher price variation');
|
||||
|
||||
const cursorItems=Array.from({length:12},(_,index)=>({id:`C${index}`,cursorStyle:`face-${index}`})),
|
||||
otherItems=Array.from({length:1},(_,index)=>({id:`O${index}`})),allStoreItems=[...cursorItems,...otherItems],
|
||||
shopContext={
|
||||
CURSOR_ITEMS:cursorItems,STORE_ITEMS:allStoreItems,
|
||||
hash32:BendPuzzle.hash32,rngFrom:BendPuzzle.rngFrom,shuffle:BendPuzzle.shuffle,
|
||||
storeItem:id=>allStoreItems.find(item=>item.id===id)||null
|
||||
};
|
||||
vm.createContext(shopContext);
|
||||
vm.runInContext(`${functionSource('normalizeStoreItemIds')}\n${functionSource('seededStoreItemIds')}\n${functionSource('storeInventoryItems')}\nthis.shop={normalizeStoreItemIds,seededStoreItemIds,storeInventoryItems};`,shopContext);
|
||||
const seedA=shopContext.shop.seededStoreItemIds(123456),seedARepeat=shopContext.shop.seededStoreItemIds(123456),seedB=shopContext.shop.seededStoreItemIds(654321),
|
||||
selected=seedA.map(shopContext.storeItem);
|
||||
assert(JSON.stringify(seedA)===JSON.stringify(seedARepeat)&&JSON.stringify(seedA)!==JSON.stringify(seedB),'Store inventory is not deterministic per field seed');
|
||||
assert(seedA.length===13&&new Set(seedA).size===13&&selected.filter(item=>item.cursorStyle).length===12&&selected.filter(item=>!item.cursorStyle).length===1,'Seeded store inventory is not exactly twelve cursors and one other item');
|
||||
assert(JSON.stringify(shopContext.shop.storeInventoryItems({seed:9},{itemIds:seedA}).map(item=>item.id))===JSON.stringify(seedA),'Persisted store inventory IDs are not honored');
|
||||
|
||||
// 11-12. Noise/reduced motion and bounded caches/worker.
|
||||
assert(html.includes('id="noiseCanvas" width="80" height="64"')&&functionSource('scheduleNoiseBackground').includes('noisePainted')&&!functionSource('scheduleNoiseBackground').includes('setTimeout')&&functionSource('paintNoiseBackground').includes("perfCount('noiseFrames')"),'Static low-cost noise background is missing');
|
||||
assert(css.includes('#noiseCanvas{')&&css.includes('opacity:.06'),'Noise effective opacity exceeds the low-contrast budget');
|
||||
assert(functionSource('evictHydratedBoardDetails').includes('hydratedBoardLru.size>32')&&functionSource('evictHydratedBoardDetails').includes('hydratedBoardBytes>8*1024*1024')&&worker.includes('uniquenessCache.size > 256'),'Hydration or uniqueness cache is unbounded/missing');
|
||||
|
||||
// 13-14. Contextual HUD and active-board isolation.
|
||||
assert(css.includes('#topbar.drawing-active')&&css.includes(':focus-within')&&css.includes('transition:opacity'),'Contextual HUD fading constraints are missing');
|
||||
assert(!css.includes('.board-card:not(.input-active) .static-layer')&&!css.includes('filter:saturate')&&!css.includes('backdrop-filter')&&!css.includes('drop-shadow'),'Non-selected boards are dimmed or global presentation still creates costly filter surfaces');
|
||||
|
||||
// 15. Skippable completion independent of persistence.
|
||||
const solveSource=functionSource('checkSolvedAndExpand');
|
||||
assert(solveSource.indexOf('completionEffect(immediateBoard,award)')<solveSource.indexOf('persistence=save(true)'),'Completion waits for persistence');
|
||||
assert(solveSource.includes('preparation=prepareExpansionCandidate(b.meta)')&&solveSource.includes('expandMeta(durableMeta,prepared)'),'Expansion generation does not start with the clear display or does not install against durable metadata');
|
||||
assert(solveSource.includes('playGemCollectionAnimation(immediateBoard,award)')&&functionSource('playGemCollectionAnimation').includes('gemCollectionSources')&&functionSource('playGemCollectionAnimation').includes('scoreCountEl'),'Clear rewards do not travel from the board to the gem wallet');
|
||||
assert(functionSource('skipCompletionVisuals').includes('finishCompletionVisual')&&functionSource('finishCompletionVisual').includes('visual.resolve'),'Completion visual is not independently skippable');
|
||||
assert(app.includes('MAX_FRONTIER_GENERATION_CYCLES=3')&&functionSource('placeChildAtFrontier').includes('cycle<MAX_FRONTIER_GENERATION_CYCLES'),'Expansion generation can enter an unbounded retry loop');
|
||||
assert(functionSource('expandMetaNow').includes('expansionRetryRound')&&functionSource('repairMetaFrontierNow').includes('round%frontiers.length')&&functionSource('repairExpansions').includes('repairMetaFrontier(meta)'),'Expansion repair repeats one deterministic seed/order and can leave permanent gaps');
|
||||
assert(!functionSource('repairExpansions').includes('&&meta.puzzle')&&!functionSource('pendingExpansionCount').includes('&&meta.puzzle'),'Off-screen compact solved boards are excluded from expansion repair');
|
||||
|
||||
// 16. Bounded hydration and memory use without hiding visible puzzle details.
|
||||
assert(functionSource('desiredInteractiveBoardIds').includes('for(const id of ids)')&&!app.includes('function promoteStaticBoard('),'Visible puzzles can still be replaced by on-demand summaries');
|
||||
assert(app.includes('HYDRATE_CONCURRENCY=4')&&functionSource('evictHydratedBoardDetails').includes('hydratedBoardLru.size>32')&&functionSource('evictHydratedBoardDetails').includes('hydratedBoardBytes>8*1024*1024')&&app.includes('BOARD_RENDERS_PER_FRAME=6'),'Hydration, cache, or render work is not bounded');
|
||||
assert(functionSource('drawPresenceLayer').includes('dpr=1')&&functionSource('drawReactionLayer').includes('dpr=1'),'Fullscreen online canvases still allocate high-DPR backing stores');
|
||||
assert(html.includes('id="fpsCounter"')&&functionSource('refreshFpsCounter').includes('FPS ${fpsLastValue}')&&app.includes('AUXILIARY_FPS=30')&&app.includes('DRAG_TARGET_FPS=60'),'FPS diagnostics or split interaction budgets are missing');
|
||||
assert(functionSource('scheduleNoiseBackground').includes('noisePainted')&&!functionSource('scheduleNoiseBackground').includes('setTimeout'),'Noise background still repaints continuously');
|
||||
|
||||
// 17. Bounded interaction burden in candidate selection.
|
||||
assert(AppLogic.interactionBurden(plain).score<=40&&functionSource('placeChildAtFrontierAttempt').includes('AppLogic.interactionBurden')&&functionSource('placeChildAtFrontierAttempt').includes('boundedBurdenPenalty'),'Interaction burden is unbounded or not integrated');
|
||||
assert(BendPuzzle.UNIQUENESS_RULE_VERSION===2&&BendPuzzle.verifyPuzzleUniqueness(lock,{maxMs:1000,nodeCap:10000}).quality?.contributingSpecials>=1,'Solution quality does not measure special contribution');
|
||||
|
||||
// 18. Encounter migration and synchronization.
|
||||
assert(functionSource('normalizeSnapshot').includes('mechanicTypesForPuzzle')&&functionSource('mergeGlobalRecords').includes('specialMechanicsSeen')&&functionSource('addSpecialCellPattern').includes('data.specialMechanicsSeen'),'Special encounter memory is not migrated, synchronized, and consumed');
|
||||
|
||||
console.log('Gameplay simplification regression groups 1-18 passed');
|
||||
46
test/helpers/app-source.js
Normal file
46
test/helpers/app-source.js
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
'use strict';
|
||||
const fs=require('fs');
|
||||
const path=require('path');
|
||||
const vm=require('vm');
|
||||
const root=path.resolve(__dirname,'../..');
|
||||
const read=name=>fs.readFileSync(path.join(root,name),'utf8');
|
||||
const app=read('app.js'),html=read('index.html'),css=read('style.css'),worker=read('puzzle-worker.js'),appLogicSource=read('app-logic.js');
|
||||
function assert(value,message){if(!value)throw new Error(message)}
|
||||
function functionSource(name,source=app){
|
||||
let start=source.indexOf(`async function ${name}(`);if(start<0)start=source.indexOf(`function* ${name}(`);if(start<0)start=source.indexOf(`function ${name}(`);
|
||||
assert(start>=0,`Missing ${name}`);
|
||||
let paren=source.indexOf('(',start),parenDepth=0,body=-1;
|
||||
for(let i=paren;i<source.length;i++){
|
||||
if(source[i]==='(')parenDepth++;
|
||||
else if(source[i]===')'&&--parenDepth===0){body=source.indexOf('{',i);break}
|
||||
}
|
||||
assert(body>=0,`Missing body for ${name}`);let depth=0;
|
||||
for(let i=body;i<source.length;i++){
|
||||
if(source[i]==='{')depth++;
|
||||
else if(source[i]==='}'&&--depth===0)return source.slice(start,i+1);
|
||||
}
|
||||
throw new Error(`Unclosed ${name}`);
|
||||
}
|
||||
function createContext(context={}){vm.createContext(context);return context}
|
||||
function runFunctions(names,context,expose=names){
|
||||
createContext(context);
|
||||
const sources=names.map(name=>functionSource(name)).join('\n');
|
||||
vm.runInContext(`${sources}\nthis.logic={${expose.join(',')}};`,context);
|
||||
return context.logic;
|
||||
}
|
||||
function loadBendPuzzle(){
|
||||
delete global.BendPuzzle;
|
||||
delete require.cache[require.resolve(path.join(root,'puzzle-core.js'))];
|
||||
require(path.join(root,'puzzle-core.js'));
|
||||
return global.BendPuzzle;
|
||||
}
|
||||
function loadAppLogic(){
|
||||
delete require.cache[require.resolve(path.join(root,'app-logic.js'))];
|
||||
return require(path.join(root,'app-logic.js'));
|
||||
}
|
||||
function starterPuzzle(){
|
||||
const match=app.match(/const STARTER_PUZZLE=Object\.freeze\((\{.*\})\);\nconst SCORE_VERSION/s);
|
||||
assert(match,'Bundled origin puzzle missing');
|
||||
return JSON.parse(match[1]);
|
||||
}
|
||||
module.exports={root,read,app,html,css,worker,appLogicSource,assert,functionSource,createContext,runFunctions,loadBendPuzzle,loadAppLogic,starterPuzzle,vm,path,fs};
|
||||
52
test/helpers/realtime-client.js
Normal file
52
test/helpers/realtime-client.js
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
'use strict';
|
||||
|
||||
const assert=require('assert/strict');
|
||||
|
||||
async function connectRealtime(base,session,{timeout=4000}={}){
|
||||
assert(session?.playerId&&session?.token,'Realtime session is required');
|
||||
const url=base.replace(/^http/,'ws')+'/api/realtime';
|
||||
const socket=new WebSocket(url);
|
||||
const queue=[];
|
||||
const waiters=[];
|
||||
let closed=false;
|
||||
function dispatch(message){
|
||||
for(let index=0;index<waiters.length;index++){
|
||||
const waiter=waiters[index];
|
||||
if(waiter.predicate(message)){
|
||||
waiters.splice(index,1);clearTimeout(waiter.timer);waiter.resolve(message);return;
|
||||
}
|
||||
}
|
||||
queue.push(message);
|
||||
}
|
||||
socket.addEventListener('message',event=>{
|
||||
try{dispatch(JSON.parse(String(event.data)))}catch(error){dispatch({type:'test-parse-error',error})}
|
||||
});
|
||||
socket.addEventListener('close',()=>{closed=true;for(const waiter of waiters.splice(0)){clearTimeout(waiter.timer);waiter.reject(new Error('Realtime socket closed'))}});
|
||||
await new Promise((resolve,reject)=>{
|
||||
const timer=setTimeout(()=>reject(new Error('Realtime socket open timed out')),timeout);
|
||||
socket.addEventListener('open',()=>{clearTimeout(timer);resolve()},{once:true});
|
||||
socket.addEventListener('error',()=>{clearTimeout(timer);reject(new Error('Realtime socket failed'))},{once:true});
|
||||
});
|
||||
socket.send(JSON.stringify({type:'hello',playerId:session.playerId,token:session.token}));
|
||||
async function waitFor(predicateOrType,waitTimeout=timeout){
|
||||
const predicate=typeof predicateOrType==='function'?predicateOrType:message=>message?.type===predicateOrType;
|
||||
const found=queue.findIndex(predicate);
|
||||
if(found>=0)return queue.splice(found,1)[0];
|
||||
if(closed)throw new Error('Realtime socket is closed');
|
||||
return new Promise((resolve,reject)=>{
|
||||
const waiter={predicate,resolve,reject,timer:null};
|
||||
waiter.timer=setTimeout(()=>{const index=waiters.indexOf(waiter);if(index>=0)waiters.splice(index,1);reject(new Error('Realtime message timed out'))},waitTimeout);
|
||||
waiters.push(waiter);
|
||||
});
|
||||
}
|
||||
const ready=await waitFor('ready');
|
||||
return{
|
||||
socket,ready,
|
||||
send(message){socket.send(JSON.stringify(message))},
|
||||
waitFor,
|
||||
close(){try{socket.close()}catch(_){}},
|
||||
queued:queue
|
||||
};
|
||||
}
|
||||
|
||||
module.exports={connectRealtime};
|
||||
285
test/interaction-smoke-test.js
Normal file
285
test/interaction-smoke-test.js
Normal file
|
|
@ -0,0 +1,285 @@
|
|||
'use strict';
|
||||
const {vm,assert,functionSource,loadAppLogic,app,css}=require('./helpers/app-source');
|
||||
const AppLogic=loadAppLogic();
|
||||
const context={PAD:16,CELL:40,ckey:(r,c)=>`${r},${c}`};
|
||||
vm.createContext(context);
|
||||
vm.runInContext(`${functionSource('cellsCrossedBySegment')}\n${functionSource('liveEndpointPoint')}\n${functionSource('hexRgb')}\n${functionSource('mixHex')}\n${functionSource('pathStrokePieces')}\nthis.logic={cellsCrossedBySegment,liveEndpointPoint,pathStrokePieces};`,context);
|
||||
const {cellsCrossedBySegment,liveEndpointPoint,pathStrokePieces}=context.logic;
|
||||
const horizontal=cellsCrossedBySegment([36,36],[76,76],{preferredAxis:'H',validCells:new Set(['0,0','0,1','1,1'])});
|
||||
assert(JSON.stringify(horizontal)==='[[0,1],[1,1]]','Exact-corner drag did not follow the horizontal valid route');
|
||||
const vertical=cellsCrossedBySegment([36,36],[76,76],{preferredAxis:'H',validCells:new Set(['0,0','1,0','1,1'])});
|
||||
assert(JSON.stringify(vertical)==='[[1,0],[1,1]]','Exact-corner drag ignored the only valid route');
|
||||
const board={drawing:{pointerId:7,pathIndex:2,lastPoint:[120,44],renderedHandlePosition:[80,44]}},tip=liveEndpointPoint(board,2,[80,44]);
|
||||
assert(tip[0]===80&&tip[1]===44,'Live endpoint followed raw pointer noise instead of the confirmed cell center');
|
||||
board.drawing.renderedHandlePosition=[120,44];
|
||||
assert(liveEndpointPoint(board,2,[80,44])[0]===120,'Live endpoint did not show the buffered next-cell center');
|
||||
assert(liveEndpointPoint(board,1,[80,44])[0]===80,'Live endpoint affected a non-active line');
|
||||
const pieces=pathStrokePieces([[[0,0],[30,0],[30,40],[50,40]]],'#000000','#ffffff');
|
||||
assert(pieces.length===1&&pieces[0].points.length===4,'A continuous bent route was split into excess SVG gradient nodes');
|
||||
assert(Math.abs(pieces[0].startProgress)<1e-9&&Math.abs(pieces.at(-1).endProgress-1)<1e-9,'Route color blend does not span the full path');
|
||||
const splitPieces=pathStrokePieces([[[0,0],[30,0]],[[70,0],[70,40],[90,40]]],'#000000','#ffffff');
|
||||
assert(splitPieces.length===2,'A warp-separated route did not retain one gradient node per visible segment');
|
||||
assert(splitPieces[0].endColor===splitPieces[1].startColor,'Route color blend has a seam between visible segments');
|
||||
assert(splitPieces[0].endProgress<splitPieces[1].endProgress,'Route color blend progress is not monotonic');
|
||||
console.log('Pointer traversal, live endpoint, and path-length color blending test passed');
|
||||
const gestureContext={
|
||||
CELL:40,POINTER_SNAP_THRESHOLD:18,POINTER_DOMINANT_RATIO:1.25,DRAG_EDGE_MARGIN:76,DRAG_EDGE_MAX_SPEED:.58,
|
||||
ckey:(r,c)=>`${r},${c}`,activePath:b=>b.path,boardCellCenter:([r,c])=>[16+(c+.5)*40,16+(r+.5)*40],pathTailAxis:()=> 'H',
|
||||
getViewportRect:()=>({left:0,top:0,right:1000,bottom:800,width:1000,height:800})
|
||||
};
|
||||
vm.createContext(gestureContext);
|
||||
vm.runInContext(`${functionSource('directionFromDelta')}\n${functionSource('gateHitBox')}\n${functionSource('edgePanVelocity')}\nthis.gesture={directionFromDelta,gateHitBox,edgePanVelocity};`,gestureContext);
|
||||
assert(gestureContext.gesture.directionFromDelta(17,0)===null&&gestureContext.gesture.directionFromDelta(19,0)==='E','Single snap threshold is not applied directly');
|
||||
assert(gestureContext.gesture.directionFromDelta(19,18,'H')==='E'&&gestureContext.gesture.directionFromDelta(19,18,'V')==='S'&&gestureContext.gesture.directionFromDelta(0,-19)==='N','Diagonal drag does not select a stable orthogonal axis');
|
||||
const eastHit=gestureContext.gesture.gateHitBox([200,120],'E'),westHit=gestureContext.gesture.gateHitBox([200,120],'W');
|
||||
assert(eastHit.x<200&&eastHit.x+eastHit.width===200&&westHit.x===200&&westHit.x+westHit.width>200,'Gate hit boxes are not confined to the owning board');
|
||||
assert(eastHit.width<=gestureContext.CELL*.5&&westHit.width<=gestureContext.CELL*.5,'Gate hit boxes extend too far into the puzzle');
|
||||
const centerVelocity=gestureContext.gesture.edgePanVelocity(500,400),rightVelocity=gestureContext.gesture.edgePanVelocity(998,400);
|
||||
assert(centerVelocity[0]===0&¢erVelocity[1]===0&&rightVelocity[0]>0,'Edge auto-pan velocity is not limited to the viewport edge');
|
||||
console.log('Direct snap threshold, outward gate reach, and edge auto-pan test passed');
|
||||
|
||||
const sampleContext={perfNow:()=>100,DRAG_MAX_POINTER_SAMPLES:12};
|
||||
vm.createContext(sampleContext);
|
||||
vm.runInContext(`${functionSource('pointerEventSamples')}\n${functionSource('trimBoardPointerSamples')}\n${functionSource('setBoardPointerSample')}\n${functionSource('appendBoardPointerSamples')}\nthis.pointer={pointerEventSamples,appendBoardPointerSamples};`,sampleContext);
|
||||
const cornerSamples=[
|
||||
{clientX:220,clientY:280},{clientX:140,clientY:280},{clientX:140,clientY:196},
|
||||
{clientX:290,clientY:196},{clientX:290,clientY:250}
|
||||
],sampleBoard={pendingPointerMove:null};
|
||||
sampleContext.pointer.appendBoardPointerSamples(sampleBoard,{pointerId:12,pointerType:'pen',getCoalescedEvents:()=>cornerSamples});
|
||||
assert(JSON.stringify([sampleBoard.pendingPointerMove.clientX,sampleBoard.pendingPointerMove.clientY])===JSON.stringify([290,250])&&sampleBoard.pointerMoveSamples.length===1,'Pointer coalescing did not retain the newest sample in the bounded logic queue');
|
||||
const threeTurnPath={startGate:0,endGate:null,cells:[[4,6],[4,5],[4,4],[4,3],[4,2],[4,1],[3,1],[2,1],[2,2],[2,3],[3,3]]};
|
||||
assert(AppLogic.analyzePathTurns(threeTurnPath,[[4,6,'E']],[],false).count===3,'The pictured left-up-right-down pickup gesture does not display three turns');
|
||||
assert(!functionSource('extendPointerTo').includes('consumeBufferedPointerTurn'),'Pickup drag still extends a speculative second cell');
|
||||
|
||||
assert(!functionSource('renderDragFrame').includes('motion')&&!functionSource('extendPointerTo').includes('Motion'),'Line extension still performs animation bookkeeping');
|
||||
console.log('Bounded ordered-sample pickup gesture and immediate line rendering test passed');
|
||||
|
||||
const pickupState={paths:[{startGate:0,endGate:null,cells:[[0,0]]},{startGate:1,endGate:null,cells:[[0,1]]}]},pickupContext={
|
||||
manhattan:(a,b)=>Math.abs(a[0]-b[0])+Math.abs(a[1]-b[1]),ckey:(r,c)=>`${r},${c}`,cellSet:p=>p.validSet,metaState:()=>pickupState
|
||||
};
|
||||
vm.createContext(pickupContext);
|
||||
vm.runInContext(`${functionSource('openTipMergePlan')}\nthis.openTipMergePlan=openTipMergePlan;`,pickupContext);
|
||||
const pickupBoard={id:'B0',p:{validSet:new Set(['0,0','0,1'])},drawing:{pathIndex:0}};
|
||||
assert(pickupContext.openTipMergePlan(pickupBoard,0,1)===null,'Adjacent pickups connected without occupying the same cell');
|
||||
assert(pickupContext.openTipMergePlan(pickupBoard,0,1,'end',true)?.merged.cells.length===2,'A knob explicitly moved into the other endpoint cell did not connect');
|
||||
pickupContext.metaState=()=>({paths:[{startGate:0,endGate:null,cells:[[0,0]]},{startGate:1,endGate:null,cells:[[0,0]]}]});
|
||||
const sameCellPickupBoard={id:'B0',p:{validSet:new Set(['0,0'])},drawing:{pathIndex:0}},sameCellMerge=pickupContext.openTipMergePlan(sameCellPickupBoard,0,1);
|
||||
assert(sameCellMerge&&sameCellMerge.merged.cells.length===1,'Two pickups in the same cell did not merge without duplicating the cell');
|
||||
|
||||
const overlapState={paths:[
|
||||
{startGate:0,endGate:null,cells:[[0,0],[1,0],[1,1]]},
|
||||
{startGate:1,endGate:null,cells:[[0,2],[1,2],[1,1],[2,1]]}
|
||||
]};
|
||||
pickupContext.metaState=()=>overlapState;
|
||||
const overlapBoard={id:'B0',p:{validSet:new Set(['0,0','1,0','1,1','0,2','1,2','2,1'])},drawing:{pathIndex:0}};
|
||||
assert(pickupContext.openTipMergePlan(overlapBoard,0,1)===null,'Overlapping pickups were allowed to create a self-duplicated path');
|
||||
|
||||
const mergeState={paths:[
|
||||
{startGate:0,endGate:null,cells:[[0,0]]},
|
||||
{startGate:1,endGate:null,cells:[[0,0]]}
|
||||
]};
|
||||
let cancelledMergeFrames=0,releasedMergePointers=0;
|
||||
const mergeContext={
|
||||
metaState:()=>mergeState,manhattan:(a,b)=>Math.abs(a[0]-b[0])+Math.abs(a[1]-b[1]),ckey:(r,c)=>`${r},${c}`,cellSet:p=>p.validSet,
|
||||
cancelBoardDragFrame:()=>cancelledMergeFrames++,
|
||||
applyBoardCommand:(_board,mutate)=>mutate()===false?false:true,
|
||||
safeRelease:()=>releasedMergePointers++,playSound:()=>{},queueMicrotask:callback=>callback(),reconcileDrawingPresentation:()=>{}
|
||||
};
|
||||
vm.createContext(mergeContext);
|
||||
vm.runInContext(`${functionSource('openTipMergePlan')}\n${functionSource('joinTips')}\nthis.joinTips=joinTips;`,mergeContext);
|
||||
const mergeBoard={id:'B0',p:{validSet:new Set(['0,0'])},drawing:{pathIndex:0,pointerId:17},svg:{},joiningPaths:false};
|
||||
assert(mergeContext.joinTips(mergeBoard,0,1),'Same-cell pickup merge was rejected');
|
||||
assert(mergeState.paths.length===1&&mergeState.paths[0].cells.length===1,'Pickup merge duplicated the shared cell');
|
||||
assert(mergeBoard.drawing===null&&cancelledMergeFrames===1&&releasedMergePointers===1,'Pickup merge did not terminate its pending frame and pointer capture exactly once');
|
||||
assert(!app.includes('function pickupJoinStepsAtPoint')&&!app.includes('function gateConnectionSteps'),'Removed adjacent pickup/gate auto-bridging code is still present');
|
||||
|
||||
let cameraCallback=null,cameraTimerCallback=null,cameraApplies=0;
|
||||
const cameraContext={
|
||||
CAMERA_DISPLAY_WATCHDOG_MS:18,DRAG_FRAME_INTERVAL:1000/60,
|
||||
cam:{x:0,y:0,scale:1},cameraInteractionFrame:0,cameraInteractionDelayTimer:0,cameraInteractionLastDraw:0,pendingCameraInteraction:null,
|
||||
requestAnimationFrame:callback=>{cameraCallback=callback;return 7},cancelAnimationFrame:()=>{},setTimeout:callback=>{cameraTimerCallback=callback;return 8},clearTimeout:()=>{},applyCamera:immediate=>{assert(immediate===true,'Queued camera update was not committed directly')},
|
||||
perfNow:()=>100,perfStart:()=>0,perfEnd:()=>0,perfCount:()=>{},recordInteractionCommit:()=>{},observeInteractionFrame:()=>{},data:{cameraAnchor:null},currentCameraAnchor:()=>({centerX:0,centerY:0,scale:1}),markGlobalDirty:()=>{},
|
||||
};
|
||||
vm.createContext(cameraContext);
|
||||
vm.runInContext(`${functionSource('commitCameraInteraction')}\n${functionSource('queueCameraInteraction')}\nthis.queueCameraInteraction=queueCameraInteraction;`,cameraContext);
|
||||
cameraContext.applyCamera=()=>cameraApplies++;
|
||||
cameraContext.queueCameraInteraction({x:10,y:20,scale:1});cameraContext.queueCameraInteraction({x:30,y:40,scale:1});
|
||||
assert(cameraContext.cameraInteractionFrame===7&&cameraContext.cam.x===0,'Raw pan events were applied before the animation frame');
|
||||
cameraCallback();
|
||||
assert(cameraContext.cam.x===30&&cameraContext.cam.y===40&&cameraApplies===1,'Pan events were not coalesced to the latest frame');
|
||||
cameraTimerCallback();
|
||||
assert(cameraApplies===1,'Camera watchdog duplicated a completed animation-frame commit');
|
||||
console.log('Pickup capture and frame-coalesced panning test passed');
|
||||
|
||||
const blankTarget={closest:()=>null};
|
||||
const card=(solved=false)=>({dataset:{id:'B0'},classList:{contains:name=>name==='solved'&&solved}});
|
||||
const targetForCard=board=>({closest:selector=>selector==='.board-card'?board:null});
|
||||
const panContext={pendingFieldItemPlacement:null,data:{metas:{B0:{id:'B0'}}},metaState:()=>({solved:false})};
|
||||
vm.createContext(panContext);
|
||||
vm.runInContext(`${functionSource('leftFieldPanAllowed')}\nthis.leftFieldPanAllowed=leftFieldPanAllowed;`,panContext);
|
||||
assert(panContext.leftFieldPanAllowed({button:0,target:blankTarget}),'Blank undiscovered field did not allow left-drag panning');
|
||||
assert(!panContext.leftFieldPanAllowed({button:0,target:targetForCard(card(false))}),'Unsolved puzzle area stole left-drag for field panning');
|
||||
assert(panContext.leftFieldPanAllowed({button:0,target:targetForCard(card(true))}),'Solved puzzle area did not allow left-drag panning');
|
||||
assert(!panContext.leftFieldPanAllowed({button:2,target:blankTarget}),'Left-pan eligibility accepted a non-left button');
|
||||
console.log('Solved/undiscovered left-drag pan eligibility test passed');
|
||||
|
||||
const overviewContext={
|
||||
metaState:id=>({solved:id==='S'}),currentLineGraphCaches:()=>({components:new Map(),geometries:new Map()}),
|
||||
collectConnectedLineComponent:()=>[],minimapGeometryForComponent:()=>({segments:[]}),MINIMAP_LONG_LINE:4
|
||||
};
|
||||
vm.createContext(overviewContext);
|
||||
vm.runInContext(`${functionSource('drawMapBoardCells')}\nthis.drawMapBoardCells=drawMapBoardCells;`,overviewContext);
|
||||
let fills=0,strokes=0;const mapContext={beginPath:()=>{},rect:()=>{},fill:()=>{fills++},stroke:()=>{strokes++},set fillStyle(_value){}};
|
||||
overviewContext.drawMapBoardCells(mapContext,[{id:'S',x:0,y:0,chunks:[[0,0]]},{id:'U',x:1,y:0,chunks:[[0,0]]}],x=>x,y=>y,1);
|
||||
assert(fills===2&&strokes===0,'Minimap/overview board renderer still draws board outlines');
|
||||
assert(functionSource('rebuildWorldOverviewCache').includes('drawMapBoardCells')&&functionSource('rebuildWorldOverviewCache').includes('drawMapLongLines')&&!functionSource('rebuildWorldOverviewCache').includes('storeCellForMeta'),'Far overview does not exactly reuse minimap board/line rendering');
|
||||
console.log('Borderless shared minimap/overview renderer test passed');
|
||||
|
||||
let resetRenderCount=0,resetChangeCount=0,resetSyncCount=0;
|
||||
const resetState={paths:[{cells:[[0,0],[0,1]]}],specialProgress:{crossings:[[0,0]]},solved:false};
|
||||
const resetBoard={id:'B0',drawing:{pointerId:9},armedGate:1,solvedPathsRendered:true,svg:{hasPointerCapture:()=>true,releasePointerCapture:()=>{}}};
|
||||
const resetContext={
|
||||
rendered:new Map(),activeBoard:'B0',metaState:()=>resetState,cancelBoardDragFrame:()=>{},clearTipMotion:()=>{},
|
||||
applyBoardCommand:(board,mutate,options)=>{mutate(resetState);if(options.persist)resetChangeCount++},
|
||||
renderBoardNow:()=>resetRenderCount++,updateSelectedProgress:()=>{},queueMicrotask:callback=>callback(),reconcileDrawingPresentation:()=>{},
|
||||
syncBoundaryConnections:()=>resetSyncCount++,toast:()=>{}
|
||||
};
|
||||
vm.createContext(resetContext);
|
||||
vm.runInContext(`${functionSource('resetSelectedBoard')}\nthis.resetSelectedBoard=resetSelectedBoard;`,resetContext);
|
||||
resetContext.resetSelectedBoard(resetBoard);
|
||||
assert(resetState.paths.length===0&&resetState.specialProgress.crossings.length===0,'First reset click did not clear the puzzle');
|
||||
assert(resetRenderCount===1&&resetChangeCount===1&&resetSyncCount===0,'Reset was not immediately rendered and persisted exactly once');
|
||||
console.log('First-click reset test passed');
|
||||
|
||||
const shopItems=[{id:'O1'},{id:'O2'},...Array.from({length:12},(_,index)=>({id:`C${index+1}`,cursorStyle:`face-${index+1}`}))];
|
||||
assert(functionSource('renderStorePanel').includes('storeInventoryItems(meta,store)')&&functionSource('renderStorePanel').includes("'store-cursor':'store-other'"),'Shop rendering bypasses the fixed 2+12 item inventory');
|
||||
const storeRateContext={
|
||||
STORE_CHANCE:.10,hash32:value=>value>>>0,LOCAL_SOLVER:'tester',STORE_PRICE_VERSION:1,SCORE_VERSION:3,
|
||||
trustedNow:()=>1000,currentPlayerName:()=>"tester",seededStoreItemIds:()=>shopItems.map(item=>item.id),storePriceDetails:()=>({coefficient:1})
|
||||
};
|
||||
vm.createContext(storeRateContext);
|
||||
vm.runInContext(`${functionSource('maybeOpenStore')}\nthis.maybeOpenStore=maybeOpenStore;`,storeRateContext);
|
||||
const eligibleStoreState=()=>({solved:true,store:null,paths:[{cells:[[0,0],[0,1]]}]});
|
||||
const belowThreshold=eligibleStoreState(),atThreshold=eligibleStoreState();
|
||||
storeRateContext.maybeOpenStore({seed:1},belowThreshold,0,.099999);
|
||||
storeRateContext.maybeOpenStore({seed:1},atThreshold,0,.10);
|
||||
assert(belowThreshold.store&&atThreshold.store===null,'Shop appearance boundary is not exactly 1/10');
|
||||
|
||||
assert(!app.includes('function overviewShopAtClient(')&&!functionSource('beginPan').includes('overviewShopAtClient'),'Zoomed-out view still has a shop-only hit target absent from the minimap');
|
||||
|
||||
const orphanState={paths:[{startGate:0,endGate:null,cells:[[0,0]]}]},orphanContext={
|
||||
metaState:()=>orphanState,applyBoardCommand:(_board,mutate)=>{mutate();return true}
|
||||
};
|
||||
vm.createContext(orphanContext);
|
||||
vm.runInContext(`${functionSource('discardUnmovedCreatedPath')}\nthis.discardUnmovedCreatedPath=discardUnmovedCreatedPath;`,orphanContext);
|
||||
const orphanBoard={id:'B0',drawing:{pathIndex:0,createdPath:true},armedGate:0};
|
||||
assert(orphanContext.discardUnmovedCreatedPath(orphanBoard)&&orphanState.paths.length===0&&orphanBoard.drawing===null,'Cancelled one-cell drag leaves an orphan pickup');
|
||||
|
||||
const numberNode={
|
||||
classList:{on:false,toggle(_name,value){this.on=value}},
|
||||
style:{value:null,setProperty(_name,value){this.value=value},removeProperty(){this.value=null}}
|
||||
},numberContext={
|
||||
ckey:(r,c)=>`${r},${c}`,metaState:()=>({paths:[{cells:[[0,0]]}]}),occupiedMap:()=>new Map([['0,0',0]]),pathColorAtCell:()=> '#72e38f'
|
||||
};
|
||||
vm.createContext(numberContext);
|
||||
vm.runInContext(`${functionSource('refreshDragNumberColors')}\nthis.refreshDragNumberColors=refreshDragNumberColors;`,numberContext);
|
||||
numberContext.refreshDragNumberColors({id:'B0',p:{},drawing:{pathIndex:0,segmentOccupancy:new Map([['0,0',0]])},numberNodes:new Map([['0,0',numberNode]])});
|
||||
assert(numberNode.classList.on&&numberNode.style.value==='#72e38f'&&functionSource('renderDragFrame').includes('refreshDragNumberColors(b)'),'Number color does not update during pickup dragging');
|
||||
|
||||
const detachedState={paths:[{startGate:0,endGate:null,openGate:null,cells:[[0,0],[0,1],[0,2]],colorIndex:2,startColorIndex:2,endColorIndex:null}]};
|
||||
const detachedContext={
|
||||
metaState:()=>detachedState,applyBoardCommand:(_board,mutate)=>{mutate();return true},
|
||||
drawingFromGate:(_board,pathIndex,gateIndex,pointerId)=>({pathIndex,gateIndex,pointerId})
|
||||
};
|
||||
vm.createContext(detachedContext);
|
||||
vm.runInContext(`${functionSource('pathUsesGate')}\n${functionSource('detachPathFromStartGate')}\n${functionSource('reverseDetachedPath')}\nthis.detached={pathUsesGate,detachPathFromStartGate,reverseDetachedPath};`,detachedContext);
|
||||
const detachedBoard={id:'B0',drawing:null,armedGate:null};
|
||||
assert(detachedContext.detached.detachPathFromStartGate(detachedBoard,0,0,19),'Dragging the gate of an open line did not detach its anchored end');
|
||||
const detachedPath=detachedState.paths[0];
|
||||
assert(detachedPath.detachedStart&&JSON.stringify(detachedPath.cells)==='[[0,2],[0,1],[0,0]]','Detached line does not retain two oriented edge pickups');
|
||||
assert(detachedContext.detached.pathUsesGate(detachedPath,0)&&detachedBoard.drawing.pointerId===19,'New gate-side pickup is not associated with the active drag');
|
||||
assert(!functionSource('renderBoardNow').includes("whitePickupEnd")&&functionSource('renderBoardNow').includes("'data-endpoint-side':'start'"),'Two-ended line does not render both colored pickup handles');
|
||||
const normalizeDetachedContext={isPlainObject:value=>value&&typeof value==='object'&&!Array.isArray(value),LINE_COLORS:Array(10).fill('#000')};
|
||||
vm.createContext(normalizeDetachedContext);
|
||||
vm.runInContext(`${functionSource('normalizePath')}\nthis.normalizePath=normalizePath;`,normalizeDetachedContext);
|
||||
const normalizedDetached=normalizeDetachedContext.normalizePath(detachedPath);
|
||||
assert(normalizedDetached.detachedStart&&!('whitePickupEnd' in normalizedDetached),'Two-ended pickup state is lost or retains the obsolete white pickup marker during persistence normalization');
|
||||
|
||||
pickupContext.metaState=()=>({paths:[
|
||||
{startGate:0,endGate:null,detachedStart:true,cells:[[0,0],[0,1]],colorIndex:2,startColorIndex:2,endColorIndex:2},
|
||||
{startGate:1,endGate:null,cells:[[0,3],[0,2],[0,1]],colorIndex:4,startColorIndex:4,endColorIndex:null}
|
||||
]});
|
||||
const detachedMergeBoard={id:'B0',p:{validSet:new Set(['0,0','0,1','0,2','0,3'])},drawing:{pathIndex:0}},
|
||||
detachedMerge=pickupContext.openTipMergePlan(detachedMergeBoard,0,1);
|
||||
assert(detachedMerge&&!detachedMerge.merged.detachedStart&&detachedMerge.merged.startGate===1&&detachedMerge.merged.endGate==null&&JSON.stringify(detachedMerge.merged.cells)==='[[0,3],[0,2],[0,1],[0,0]]','An isolated line could not merge into an anchored pickup');
|
||||
pickupContext.metaState=()=>({paths:[
|
||||
{startGate:0,endGate:null,cells:[[0,0]],colorIndex:1,startColorIndex:1},
|
||||
{startGate:1,endGate:null,detachedStart:true,cells:[[0,0],[0,1],[0,2]],colorIndex:3,startColorIndex:3,endColorIndex:3}
|
||||
]});
|
||||
const detachedStartMerge=pickupContext.openTipMergePlan(detachedMergeBoard,0,1,'start');
|
||||
assert(detachedStartMerge&&JSON.stringify(detachedStartMerge.merged.cells)==='[[0,0],[0,1],[0,2]]','The opposite endpoint of an isolated line could not be joined');
|
||||
|
||||
const graphData={metas:{A:{id:'A',puzzle:{}},B:{id:'B',puzzle:{}}},states:{
|
||||
A:{paths:[{startGate:0,endGate:null,detachedStart:true,cells:[[0,0],[0,1]]}]},
|
||||
B:{paths:[{startGate:0,endGate:null,cells:[[0,0],[1,0],[2,0]]}]}
|
||||
}};
|
||||
const graphEnvironment={data:graphData,metaState:id=>graphData.states[id],matchingNeighborGate:meta=>meta.id==='A'?{meta:graphData.metas.B,gateIndex:0}:{meta:graphData.metas.A,gateIndex:0}};
|
||||
const detachedComponent=AppLogic.collectConnectedLineComponent(graphData.metas.A,0,new Map(),graphEnvironment);
|
||||
assert(detachedComponent.members.length===1&&detachedComponent.length===2,'Detached pickup still inherits thickness through its former gate');
|
||||
|
||||
const reconnectState={paths:[{startGate:0,endGate:null,openGate:0,detachedStart:true,cells:[[0,1],[0,0]],colorIndex:2,startColorIndex:2,endColorIndex:2}]},gateEffects=[];
|
||||
const reconnectContext={
|
||||
metaState:()=>reconnectState,usedGateSet:()=>new Set(),LINE_COLORS:['#0af','#fa0','#0f0'],canonicalGateColorIndex:()=>1,
|
||||
applyBoardCommand:(_board,mutate)=>{mutate();return true},neighborColor:()=>null,playSound:()=>{},
|
||||
usesLightweightDragOverlay:()=>false,requestAnimationFrame:callback=>callback(),
|
||||
gateConnectEffect:(_board,_gate,color)=>gateEffects.push(color),queueMicrotask:callback=>callback(),reconcileDrawingPresentation:()=>{}
|
||||
};
|
||||
vm.createContext(reconnectContext);
|
||||
vm.runInContext(`${functionSource('finalizeAtGate')}\nthis.finalizeAtGate=finalizeAtGate;`,reconnectContext);
|
||||
const reconnectBoard={id:'B0',p:{g:[[0,0,'W']]},drawing:{pathIndex:0},armedGate:0};
|
||||
assert(reconnectContext.finalizeAtGate(reconnectBoard,0),'Detached pickup could not reconnect to its original gate');
|
||||
assert(!reconnectState.paths[0].detachedStart&&reconnectState.paths[0].startGate===0&&gateEffects[0]==='#0f0','Reconnected two-ended line did not collapse to one anchored line with its line-color effect');
|
||||
console.log('Shop access/order, orphan cleanup, live number color, and dual-pickup tests passed');
|
||||
|
||||
const rewardContext={MAX_SCORE:1000000000,anomalyScoreMultiplier:()=>1};
|
||||
vm.createContext(rewardContext);
|
||||
vm.runInContext(`${functionSource('scoreFromThickness')}\nthis.scoreFromThickness=scoreFromThickness;`,rewardContext);
|
||||
const smallEasy=rewardContext.scoreFromThickness({level:2,chunks:[[0,0]]},20),
|
||||
smallHard=rewardContext.scoreFromThickness({level:10,chunks:[[0,0]]},20),
|
||||
largeHard=rewardContext.scoreFromThickness({level:10,chunks:Array.from({length:8},(_,index)=>[index,0])},20);
|
||||
assert(smallHard>smallEasy*8&&largeHard>smallHard*2.5,'Hard and large puzzle rewards do not scale strongly enough with expected solve time');
|
||||
|
||||
const gateArmingState={paths:[{startGate:0,endGate:null,openGate:0,cells:[[0,0]]}]},gateArmingContext={
|
||||
metaState:()=>gateArmingState,sameCell:(a,b)=>a&&b&&a[0]===b[0]&&a[1]===b[1]
|
||||
};
|
||||
vm.createContext(gateArmingContext);
|
||||
vm.runInContext(`${functionSource('updateGateSnapArming')}\nthis.updateGateSnapArming=updateGateSnapArming;`,gateArmingContext);
|
||||
const gateArmingBoard={id:'B0'},gateDrawing={pathIndex:0,originGate:0,originGateCell:[0,0],gateSnapArmed:false,leftOriginGateCell:false};
|
||||
gateArmingContext.updateGateSnapArming(gateArmingBoard,gateDrawing);
|
||||
assert(!gateDrawing.gateSnapArmed&&!gateDrawing.leftOriginGateCell,'A connected gate rearmed before the line endpoint left its gate cell');
|
||||
gateArmingState.paths[0].cells.push([0,1]);gateArmingContext.updateGateSnapArming(gateArmingBoard,gateDrawing);
|
||||
assert(gateDrawing.gateSnapArmed&&gateDrawing.leftOriginGateCell,'Dragging a connected gate endpoint out of its cell did not unlock movement');
|
||||
assert(functionSource('extendPointerTo').includes("snappedGate===drawing.originGate&&!drawing.leftOriginGateCell"),'The origin gate can recapture a connected endpoint before it moves away');
|
||||
|
||||
const disappearingState={paths:[{startGate:0,endGate:null,detachedStart:true,cells:[[0,0],[0,1]],colorIndex:1,startColorIndex:1,endColorIndex:1}]};
|
||||
let disappearingReleased=0,disappearingPersisted=0;
|
||||
const disappearingContext={
|
||||
CELL:40,metaState:()=>disappearingState,boardCellCenter:([r,c])=>[20+c*40,20+r*40],pointerLocalRadius:(_board,pixels)=>pixels,
|
||||
cancelBoardDragFrame:()=>{},clearTipMotion:()=>{},applyBoardCommand:(_board,mutate,options)=>{const result=mutate();if(result===false)return false;if(options?.persist)disappearingPersisted++;return true},
|
||||
safeRelease:()=>disappearingReleased++,playSound:()=>{},queueMicrotask:callback=>callback(),reconcileDrawingPresentation:()=>{}
|
||||
};
|
||||
vm.createContext(disappearingContext);
|
||||
vm.runInContext(`${functionSource('removeDetachedPathAtOwnEndpoint')}\nthis.removeDetachedPathAtOwnEndpoint=removeDetachedPathAtOwnEndpoint;`,disappearingContext);
|
||||
const disappearingBoard={id:'B0',drawing:{pathIndex:0,pointerId:23},armedGate:null,svg:{}};
|
||||
assert(!disappearingContext.removeDetachedPathAtOwnEndpoint(disappearingBoard,[60,20]),'A two-ended line vanished before its handles overlapped');
|
||||
assert(disappearingContext.removeDetachedPathAtOwnEndpoint(disappearingBoard,[20,20]),'Overlapping both handles did not remove the two-ended line');
|
||||
assert(disappearingState.paths.length===0&&disappearingBoard.drawing===null&&disappearingReleased===1&&disappearingPersisted===1,'Two-ended line removal did not cleanly persist and release pointer capture');
|
||||
console.log('Connected-gate dragging and two-handle line disappearance tests passed');
|
||||
|
||||
assert(!app.includes('RETRACTION_HOLD_MS')&&!app.includes('pendingRetraction'),'Own-line retraction still has a delayed state');
|
||||
assert(functionSource('extendOne').includes('path.cells.splice(rewind+1)')&&functionSource('extendPointerTo').includes('rewindActivePathToCell(b,targetCell'),'Original immediate own-line shortening was not restored');
|
||||
assert(functionSource('gateFromPointOrCell').includes('maxPixels:0')&&!functionSource('extendPointerTo').includes('gateConnectionSteps'),'A knob can still snap to a gate from an adjacent cell');
|
||||
assert(functionSource('shouldTeleportToUnsolvedBoard').includes('return false')&&!functionSource('bindBoard').includes('centerMeta(')&&!app.includes('function promoteStaticBoard('),'Board input can still promote a summary or teleport the camera');
|
||||
assert(functionSource('matchingNumberKeys').includes('partialTurnCount')&&functionSource('updateNumberMatchFeedback').includes("classList.toggle('number-match',current.size>0)")&&functionSource('updateNumberMatchFeedback').includes("matchOrbit.classList.toggle('show'")&&css.includes('@keyframes numberMatchOrbit')&&css.includes('stroke-dasharray:1.2 5.35'),'Matching turn-number feedback does not keep an enlarged knob with a rotating perforated orbit');
|
||||
assert(functionSource('renderBoardNow').includes("'\\u66f2\\u304c\\u308b'")&&app.includes('numberLayer.append(node,warning)')&&css.includes('.number-turn-warning.show{opacity:1}'),'The 曲がる warning is not prominent or is behind the number');
|
||||
console.log('Immediate retraction, exact-cell gate, no-click-teleport, and number-match feedback guards passed');
|
||||
38
test/mirror-chunk-smoke-test.js
Normal file
38
test/mirror-chunk-smoke-test.js
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
'use strict';
|
||||
const {vm,app,read,assert,functionSource}=require('./helpers/app-source');
|
||||
|
||||
const checkpointSource=functionSource('runMirrorCheckpoint'),benchmark=read('test/browser-performance-benchmark.js');
|
||||
assert(checkpointSource.includes("build.stage==='serialize'")&&checkpointSource.includes('writeMirrorBuildChunk(build)'),'Idle mirror checkpoint does not serialize and write bounded chunks');
|
||||
assert(!checkpointSource.includes('writeCompactMirror(')&&!checkpointSource.includes('JSON.stringify(snapshot)'),'Idle mirror checkpoint still performs a full synchronous mirror write');
|
||||
assert(app.includes("MIRROR_CHUNK_BYTES=64*1024")&&app.includes("MIRROR_CHUNK_FORMAT='bend-field-chunked-v1'"),'Chunked mirror format or size bound is missing');
|
||||
assert(!benchmark.includes('33*multiplier')&&!benchmark.includes('20*multiplier')&&!benchmark.includes('40*multiplier'),'CPU-throttled benchmark thresholds are still multiplied beyond the documented targets');
|
||||
assert(benchmark.includes("name:'complex'")&&benchmark.includes("chunks:10")&&benchmark.includes("name:'long-line'"),'Browser benchmark does not cover a ten-chunk board and a long connected line');
|
||||
|
||||
const serializerContext={JSON,Number,Object,Array};
|
||||
vm.createContext(serializerContext);
|
||||
vm.runInContext(`${functionSource('jsonValueFragments')}\nthis.jsonValueFragments=jsonValueFragments;`,serializerContext);
|
||||
const sample={schema:30,title:'曲線\\"test',enabled:true,missing:null,metas:{A:{id:'A',chunks:[[0,0],[1,0]],puzzle:{valid:[[0,0],[0,1]]}}},states:{A:{paths:[{cells:[[0,0],[0,1]]}]}}};
|
||||
const serialized=[...serializerContext.jsonValueFragments(sample)].join('');
|
||||
assert(serialized===JSON.stringify(sample)&&JSON.parse(serialized).metas.A.chunks.length===2,'Incremental JSON serializer changed the mirror payload');
|
||||
|
||||
const storage=new Map(),manifest={format:'bend-field-chunked-v1',generation:'g1',chunks:3,bytes:11,updatedAt:12};
|
||||
storage.set('save',JSON.stringify(manifest));storage.set('chunk:g1:0','{"a":');storage.set('chunk:g1:1','[1,2]');storage.set('chunk:g1:2','}');
|
||||
const readContext={
|
||||
storageKey:'save',mirrorChunkPrefix:'chunk:',MIRROR_CHUNK_FORMAT:'bend-field-chunked-v1',LOCAL_MIRROR_MAX_BYTES:1024,MIRROR_CHUNK_BYTES:4,
|
||||
safeLocalGet:key=>storage.has(key)?storage.get(key):null,JSON,Number,Math
|
||||
};
|
||||
vm.createContext(readContext);
|
||||
vm.runInContext(`${functionSource('mirrorChunkKey')}\n${functionSource('parseMirrorManifest')}\n${functionSource('readCompactMirrorRaw')}\nthis.readCompactMirrorRaw=readCompactMirrorRaw;`,readContext);
|
||||
assert(readContext.readCompactMirrorRaw()==='{"a":[1,2]}','Chunked mirror manifest was not reconstructed correctly');
|
||||
storage.delete('chunk:g1:1');assert(readContext.readCompactMirrorRaw()===null,'Incomplete chunked mirror was accepted');
|
||||
|
||||
const writes=[],chunkContext={
|
||||
mirrorChunkKey:(generation,index)=>`chunk:${generation}:${index}`,safeLocalSet:(key,value)=>{writes.push([key,value]);return true},
|
||||
perfStart:()=>0,perfNow:()=>3,perfGauge:()=>{},perfEnd:()=>{},perfCount:()=>{}
|
||||
};
|
||||
vm.createContext(chunkContext);
|
||||
vm.runInContext(`${functionSource('writeMirrorBuildChunk')}\nthis.writeMirrorBuildChunk=writeMirrorBuildChunk;`,chunkContext);
|
||||
const build={generation:'g2',chunkIndex:0,pendingChunk:'x'.repeat(65536),bytes:0};
|
||||
assert(chunkContext.writeMirrorBuildChunk(build)===true&&writes.length===1&&writes[0][1].length===65536&&build.pendingChunk===''&&build.bytes===65536,'Mirror chunk writer did not perform exactly one bounded write');
|
||||
|
||||
console.log('Chunked recovery mirror and strict performance acceptance regression passed');
|
||||
174
test/performance-smoke-test.js
Normal file
174
test/performance-smoke-test.js
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
'use strict';
|
||||
const {vm,app,css,assert,functionSource}=require('./helpers/app-source');
|
||||
|
||||
const flashSource=functionSource('flashConfirmedCell'),eventSource=functionSource('eventToSvg'),
|
||||
dragSource=functionSource('renderDragFrame'),extendSource=functionSource('extendPointerTo'),
|
||||
extendOneSource=functionSource('extendOne'),bindBoardSource=functionSource('bindBoard'),
|
||||
dragCacheSource=functionSource('buildDragCache'),
|
||||
minimapSource=functionSource('drawMinimap'),minimapBuildSource=functionSource('rebuildMinimapWorld'),
|
||||
widthRefreshSource=functionSource('refreshRenderedLineWidths'),ensureBoardsSource=functionSource('ensureBoards');
|
||||
|
||||
assert(!flashSource.includes('getBoundingClientRect')&&!flashSource.includes('querySelector'),'Cell confirmation flash performs a layout read or DOM query');
|
||||
assert(flashSource.includes("svgEl('rect'")&&flashSource.includes('b.dragLayer.append(node)')&&flashSource.includes("typeof node.animate==='function'"),'Cell confirmation flash does not use one temporary SVG node and WAAPI');
|
||||
assert(!functionSource('makeBoard').includes('cellHits.set')&&functionSource('makeBoard').includes('cellFlashAnimations:new Map()'),'Board construction still retains one hit node per cell or lacks animation tracking');
|
||||
assert(!eventSource.includes('getBoundingClientRect')&&eventSource.includes('boardScreenRect(b)'),'Pointer conversion reads live SVG layout');
|
||||
assert(!functionSource('pointerLocalRadius').includes('getBoundingClientRect')&&functionSource('edgePanVelocity').includes('getViewportRect()'),'High-frequency input still reads live element geometry');
|
||||
|
||||
assert(extendSource.includes('renderDragFrame(b)'),'Pointer drawing does not enter the drag-only renderer');
|
||||
assert(!extendSource.includes('invalidateLineGraphCaches')&&!extendSource.includes('queueLineWidthRefresh'),'Pointer frames still invalidate or traverse connected-line components');
|
||||
assert(dragSource.includes('drawingLineWidth(b,index)')&&functionSource('drawingLineWidth').includes('drawing.lineWidth=width'),'Drag width is not fixed for the active gesture');
|
||||
assert(dragSource.includes('pathVisualNodes')&&dragSource.includes('dragCache')&&dragSource.includes('refreshDragSpecialState')&&dragCacheSource.includes('b.dragLayer')&&dragCacheSource.includes('strokes'),'Drag-only renderer does not retain and selectively replace active visuals');
|
||||
for(const forbidden of ['pathValid(','straightNumberWarningKeys(','multipleNumberWarningKeys(','queueLineWidthRefresh(','renderBoard(b)'])assert(!dragSource.includes(forbidden),`Drag-only renderer performs full-render work: ${forbidden}`);
|
||||
assert(functionSource('processBoardDragFrame').includes('extendPointerTo(b,point,true)')&&!functionSource('processBoardDragFrame').includes('renderBoard('),'Pointer RAF does not defer raw-sample rendering to one drag-frame repaint');
|
||||
assert(extendOneSource.includes('isCrossing=b.crossingKeySet.has(key)')&&extendOneSource.includes('currentOwner=')&&extendOneSource.includes('occ.get(exitKey)'),'Ordinary drag occupancy bypasses its retained map');
|
||||
assert(extendOneSource.includes('if(!suppressMotion){flashConfirmedCell')&&(extendSource.match(/emitBatchedFeedback\(\)/g)||[]).length===2,'Batched pointer traversal emits per-cell confirmation effects');
|
||||
assert((bindBoardSource.match(/b\.drawing\.keyboardActive=true/g)||[]).length>=2,'Keyboard continuation does not keep its board at interactive LOD');
|
||||
|
||||
assert(functionSource('renderedConnectedLineWidth').includes('currentLineGraphCaches()')&&functionSource('markStateDirty').includes('invalidateLineGraphCaches(id,pathIndex)'),'Connected-line caches are not persistent or invalidated by the touched state/path');
|
||||
assert(widthRefreshSource.includes('board.pathStrokeNodes')&&widthRefreshSource.includes('board.connectorNodes'),'Line-width refresh does not prefer retained SVG node references');
|
||||
assert(!widthRefreshSource.includes('querySelectorAll'),'Line-width refresh retains a DOM-query fallback');
|
||||
assert(minimapBuildSource.includes('minimapCache={revision:minimapWorldRevision')&&minimapSource.includes('if(stale)rebuildMinimapWorld')&&minimapSource.includes('drawImage(minimapBase'),'Minimap does not retain and reuse an overscanned world layer');
|
||||
assert(functionSource('drawMapLongLines').includes('minimapGeometryForComponent(component,caches.geometries)'),'Long-line minimap geometry is recalculated during every world-layer rebuild');
|
||||
assert(functionSource('markStateDirty').includes('invalidateWorldPresentation()'),'State changes do not invalidate minimap/overview content');
|
||||
assert(app.includes('globalThis.BEND_PERF=')&&functionSource('perfObserve').includes('samples.length>240'),'Performance measurements are missing or unbounded');
|
||||
assert(css.includes('#noiseCanvas.interaction-muted')&&css.includes('body.reduced-effects #noiseCanvas')&&functionSource('scheduleNoiseBackground').includes('noisePainted')&&!functionSource('scheduleNoiseBackground').includes('setTimeout'),'Decorative noise is not static or interaction/reduced-motion suppression is missing');
|
||||
assert((ensureBoardsSource.match(/changes<LOD_CHANGES_PER_PASS/g)||[]).length>=3&&ensureBoardsSource.includes('if(pending)scheduleLodPass()'),'LOD creation or eviction bypasses the per-pass budget');
|
||||
assert(functionSource('desiredInteractiveBoardIds').includes('for(const id of ids)')&&!app.includes('INTERACTIVE_DETAIL_CELL_BUDGET'),'Visible-puzzle detail selection is incomplete or still budget-culls boards');
|
||||
assert(ensureBoardsSource.includes("perfGauge('visibleUnsolvedBoards'"),'LOD performance telemetry cannot prove visible unsolved-board coverage');
|
||||
|
||||
const visibleIdsForDetail=new Set(Array.from({length:30},(_,index)=>`B${index}`)),
|
||||
visibleContext={data:{metas:Object.fromEntries([...visibleIdsForDetail].map(id=>[id,{id,x:Number(id.slice(1)),y:0,puzzle:{bounds:{w:5,h:5},valid:Array.from({length:25})}}]))}};
|
||||
vm.createContext(visibleContext);
|
||||
vm.runInContext(`${functionSource('desiredInteractiveBoardIds')}\nthis.desiredInteractiveBoardIds=desiredInteractiveBoardIds;`,visibleContext);
|
||||
const desiredVisible=visibleContext.desiredInteractiveBoardIds(visibleIdsForDetail);
|
||||
assert(desiredVisible.has('B0')&&desiredVisible.size===visibleIdsForDetail.size,'Not every visible hydrated puzzle remained detailed');
|
||||
|
||||
let animationStarts=0,animationCancels=0,flashRemovals=0;
|
||||
function makeFlashNode(){return{animate(){animationStarts++;const animation={cancel(){animationCancels++},finished:Promise.resolve()};return animation},remove(){flashRemovals++}}}
|
||||
const flashContext={
|
||||
PAD:10,CELL:40,ckey:(r,c)=>`${r},${c}`,svgEl:()=>makeFlashNode(),setTimeout:callback=>{callback();return 1},clearTimeout(){}
|
||||
};
|
||||
vm.createContext(flashContext);
|
||||
vm.runInContext(`${flashSource}\nthis.flashConfirmedCell=flashConfirmedCell;`,flashContext);
|
||||
const flashBoard={svg:{isConnected:true},dragLayer:{append(){}},cellFlashAnimations:new Map()};
|
||||
flashContext.flashConfirmedCell(flashBoard,[1,2]);flashContext.flashConfirmedCell(flashBoard,[1,2]);
|
||||
assert(animationStarts===2&&animationCancels>=1&&flashBoard.cellFlashAnimations.size<=1,'Repeated cell confirmation did not replace one temporary animation');
|
||||
|
||||
let rectCalls=0;
|
||||
const eventContext={boardScreenRect:()=>{rectCalls++;return{left:10,top:20,width:200,height:100}}};
|
||||
vm.createContext(eventContext);
|
||||
vm.runInContext(`${eventSource}\nthis.eventToSvg=eventToSvg;`,eventContext);
|
||||
const eventBoard={svg:{viewBox:{baseVal:{width:400,height:200}},getBoundingClientRect(){throw new Error('event conversion forced layout')}}},
|
||||
mapped=eventContext.eventToSvg(eventBoard,{clientX:110,clientY:70});
|
||||
assert(rectCalls===1&&mapped[0]===200&&mapped[1]===100,'Cached board geometry produced incorrect pointer coordinates');
|
||||
|
||||
const cacheContext={lineGraphRevision:0,lineGraphCacheRevision:-1,lineComponentCache:new Map(),lineWidthCache:new Map(),lineMinimapGeometryCache:new WeakMap(),data:{metas:{},states:{}},linePathKey:(id,index)=>`${id}:${index}`,matchingNeighborGate:()=>null};
|
||||
vm.createContext(cacheContext);
|
||||
vm.runInContext(`${functionSource('invalidateLineGraphCaches')}\n${functionSource('currentLineGraphCaches')}\nthis.logic={invalidateLineGraphCaches,currentLineGraphCaches};`,cacheContext);
|
||||
const firstCaches=cacheContext.logic.currentLineGraphCaches(),secondCaches=cacheContext.logic.currentLineGraphCaches();
|
||||
assert(firstCaches.components===secondCaches.components&&firstCaches.widths===secondCaches.widths,'Connected-line caches do not persist across reads');
|
||||
cacheContext.logic.invalidateLineGraphCaches();const thirdCaches=cacheContext.logic.currentLineGraphCaches();
|
||||
assert(thirdCaches.components!==firstCaches.components&&thirdCaches.widths!==firstCaches.widths,'Connected-line invalidation reused stale cache maps');
|
||||
const shared={members:[[{id:'A'},0],[{id:'B'},0]],length:10},unrelated={members:[[{id:'C'},0]],length:3};
|
||||
thirdCaches.components.set('A:0',shared);thirdCaches.components.set('B:0',shared);thirdCaches.components.set('C:0',unrelated);
|
||||
thirdCaches.widths.set('A:0',5);thirdCaches.widths.set('B:0',5);thirdCaches.widths.set('C:0',4);
|
||||
cacheContext.logic.invalidateLineGraphCaches('A',0);
|
||||
assert(!thirdCaches.components.has('A:0')&&!thirdCaches.components.has('B:0')&&thirdCaches.components.get('C:0')===unrelated&&thirdCaches.widths.has('C:0'),'Path-granular invalidation cleared an unrelated connected-line component');
|
||||
|
||||
const widthWrites=[],meta={id:'A',puzzle:{}},
|
||||
pathNode={dataset:{pathIndex:'0'},style:{setProperty:(name,value)=>widthWrites.push(['path',name,value])}},
|
||||
connectorNode={dataset:{ownerBoard:'A',pathIndex:'0'},style:{setProperty:(name,value)=>widthWrites.push(['connector',name,value])}},
|
||||
widthBoard={id:'A',meta,pathStrokeNodes:[pathNode],connectorNodes:[connectorNode],pathLayer:{querySelectorAll(){throw new Error('path DOM scan')}},connectorLayer:{querySelectorAll(){throw new Error('connector DOM scan')}}};
|
||||
const widthContext={
|
||||
rendered:new Map([['A',widthBoard]]),data:{metas:{A:meta}},metaState:()=>({paths:[{}]}),
|
||||
linePathKey:(id,index)=>`${id}:${index}`,splitLinePathKey:key=>{const split=key.lastIndexOf(':');return[key.slice(0,split),Number(key.slice(split+1))]},
|
||||
currentLineGraphCaches:()=>({components:new Map(),widths:new Map()}),
|
||||
collectConnectedLineComponent:()=>({members:[[meta,0]]}),renderedConnectedLineWidth:()=>8,
|
||||
applyLineWidth:(node,width)=>node.style.setProperty('--line-width',width.toFixed(2)),
|
||||
perfStart:()=>0,perfEnd:()=>0,perfGauge:()=>{}
|
||||
};
|
||||
vm.createContext(widthContext);
|
||||
vm.runInContext(`${widthRefreshSource}\nthis.refreshRenderedLineWidths=refreshRenderedLineWidths;`,widthContext);
|
||||
widthContext.refreshRenderedLineWidths(new Set(['A:0']));
|
||||
assert(widthWrites.length===2&&widthWrites.every(([,name,value])=>name==='--line-width'&&value==='8.00'),'Retained line nodes were not updated without DOM scans');
|
||||
|
||||
|
||||
let geometryStateReads=0,geometryBuilds=0;
|
||||
const geometryMeta={id:'G',x:2,y:3,puzzle:{}},geometryComponent={length:1500,members:[[geometryMeta,0]]},geometryContext={
|
||||
CHUNK:5,metaState:()=>{geometryStateReads++;return{paths:[{startGate:0,endGate:1,cells:[[0,0],[0,1],[1,1]]}]}},
|
||||
puzzleOf:()=>({}),gateObj:(_p,index)=>index?{cell:[1,1],side:'S'}:{cell:[0,0],side:'W'},pathColor:()=> '#fff',perfCount:()=>geometryBuilds++,currentLineGraphCaches:()=>({geometries:new WeakMap()})
|
||||
};
|
||||
vm.createContext(geometryContext);vm.runInContext(`${functionSource('minimapGeometryForComponent')}\nthis.minimapGeometryForComponent=minimapGeometryForComponent;`,geometryContext);
|
||||
const geometryCache=new WeakMap(),firstGeometry=geometryContext.minimapGeometryForComponent(geometryComponent,geometryCache),secondGeometry=geometryContext.minimapGeometryForComponent(geometryComponent,geometryCache);
|
||||
assert(firstGeometry===secondGeometry&&geometryStateReads===1&&geometryBuilds===1&&firstGeometry.segments[0].points.length>=2,'Simplified long-line geometry was not retained per connected component');
|
||||
|
||||
let minimapBuilds=0,center=[1,0],drawCopies=0;
|
||||
const mapContext2d={setTransform(){},clearRect(){},drawImage(){drawCopies++},beginPath(){},moveTo(){},lineTo(){},stroke(){}},
|
||||
attributes=new Map(),minimapCanvas={
|
||||
width:210,height:132,getBoundingClientRect:()=>({width:210,height:132}),getContext:()=>mapContext2d,
|
||||
getAttribute:name=>attributes.get(name)||null,setAttribute:(name,value)=>attributes.set(name,value)
|
||||
},
|
||||
minimapContext={
|
||||
minimapCanvas,minimapStatus:{textContent:''},minimapBase:{},minimapDirty:true,minimapLongSegments:0,minimapWorldRevision:1,
|
||||
minimapCache:{revision:1,width:210,height:132,dpr:1,anchorX:0,anchorY:0,scale:5,overscanPixels:50,baseWidth:310,baseHeight:232,longSegments:0},
|
||||
window:{devicePixelRatio:1},MINIMAP_VIEW_CHUNKS_X:42,data:{metas:{}},getMinimapRect:()=>({width:210,height:132}),
|
||||
cameraCenterInChunks:()=>center,visibleMetaIdsForBounds:()=>new Set(),metaState:()=>({solved:false}),
|
||||
perfStart:()=>0,perfEnd:()=>0,perfCount:()=>{}
|
||||
};
|
||||
minimapContext.rebuildMinimapWorld=(width,height,dpr,x,y)=>{
|
||||
minimapBuilds++;minimapContext.minimapCache={...minimapContext.minimapCache,revision:minimapContext.minimapWorldRevision,width,height,dpr,anchorX:x,anchorY:y};
|
||||
};
|
||||
vm.createContext(minimapContext);
|
||||
vm.runInContext(`${minimapSource}\nthis.drawMinimap=drawMinimap;`,minimapContext);
|
||||
minimapContext.drawMinimap();assert(minimapBuilds===0&&drawCopies===1,'Small camera movement rebuilt the cached minimap world layer');
|
||||
center=[20,0];minimapContext.drawMinimap();assert(minimapBuilds===1&&drawCopies===2,'Camera movement beyond minimap overscan did not rebuild exactly once');
|
||||
|
||||
let overviewLodSchedules=0;
|
||||
const overviewRendered=new Map(Array.from({length:6},(_,index)=>[`D${index}`,{id:`D${index}`,drawing:null}])),
|
||||
overviewStatic=new Map(Array.from({length:6},(_,index)=>[`S${index}`,{id:`S${index}`}]));
|
||||
const overviewLodContext={
|
||||
LOD_CHANGES_PER_PASS:4,rendered:overviewRendered,staticRendered:overviewStatic,inWorldOverview:()=>true,
|
||||
destroyBoard:board=>overviewRendered.delete(board.id),destroyStaticBoard:board=>overviewStatic.delete(board.id),
|
||||
scheduleLodPass:()=>overviewLodSchedules++,scheduleWorldOverview:()=>{},perfStart:()=>0,perfCount:()=>{},perfEnd:()=>{}
|
||||
};
|
||||
vm.createContext(overviewLodContext);vm.runInContext(`${ensureBoardsSource}\nthis.ensureBoards=ensureBoards;`,overviewLodContext);overviewLodContext.ensureBoards();
|
||||
assert(overviewRendered.size+overviewStatic.size===8&&overviewLodSchedules===1,'Overview eviction exceeded its four-change budget or failed to schedule continuation');
|
||||
|
||||
let creationLodSchedules=0;
|
||||
const visibleIds=new Set(Array.from({length:10},(_,index)=>`B${index}`)),creationStatic=new Map(),
|
||||
creationData={metas:Object.fromEntries([...visibleIds].map(id=>[id,{id,puzzle:{}}]))};
|
||||
const creationLodContext={
|
||||
LOD_CHANGES_PER_PASS:4,rendered:new Map(),staticRendered:creationStatic,data:creationData,inWorldOverview:()=>false,
|
||||
visibleMetaIds:()=>visibleIds,desiredInteractiveBoardIds:ids=>new Set(ids),metaState:()=>({solved:false}),
|
||||
makeStaticBoard:meta=>creationStatic.set(meta.id,{id:meta.id,signature:'current'}),destroyStaticBoard:board=>board&&creationStatic.delete(board.id),
|
||||
destroyBoard:()=>{},makeBoard:meta=>creationLodContext.rendered.set(meta.id,{id:meta.id,drawing:null}),staticBoardSignature:()=> 'current',
|
||||
scheduleLodPass:()=>creationLodSchedules++,perfStart:()=>0,perfCount:()=>{},perfGauge:()=>{},perfEnd:()=>{}
|
||||
};
|
||||
vm.createContext(creationLodContext);vm.runInContext(`${ensureBoardsSource}\nthis.ensureBoards=ensureBoards;`,creationLodContext);creationLodContext.ensureBoards();
|
||||
assert(creationLodContext.rendered.size===4&&creationStatic.size===0&&creationLodSchedules===1,'Detailed board creation exceeded its four-change budget or hid visible boards behind static summaries');
|
||||
|
||||
(async()=>{
|
||||
let workerConstructions=0,workerNow=1000,timerId=0;
|
||||
const restartCallbacks=[],restartDelays=[],workerRetryContext={
|
||||
APP_VERSION:'47.36',GENERATOR_VERSION:5,puzzleWorker:null,workerRestartTimer:null,workerFailureCount:0,workerDisabledUntil:0,workerRetryAt:0,
|
||||
Date:{now:()=>workerNow},Worker:function Worker(){workerConstructions++;throw new Error('synthetic worker failure')},
|
||||
setTimeout:(callback,delay)=>{restartCallbacks.push(callback);restartDelays.push(delay);return++timerId},clearTimeout:()=>{},console:{warn:()=>{}}
|
||||
};
|
||||
vm.createContext(workerRetryContext);
|
||||
vm.runInContext(`${functionSource('scheduleWorkerRestart')}\n${functionSource('createPuzzleWorker')}\nthis.createPuzzleWorker=createPuzzleWorker;`,workerRetryContext);
|
||||
workerRetryContext.createPuzzleWorker();workerRetryContext.createPuzzleWorker();
|
||||
assert(workerConstructions===1&&restartDelays[0]===1000,'Worker creation retried immediately while a backoff timer was armed');
|
||||
workerNow+=1000;restartCallbacks.shift()();
|
||||
assert(workerConstructions===2&&restartDelays[1]===2000,'Worker restart did not advance through the gated exponential backoff');
|
||||
|
||||
let mainThreadCalls=0;
|
||||
const workerContext={
|
||||
puzzleWorker:null,workerSeq:0,workerJobs:new Map(),location:{protocol:'https:'},Worker:function Worker(){},
|
||||
createPuzzleWorker:()=>null,perfCount:()=>{},generatePuzzleOnMainThread:()=>{mainThreadCalls++;return Promise.resolve({})}
|
||||
};
|
||||
vm.createContext(workerContext);
|
||||
vm.runInContext(`${functionSource('generatePuzzleAsync')}\nthis.generatePuzzleAsync=generatePuzzleAsync;`,workerContext);
|
||||
let rejected=false;try{await workerContext.generatePuzzleAsync([[0,0]],1,1)}catch(_){rejected=true}
|
||||
assert(rejected&&mainThreadCalls===0,'Served-mode worker failure fell back to synchronous main-thread generation');
|
||||
console.log('Performance regression smoke test passed');
|
||||
})().catch(error=>{console.error(error);process.exitCode=1});
|
||||
24
test/phase2-source-smoke-test.js
Normal file
24
test/phase2-source-smoke-test.js
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
'use strict';
|
||||
const fs=require('fs');
|
||||
const path=require('path');
|
||||
const assert=require('assert/strict');
|
||||
const {root,functionSource}=require('./helpers/app-source');
|
||||
const app=fs.readFileSync(path.join(root,'app.js'),'utf8');
|
||||
const server=fs.readFileSync(path.join(root,'server.js'),'utf8');
|
||||
const realtime=fs.readFileSync(path.join(root,'realtime-server.js'),'utf8');
|
||||
const html=fs.readFileSync(path.join(root,'index.html'),'utf8');
|
||||
const css=fs.readFileSync(path.join(root,'style.css'),'utf8');
|
||||
const pkg=JSON.parse(fs.readFileSync(path.join(root,'package.json'),'utf8'));
|
||||
|
||||
assert(html.includes('<canvas id="presenceCanvas"'),'Presence canvas is missing');
|
||||
assert(css.includes('#presenceCanvas')&&css.includes('.board-claim-badge'),'Presence or claim presentation styles are missing');
|
||||
assert(app.includes('REALTIME_CURSOR_INTERVAL=50')&&app.includes('REALTIME_CURSOR_HEARTBEAT_INTERVAL=5000')&&app.includes('AUXILIARY_FRAME_INTERVAL'),'Cursor transport is not capped at 20 Hz or presence drawing is not tied to the auxiliary frame budget');
|
||||
assert(functionSource('scheduleRealtimeViewport').includes('REALTIME_VIEWPORT_INTERVAL'),'Viewport subscription is not throttled');
|
||||
assert(functionSource('drawPresenceLayer').includes('remotePlayers')&&functionSource('drawPresenceLayer').includes('scheduleMinimap'),'Remote cursors are not rendered through the shared canvas layer');
|
||||
assert(functionSource('drawMinimap').includes('nearbyPlayers'),'Remote players are missing from the minimap');
|
||||
assert(functionSource('requestBoardClaim').includes("type:'claim'")&&functionSource('touchBoardClaim').includes("type:'claim-touch'"),'Client claim lease messages are incomplete');
|
||||
assert(server.includes("status:423")&&server.includes('hasClaim(player.playerId,rawRow.id)'),'Server clear validation does not require the active claimant');
|
||||
assert(realtime.includes('5 * 60 * 1000')&&realtime.includes("releaseBoardClaim(boardId, 'moved')")&&realtime.includes("reason:'expired'"),'Five-minute lease or board-switch release behavior is missing');
|
||||
assert(realtime.includes("message.type === 'viewport'")&&realtime.includes("message.type === 'cursor-hide'")&&realtime.includes('pointInViewport'),'Realtime fan-out is not viewport-filtered');
|
||||
assert(!Object.keys(pkg.dependencies||{}).length&&!Object.keys(pkg.devDependencies||{}).length,'Phase 2 added an unnecessary runtime dependency');
|
||||
console.log('Shared-world phase 2 source guards passed');
|
||||
33
test/realtime-lease-unit-test.js
Normal file
33
test/realtime-lease-unit-test.js
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
'use strict';
|
||||
const http=require('http');
|
||||
const assert=require('assert/strict');
|
||||
const {createRealtimeHub}=require('../realtime-server');
|
||||
const {connectRealtime}=require('./helpers/realtime-client');
|
||||
|
||||
(async()=>{
|
||||
const server=http.createServer((_req,res)=>{res.writeHead(404);res.end()});
|
||||
const identities=new Map([
|
||||
['alice',{playerId:'alice',token:'a',name:'Alice'}],
|
||||
['bob',{playerId:'bob',token:'b',name:'Bob'}]
|
||||
]);
|
||||
const hub=createRealtimeHub({
|
||||
server,claimTtlMs:120,
|
||||
authenticate:async({playerId,token})=>{const identity=identities.get(playerId);if(!identity||identity.token!==token)throw new Error('bad auth');return identity},
|
||||
getBoardInfo:async boardId=>boardId==='B0'?{solved:false,bounds:{minX:0,minY:0,maxX:1,maxY:1}}:null
|
||||
});
|
||||
await new Promise(resolve=>server.listen(0,'127.0.0.1',resolve));
|
||||
const base=`http://127.0.0.1:${server.address().port}`;
|
||||
let alice=null,bob=null;
|
||||
try{
|
||||
alice=await connectRealtime(base,{playerId:'alice',token:'a'});bob=await connectRealtime(base,{playerId:'bob',token:'b'});
|
||||
alice.send({type:'viewport',minX:-2,minY:-2,maxX:2,maxY:2});bob.send({type:'viewport',minX:-2,minY:-2,maxX:2,maxY:2});await alice.waitFor('snapshot');await bob.waitFor('snapshot');
|
||||
alice.send({type:'claim',requestId:'lease-a',boardId:'B0'});assert.equal((await alice.waitFor(message=>message.type==='claim-result'&&message.requestId==='lease-a')).ok,true);await bob.waitFor(message=>message.type==='claim'&&message.claim?.boardId==='B0');
|
||||
alice.close();alice=null;
|
||||
bob.send({type:'claim',requestId:'lease-b-early',boardId:'B0'});const early=await bob.waitFor(message=>message.type==='claim-result'&&message.requestId==='lease-b-early');assert.equal(early.ok,false);assert.equal(early.reason,'occupied');
|
||||
await new Promise(resolve=>setTimeout(resolve,170));bob.send({type:'snapshot-request'});await bob.waitFor(message=>message.type==='claim-release'&&message.boardId==='B0'&&message.reason==='expired');
|
||||
bob.send({type:'claim',requestId:'lease-b-late',boardId:'B0'});const late=await bob.waitFor(message=>message.type==='claim-result'&&message.requestId==='lease-b-late');assert.equal(late.ok,true);
|
||||
console.log('Realtime lease expiry and disconnect semantics passed');
|
||||
}finally{
|
||||
alice?.close();bob?.close();hub.close();await new Promise(resolve=>server.close(resolve));
|
||||
}
|
||||
})().catch(error=>{console.error(error);process.exitCode=1});
|
||||
70
test/realtime-phase2-smoke-test.js
Normal file
70
test/realtime-phase2-smoke-test.js
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
'use strict';
|
||||
const {spawn}=require('child_process');
|
||||
const fs=require('fs');
|
||||
const os=require('os');
|
||||
const path=require('path');
|
||||
const assert=require('assert/strict');
|
||||
const {root,starterPuzzle}=require('./helpers/app-source');
|
||||
const {connectRealtime}=require('./helpers/realtime-client');
|
||||
|
||||
const port=20000+Math.floor(Math.random()*10000);
|
||||
const dataDir=fs.mkdtempSync(path.join(os.tmpdir(),'bend-field-realtime-phase2-'));
|
||||
const child=spawn(process.execPath,[path.join(root,'server.js')],{
|
||||
env:{...process.env,PORT:String(port),HOST:'127.0.0.1',BEND_FIELD_DATA_DIR:dataDir},stdio:['ignore','pipe','pipe']
|
||||
});
|
||||
let stderr='',aliceRealtime=null,bobRealtime=null;child.stderr.on('data',chunk=>stderr+=chunk);
|
||||
const base=`http://127.0.0.1:${port}`;
|
||||
const sleep=ms=>new Promise(resolve=>setTimeout(resolve,ms));
|
||||
async function request(url,options={}){const response=await fetch(base+url,options),body=await response.json();return{response,body}}
|
||||
function auth(session){return{authorization:`Bearer ${session.playerId}.${session.token}`,'content-type':'application/json'}}
|
||||
function boardMeta(id,x,seed,puzzle){return{id,x,y:0,chunks:[[0,0]],level:1,targetLevel:1,seed,axis:'MIX',sealedSides:[],puzzle,rev:1,revAuthor:'client'}}
|
||||
function solvedState(puzzle){return{paths:puzzle.solution.map(route=>({startGate:route.startGate,endGate:route.endGate,cells:route.cells.map(cell=>[...cell])})),solved:true,expanded:false,rev:2,revAuthor:'client'}}
|
||||
|
||||
(async()=>{
|
||||
for(let index=0;index<80;index++){try{const status=await request('/api/cloud/status');if(status.response.ok)break}catch(_){}await sleep(50)}
|
||||
const status=await request('/api/cloud/status');assert.equal(status.body.realtime,true);assert.equal(status.body.claimTtlMs,300000);
|
||||
const alice=(await request('/api/cloud/session',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({name:'Alice'})})).body;
|
||||
const bob=(await request('/api/cloud/session',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({name:'Bob'})})).body;
|
||||
const puzzle=starterPuzzle(),b0=boardMeta('B0',0,111,puzzle),b1=boardMeta('B1',2,222,puzzle);
|
||||
const bootstrap=await request('/api/cloud/push',{method:'POST',headers:auth(alice),body:JSON.stringify({baseRevision:0,global:{schema:31,appVersion:'47.70',generatorVersion:5,worldGeneration:'v47-field-reset-20260728-interaction-fix',nextId:2},metas:[b0,b1],states:[{id:'B0',value:{paths:[],solved:false,rev:1,revAuthor:'alice'}},{id:'B1',value:{paths:[],solved:false,rev:1,revAuthor:'alice'}}],deleted:[]})});
|
||||
assert.equal(bootstrap.response.status,200);assert.equal(bootstrap.body.revision,1);
|
||||
|
||||
aliceRealtime=await connectRealtime(base,alice);bobRealtime=await connectRealtime(base,bob);
|
||||
aliceRealtime.send({type:'viewport',minX:-8,minY:-8,maxX:8,maxY:8});bobRealtime.send({type:'viewport',minX:-8,minY:-8,maxX:8,maxY:8});
|
||||
await aliceRealtime.waitFor('snapshot');await bobRealtime.waitFor('snapshot');
|
||||
|
||||
aliceRealtime.send({type:'cursor',x:.25,y:.5,cursorStyle:'default'});
|
||||
const cursor=await bobRealtime.waitFor(message=>message.type==='cursor'&&message.playerId===alice.playerId);
|
||||
assert.equal(cursor.name,'Alice');assert.equal(cursor.x,.25);assert.equal(cursor.y,.5);assert.equal(cursor.cursorStyle,'default');
|
||||
aliceRealtime.send({type:'cursor-hide'});await bobRealtime.waitFor(message=>message.type==='player-left'&&message.presenceId===aliceRealtime.ready.presenceId);
|
||||
await sleep(55);aliceRealtime.send({type:'cursor',x:.25,y:.5,cursorStyle:'default'});await bobRealtime.waitFor(message=>message.type==='cursor'&&message.playerId===alice.playerId);
|
||||
|
||||
aliceRealtime.send({type:'claim',requestId:'alice-b0',boardId:'B0'});
|
||||
const aliceClaim=await aliceRealtime.waitFor(message=>message.type==='claim-result'&&message.requestId==='alice-b0');assert.equal(aliceClaim.ok,true);assert.equal(aliceClaim.claim.boardId,'B0');
|
||||
const observedClaim=await bobRealtime.waitFor(message=>message.type==='claim'&&message.claim?.boardId==='B0');assert.equal(observedClaim.claim.playerName,'Alice');
|
||||
|
||||
bobRealtime.send({type:'claim',requestId:'bob-b0',boardId:'B0'});
|
||||
const denied=await bobRealtime.waitFor(message=>message.type==='claim-result'&&message.requestId==='bob-b0');assert.equal(denied.ok,false);assert.equal(denied.reason,'occupied');
|
||||
|
||||
const rejectedClear=await request('/api/cloud/push',{method:'POST',headers:auth(bob),body:JSON.stringify({baseRevision:1,global:{nextId:2},metas:[],states:[{id:'B0',value:solvedState(puzzle)}],deleted:[]})});
|
||||
assert.equal(rejectedClear.response.status,423);assert.match(rejectedClear.body.error,/claim/i);
|
||||
|
||||
const clearPromise=bobRealtime.waitFor(message=>message.type==='board-cleared'&&message.event?.id==='B0');
|
||||
const releasePromise=bobRealtime.waitFor(message=>message.type==='claim-release'&&message.boardId==='B0'&&message.reason==='cleared');
|
||||
const cleared=await request('/api/cloud/push',{method:'POST',headers:auth(alice),body:JSON.stringify({baseRevision:1,global:{nextId:2,lastSolveAt:Date.now()},metas:[],states:[{id:'B0',value:solvedState(puzzle)}],deleted:[]})});
|
||||
assert.equal(cleared.response.status,200);assert.equal(cleared.body.clearEvents[0].playerName,'Alice');
|
||||
const clearEvent=await clearPromise;assert.equal(clearEvent.event.level,1);await releasePromise;
|
||||
|
||||
const profilePromise=bobRealtime.waitFor(message=>message.type==='player-profile'&&message.playerId===alice.playerId);
|
||||
const renamed=await request('/api/cloud/profile',{method:'POST',headers:auth(alice),body:JSON.stringify({name:'Alice 2'})});assert.equal(renamed.response.status,200);assert.equal((await profilePromise).name,'Alice 2');
|
||||
|
||||
aliceRealtime.send({type:'claim',requestId:'alice-b1',boardId:'B1'});
|
||||
const b1Claim=await aliceRealtime.waitFor(message=>message.type==='claim-result'&&message.requestId==='alice-b1');assert.equal(b1Claim.ok,true);
|
||||
await bobRealtime.waitFor(message=>message.type==='claim'&&message.claim?.boardId==='B1');
|
||||
aliceRealtime.close();aliceRealtime=null;
|
||||
await bobRealtime.waitFor(message=>message.type==='player-left'&&message.playerId===alice.playerId);
|
||||
bobRealtime.send({type:'claim',requestId:'bob-b1-after-disconnect',boardId:'B1'});
|
||||
const retained=await bobRealtime.waitFor(message=>message.type==='claim-result'&&message.requestId==='bob-b1-after-disconnect');assert.equal(retained.ok,false);assert.equal(retained.reason,'occupied');
|
||||
|
||||
console.log('BEND FIELD realtime phase 2 smoke test passed');
|
||||
})().catch(error=>{console.error(error);if(stderr)console.error(stderr);process.exitCode=1}).finally(()=>{aliceRealtime?.close();bobRealtime?.close();child.kill('SIGTERM');fs.rmSync(dataDir,{recursive:true,force:true})});
|
||||
24
test/reset-smoke-test.js
Normal file
24
test/reset-smoke-test.js
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
'use strict';
|
||||
const {vm,app,assert,functionSource}=require('./helpers/app-source');
|
||||
const removed=[],deleted=[];
|
||||
const staleSessionJournal='bend-field:v30:v47-field-reset-20260727:journal:stale-session',retired=[
|
||||
{schema:31,generation:'v47-field-reset-20260728-bugfix'},
|
||||
{schema:30,generation:'v47-field-reset-20260727'}
|
||||
];
|
||||
const context={
|
||||
RETIRED_WORLD_STORES:retired,
|
||||
safeLocalRemove:key=>{removed.push(key);return true},
|
||||
safeLocalKeys:prefix=>staleSessionJournal.startsWith(prefix)?[staleSessionJournal]:[],
|
||||
indexedDB:{deleteDatabase:name=>deleted.push(name)}
|
||||
};
|
||||
vm.createContext(context);
|
||||
vm.runInContext(`${functionSource('deleteRetiredWorldData')}\nthis.run=deleteRetiredWorldData;`,context);
|
||||
context.run();
|
||||
for(const item of retired){
|
||||
const prefix=`bend-field:v${item.schema}:${item.generation}`;
|
||||
for(const suffix of[':compact',':recovery',':journal',':revision',':signal',':world:lease'])assert(removed.includes(prefix+suffix),`Old save ${suffix} was not removed`);
|
||||
assert(deleted.includes(`${prefix}:world`),'Old IndexedDB was not deleted');
|
||||
}
|
||||
assert(removed.includes(staleSessionJournal),'Old per-session recovery journal was not removed');
|
||||
assert(app.includes("SAVE_SCHEMA=31,STORAGE_SCHEMA=30,IDB_LAYOUT_VERSION=8,FIELD_STORAGE_FORMAT=2,GAMEPLAY_DATA_VERSION=3,WORLD_GENERATION='v47-field-reset-20260728-interaction-fix'"),'v47.36 interaction-fix field reset generation is not active');
|
||||
console.log('Full field reset test passed');
|
||||
16
test/run-all.js
Normal file
16
test/run-all.js
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
'use strict';
|
||||
const path=require('path');
|
||||
const fs=require('fs');
|
||||
const {execFileSync}=require('child_process');
|
||||
const tests=[
|
||||
'source-smoke-test.js','v4771-ui-input-smoke-test.js','v4772-ownership-reaction-name-smoke-test.js',
|
||||
'v4774-drag-overview-smoke-test.js','v4775-pan-cursor-performance-smoke-test.js','v4776-frame-pipeline-smoke-test.js','v4777-hud-input-performance-smoke-test.js','v4778-interaction-scheduler-smoke-test.js','cleanup-performance-smoke-test.js','field-persistence-smoke-test.js','field-save-load-v2-smoke-test.js','gameplay-simplification-smoke-test.js','economy-simulation-test.js','performance-smoke-test.js','mirror-chunk-smoke-test.js','storage-smoke-test.js','save-pipeline-smoke-test.js','concurrency-smoke-test.js','stage34-smoke-test.js',
|
||||
'expansion-repair-smoke-test.js','expansion-smoke-test.js','interaction-smoke-test.js','anomaly-smoke-test.js','special-cell-smoke-test.js','reset-smoke-test.js','shared-world-client-smoke-test.js','phase2-source-smoke-test.js','realtime-lease-unit-test.js','realtime-phase2-smoke-test.js','server-smoke-test.js','shared-world-complete-smoke-test.js','security-authority-smoke-test.js'
|
||||
];
|
||||
for(const file of tests)execFileSync(process.execPath,[path.join(__dirname,file)],{stdio:'inherit'});
|
||||
const browserPath=process.env.BEND_FIELD_BROWSER_PATH||process.env.BEND_FIELD_EDGE_PATH||(process.platform==='win32'?'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe':'/usr/bin/chromium');
|
||||
if(process.env.BEND_FIELD_RUN_BROWSER_TESTS==='1'){
|
||||
if(!fs.existsSync(browserPath))throw new Error(`No supported browser was found at ${browserPath}`);
|
||||
execFileSync(process.execPath,[path.join(__dirname,'browser-performance-benchmark.js')],{stdio:'inherit',env:{...process.env,BEND_FIELD_BROWSER_PATH:browserPath,BEND_FIELD_EDGE_PATH:browserPath,BEND_FIELD_BENCHMARK_PROFILE:'small'}});
|
||||
execFileSync(process.execPath,[path.join(__dirname,'browser-field-storage-benchmark.js')],{stdio:'inherit',env:{...process.env,BEND_FIELD_BROWSER_PATH:browserPath,BEND_FIELD_SCALE_SIZES:'1000'}});
|
||||
}else console.warn('Real-browser tests are opt-in. Set BEND_FIELD_RUN_BROWSER_TESTS=1 or run the benchmark scripts directly.');
|
||||
143
test/save-pipeline-smoke-test.js
Normal file
143
test/save-pipeline-smoke-test.js
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
'use strict';
|
||||
const {vm,app,assert,functionSource}=require('./helpers/app-source');
|
||||
for(const marker of ['GATE_ARROW','legacyStorageKeys','saveFailed','minimapTransform','generatorMode','hydrateRemaining','boardAtGlobalCell','areaDifficultyShift','plannedExpansionFrontiers','metaIsVisible','mergeLatestFromStorage','shopBtn','shopCountEl']){
|
||||
assert(!app.includes(marker),`Dead marker remains: ${marker}`);
|
||||
}
|
||||
const saveSource=functionSource('save'),persistSource=functionSource('persistDirtyToDb'),
|
||||
pullCloudSource=functionSource('pullCloudWorld'),pushCloudSource=functionSource('pushCloudPending');
|
||||
assert(!saveSource.includes('writeCompactMirror'),'save() still writes the full mirror before persistence');
|
||||
assert((persistSource.match(/snapshotForStorage\(/g)||[]).length===1&&(persistSource.match(/writeCompactMirror\(/g)||[]).length===1,'IndexedDB persistence still builds or writes a full mirror');
|
||||
assert(persistSource.includes('scheduleMirrorCheckpoint()'),'Successful IndexedDB persistence does not schedule an idle recovery checkpoint');
|
||||
assert(!app.includes("pagehide',()=>{try{writeCompactMirror")&&!app.includes("visibilitychange',()=>{if(document.visibilityState==='hidden')try{writeCompactMirror"),'Lifecycle handlers still rebuild the mirror directly');
|
||||
assert(pullCloudSource.indexOf('cloudProfileIdentity()!==profileIdentity')<pullCloudSource.indexOf('data.cloudRevision='),'Cloud pull mutates revision before confirming the active profile');
|
||||
assert(pushCloudSource.includes('mergeCloudPending(cloudPushPending,pending)')&&pushCloudSource.indexOf('cloudProfileIdentity()!==profileIdentity')<pushCloudSource.indexOf('acknowledgeCloudPending('),'Cloud push acknowledges work before confirming the active profile');
|
||||
{
|
||||
const batchContext={};vm.createContext(batchContext);vm.runInContext(`${functionSource('emptyCloudPending')}\n${functionSource('takeCloudPendingBatch')}\nthis.takeCloudPendingBatch=takeCloudPendingBatch;`,batchContext);
|
||||
const source={metaIds:new Set(Array.from({length:600},(_,index)=>`B${index}`)),stateIds:new Set(['B0']),deleted:new Set(['B999']),globalChanged:true},
|
||||
{batch,remainder}=batchContext.takeCloudPendingBatch(source,512);
|
||||
assert(batch.metaIds.size===512&&remainder.metaIds.size===88&&remainder.stateIds.has('B0')&&remainder.deleted.has('B999')&&batch.globalChanged,'Cloud change batching lost or exceeded work');
|
||||
}
|
||||
|
||||
const writes={meta:0,state:0,global:0,outbox:0,recovery:0,mirror:0,snapshot:0,checkpoint:0,revision:0,journalClear:0,signal:0,cloud:0};
|
||||
const recoveryWrites=[],journalClears=[];
|
||||
const request=result=>({result});
|
||||
const stores={
|
||||
control:{get:()=>request({key:'active',activeFormat:2,activeEpoch:'world:test-epoch'})},
|
||||
worlds:{get:()=>request({epoch:'world:test-epoch',status:'active',createdAt:1,source:{kind:'fresh'},global:null}),put:()=>writes.global++},
|
||||
boardIndex:{put:()=>{},delete:()=>{},get:()=>request(null)},boardPuzzles:{put:()=>writes.meta++,delete:()=>{},get:()=>request(null)},boardStates:{put:()=>writes.state++,delete:()=>{},get:()=>request(null)},
|
||||
outboxV2:{put:()=>writes.outbox++,delete:()=>{}},recoveryV2:{put:row=>{writes.recovery++;recoveryWrites.push(row)},delete:()=>{}},tombstonesV2:{put:()=>{},delete:()=>{},get:()=>request(null)}
|
||||
};
|
||||
const context={
|
||||
console,sessionId:'test-session',lastRevision:100,
|
||||
data:{worldEpoch:'world:test-epoch',globalRev:10,globalRevAuthor:'test-session',metas:{B0:{id:'B0',rev:7,revAuthor:'test-session'}},states:{B0:{rev:9,revAuthor:'test-session'}},updatedAt:0,clockFloor:0},
|
||||
dirtyMetaIds:new Set(['B0']),dirtyStateIds:new Set(['B0']),deletedBoardIds:new Set(),deletedBoardRevisions:new Map(),
|
||||
deletedBoardAuthors:new Map(),
|
||||
recoveryJournalsToCover:[{sessionId:'prior-session',seq:6,_storageKey:'journal:prior'}],recoveryJournalSeq:3,
|
||||
cloudOutboxDeleteKeys:new Set(),cloudJournalMetaIds:new Set(),cloudJournalStateIds:new Set(),cloudJournalDeletedIds:new Set(),cloudApiEnabled:false,
|
||||
globalDirty:true,globalChangeSeq:3,cloudJournalGlobalChanged:true,worldSignalSeq:0,idbAvailable:true,activeStorageFormat:2,FIELD_STORAGE_FORMAT:2,storageAccessError:null,storageKey:'save',
|
||||
pruneAndCount:()=>{},hasPendingPersistence:()=>context.globalDirty||context.dirtyMetaIds.size>0||context.dirtyStateIds.size>0||context.deletedBoardIds.size>0,
|
||||
validWorldEpoch:value=>typeof value==='string'&&value.startsWith('world:'),storedWorldEpoch:()=>null,createWorldEpoch:()=> 'world:test-epoch',rememberWorldEpoch:()=>true,
|
||||
revisionVersion:value=>({rev:value?.rev||0,revAuthor:value?.revAuthor||value?.author||''}),compareRevisionVersions:(a,b)=>(a?.rev||0)-(b?.rev||0)||String(a?.revAuthor||a?.author||'').localeCompare(String(b?.revAuthor||b?.author||'')),newerRevisionValue:(a,b)=>((a?.rev||0)>=(b?.rev||0)?a:b),
|
||||
trustedNow:()=>123456,
|
||||
globalForStorage:(source,updatedAt)=>({worldEpoch:source.worldEpoch,globalRev:source.globalRev,globalRevAuthor:source.globalRevAuthor,updatedAt,clockFloor:updatedAt}),
|
||||
mergeGlobalRecords:(_stored,incoming)=>incoming,applyGlobalRecordToData:record=>Object.assign(context.data,record),staleWorldEpochError:()=>Object.assign(new Error('stale'),{code:'STALE_WORLD_EPOCH'}),
|
||||
metaForStorage:value=>({...value}),stateForStorage:value=>({...value}),
|
||||
metaRowsForStorage:ids=>ids.map(id=>context.data.metas[id]&&({...context.data.metas[id]})).filter(Boolean),
|
||||
stateRowsForStorage:ids=>ids.map(id=>context.data.states[id]&&({id,value:{...context.data.states[id]}})).filter(Boolean),
|
||||
snapshotForStorage:updatedAt=>{writes.snapshot++;return{updatedAt}},
|
||||
openWorldDb:async()=>({transaction:()=>({objectStore:name=>stores[name],abort(){}})}),transactionDone:async()=>{},requestValue:async req=>req.result,
|
||||
recoveryWalPromise:Promise.resolve(),normalizeMeta:(_id,value)=>value,normalizeState:value=>value,normalizedStateObjects:new WeakSet(),
|
||||
boardIndexSummaries:new Map(),
|
||||
metaFromV2Records:()=>null,stateFromV2Record:()=>null,puzzleRecordV2:value=>value,stateRecordV2:(_id,value)=>value,summarizeBoardV2:value=>value,
|
||||
fieldBoundsFromMetas:()=>({minX:0,minY:0,maxX:1,maxY:1}),SAVE_SCHEMA:31,WORLD_GENERATION:'current-only-test',
|
||||
writeCompactMirror:snapshot=>{assert(snapshot.updatedAt===123456,'Mirror did not use the captured persistence snapshot');writes.mirror++;return true},safeLocalSet:()=>{writes.mirror++;return true},
|
||||
scheduleMirrorCheckpoint:()=>writes.checkpoint++,
|
||||
updateStorageRevision:()=>writes.revision++,clearRecoveryJournalIfCovered:(seq,covered)=>{writes.journalClear++;journalClears.push({seq,covered})},
|
||||
broadcastWorldSignal:()=>writes.signal++,scheduleCloudPush:()=>writes.cloud++,
|
||||
perfStart:()=>0,perfEnd:()=>0,perfGauge:()=>{},deepClone:value=>JSON.parse(JSON.stringify(value)),resetHistory:[],invalidateStoreEffectCache:()=>{},statsDirty:false
|
||||
};
|
||||
vm.createContext(context);
|
||||
vm.runInContext(`${persistSource}\nthis.persistDirtyToDb=persistDirtyToDb;`,context);
|
||||
(async()=>{
|
||||
const first=await context.persistDirtyToDb();
|
||||
assert(!first.skipped,'Dirty save was skipped');
|
||||
assert(writes.meta===1&&writes.state===1&&writes.global===1,'Incremental database stores were not written once');
|
||||
assert(writes.snapshot===0&&writes.mirror===0,'Ordinary IndexedDB persistence built or synchronously wrote a full mirror');
|
||||
assert(writes.checkpoint===1,'Ordinary IndexedDB persistence did not schedule one idle mirror checkpoint');
|
||||
assert(recoveryWrites.some(row=>row.key==='covered:test-session'&&row.value.seq===3)&&recoveryWrites.some(row=>row.key==='covered:prior-session'&&row.value.seq===6),'Persistence did not atomically write current and recovered-session coverage markers');
|
||||
assert(journalClears.length===1&&journalClears[0].seq===3&&journalClears[0].covered[0]._storageKey==='journal:prior','Committed recovery journals were not passed to cleanup after persistence');
|
||||
assert(context.dirtyMetaIds.size===0&&context.dirtyStateIds.size===0&&!context.globalDirty,'Dirty state was not cleared after persistence');
|
||||
const second=await context.persistDirtyToDb();
|
||||
assert(second.skipped,'Unchanged persistence did not exit early');
|
||||
assert(writes.snapshot===0&&writes.mirror===0&&writes.checkpoint===1&&writes.global===1,'Unchanged persistence performed additional work');
|
||||
|
||||
context.deletedBoardIds.add('B9');context.deletedBoardRevisions.set('B9',10);
|
||||
context.transactionDone=async()=>{context.deletedBoardRevisions.set('B9',11)};
|
||||
const deletionCommit=await context.persistDirtyToDb();
|
||||
assert(!deletionCommit.skipped&&context.deletedBoardIds.has('B9')&&context.deletedBoardRevisions.get('B9')===11,'A newer deletion tombstone was cleared by an older in-flight commit');
|
||||
|
||||
context.dirtyMetaIds.add('B0');context.dirtyStateIds.add('B0');context.globalDirty=true;context.globalChangeSeq++;
|
||||
const beforeFailure={mirror:writes.mirror,snapshot:writes.snapshot,checkpoint:writes.checkpoint,signal:writes.signal,cloud:writes.cloud};
|
||||
context.transactionDone=async()=>{throw new Error('transaction failed')};
|
||||
let failed=false;try{await context.persistDirtyToDb()}catch(_){failed=true}
|
||||
assert(failed,'Database failure was swallowed');
|
||||
assert(context.dirtyMetaIds.has('B0')&&context.dirtyStateIds.has('B0')&&context.globalDirty,'Database failure cleared dirty state');
|
||||
assert(writes.mirror===beforeFailure.mirror&&writes.snapshot===beforeFailure.snapshot&&writes.checkpoint===beforeFailure.checkpoint&&writes.signal===beforeFailure.signal&&writes.cloud===beforeFailure.cloud,'Database failure published or checkpointed an uncommitted save');
|
||||
|
||||
context.idbAvailable=false;context.transactionDone=async()=>{};context.writeCompactMirror=()=>false;context.storageAccessError=new Error('mirror failed');
|
||||
failed=false;try{await context.persistDirtyToDb()}catch(_){failed=true}
|
||||
assert(failed,'Mirror-only storage failure was swallowed');
|
||||
assert(writes.snapshot===1,'Mirror-only storage did not build its required fallback snapshot');
|
||||
assert(context.dirtyMetaIds.has('B0')&&context.dirtyStateIds.has('B0')&&context.globalDirty,'Mirror failure cleared dirty state');
|
||||
|
||||
let scheduled=0,immediate=0;
|
||||
const saveContext={
|
||||
Promise,SAVE_DELAY:180,saveTimer:null,lifecyclePersistenceSuppressed:false,
|
||||
hasPendingPersistence:()=>false,persistNow:()=>{immediate++;return Promise.resolve(true)},
|
||||
setSaveStatus:()=>{},clearTimeout:()=>{},setTimeout:()=>{scheduled++;return 1}
|
||||
};
|
||||
vm.createContext(saveContext);vm.runInContext(`${saveSource}\nthis.save=save;`,saveContext);
|
||||
assert(saveContext.save()===true&&scheduled===0,'No-op save scheduled work');
|
||||
await saveContext.save(true);assert(immediate===0,'No-op immediate save invoked persistence');
|
||||
saveContext.hasPendingPersistence=()=>true;
|
||||
saveContext.save();assert(scheduled===1,'Dirty deferred save was not scheduled');
|
||||
await saveContext.save(true);assert(immediate===1,'Dirty immediate save did not persist');
|
||||
|
||||
let checkpointPersists=0;
|
||||
const checkpointTimers=[],checkpointContext={
|
||||
cloudCheckpointRetryTimer:0,hasPendingPersistence:()=>true,persistNow:async()=>{checkpointPersists++;return false},
|
||||
setTimeout:(callback,delay)=>{checkpointTimers.push({callback,delay});return checkpointTimers.length}
|
||||
};
|
||||
vm.createContext(checkpointContext);vm.runInContext(`${functionSource('scheduleCloudCheckpointRetry')}\nthis.scheduleCloudCheckpointRetry=scheduleCloudCheckpointRetry;`,checkpointContext);
|
||||
checkpointContext.scheduleCloudCheckpointRetry();checkpointContext.scheduleCloudCheckpointRetry();
|
||||
assert(checkpointTimers.length===1&&checkpointTimers[0].delay===1000,'Cloud checkpoint retries were not coalesced behind one timer');
|
||||
checkpointTimers[0].callback();await Promise.resolve();await Promise.resolve();
|
||||
assert(checkpointPersists===1&&checkpointTimers.length===2,'Failed cloud checkpoint was not retried after the guarded persistence attempt');
|
||||
|
||||
let profileRetrySchedules=0;
|
||||
const profileContext={
|
||||
data:{cloudProfile:null,cloudRevision:9},serverClockOffset:null,Date,
|
||||
fetchJson:async()=>({playerId:'a'.repeat(16),token:'b'.repeat(32),serverTime:Date.now()}),
|
||||
markGlobalDirty:()=>{},persistNow:async()=>false,scheduleCloudCheckpointRetry:()=>profileRetrySchedules++
|
||||
};
|
||||
vm.createContext(profileContext);vm.runInContext(`${functionSource('createCloudProfile')}\nthis.createCloudProfile=createCloudProfile;`,profileContext);
|
||||
let profileFailed=false;try{await profileContext.createCloudProfile()}catch(_){profileFailed=true}
|
||||
assert(profileFailed&&profileRetrySchedules===1&&profileContext.data.cloudProfile?.playerId==='a'.repeat(16),'Uncommitted cloud profile was treated as a completed checkpoint');
|
||||
|
||||
let acknowledged=0,armedDelay=null;
|
||||
const pending={metaIds:new Set(['B0']),stateIds:new Set(),deleted:new Set(),globalChanged:false};
|
||||
const cloudContext={
|
||||
data:{cloudProfile:{playerId:'a'.repeat(16),token:'b'.repeat(32)},cloudRevision:5,metas:{B0:{id:'B0',rev:7}},states:{}},
|
||||
cloudAvailable:true,cloudSyncing:false,cloudPushTimer:null,cloudPushPending:pending,cloudJournalChangeSeq:1,serverClockOffset:null,
|
||||
clearTimeout:()=>{},setCloudStatus:()=>{},sharedWorldGlobalSignature:()=>"world",sharedWorldGlobalForCloud:()=>({}),lastCloudWorldGlobalSignature:"old",applyCloudEnvelope:()=>{},cloudRowsForStorage:async ids=>({metas:[...ids].map(id=>({id,rev:7})),states:[]}),
|
||||
cloudAuthHeaders:()=>({}),fetchJson:async()=>{cloudContext.data.cloudProfile={playerId:'c'.repeat(16),token:'d'.repeat(32)};return{revision:6,serverTime:Date.now()}},
|
||||
acknowledgeCloudPending:()=>acknowledged++,markGlobalDirty:()=>{},persistNow:async()=>true,scheduleCloudCheckpointRetry:()=>{},
|
||||
pullCloudWorld:async()=>true,armCloudPush:delay=>{armedDelay=delay;return true},console:{warn:()=>{}},Date
|
||||
};
|
||||
vm.createContext(cloudContext);
|
||||
vm.runInContext(`${functionSource('emptyCloudPending')}\n${functionSource('mergeCloudPending')}\n${functionSource('takeCloudPendingBatch')}\n${functionSource('cloudPendingHasWork')}\n${functionSource('cloudProfileIdentity')}\n${pushCloudSource}\nthis.pushCloudPending=pushCloudPending;`,cloudContext);
|
||||
const profileChangedResult=await cloudContext.pushCloudPending();
|
||||
assert(profileChangedResult===false&&acknowledged===0&&cloudContext.data.cloudRevision===5,'Cloud response from an obsolete profile was acknowledged');
|
||||
assert(cloudContext.cloudPushPending.metaIds.has('B0')&&armedDelay===5000,'Profile-switch guard did not restore and defer pending cloud work');
|
||||
console.log('Save pipeline cleanup test passed');
|
||||
})().catch(error=>{console.error(error);process.exitCode=1});
|
||||
14
test/security-authority-smoke-test.js
Normal file
14
test/security-authority-smoke-test.js
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
'use strict';
|
||||
const {spawn}=require('child_process');const fs=require('fs');const os=require('os');const path=require('path');const assert=require('assert/strict');
|
||||
const {root,starterPuzzle}=require('./helpers/app-source');const {connectRealtime}=require('./helpers/realtime-client');
|
||||
const port=32000+Math.floor(Math.random()*2000),dataDir=fs.mkdtempSync(path.join(os.tmpdir(),'bend-auth-')),child=spawn(process.execPath,[path.join(root,'server.js')],{env:{...process.env,PORT:String(port),HOST:'127.0.0.1',BEND_FIELD_DATA_DIR:dataDir},stdio:['ignore','pipe','pipe']});let stderr='',ws;child.stderr.on('data',c=>stderr+=c);const base=`http://127.0.0.1:${port}`,sleep=ms=>new Promise(r=>setTimeout(r,ms));
|
||||
async function req(url,opt={}){const response=await fetch(base+url,opt);return{response,body:await response.json()}}function auth(s){return{authorization:`Bearer ${s.playerId}.${s.token}`,'content-type':'application/json'}}
|
||||
function meta(id,x,seed,p){return{id,x,y:0,chunks:[[0,0]],level:1,targetLevel:1,seed,axis:'MIX',sealedSides:[],puzzle:p,rev:1,revAuthor:'x'}}
|
||||
(async()=>{for(let i=0;i<100;i++){try{if((await req('/api/cloud/status')).response.ok)break}catch{}await sleep(30)}const alice=(await req('/api/cloud/session',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({name:'Alice'})})).body,p=starterPuzzle(),b0=meta('B0',0,15,p);
|
||||
let r=await req('/api/cloud/push',{method:'POST',headers:auth(alice),body:JSON.stringify({baseRevision:0,global:{nextId:1},metas:[b0],states:[{id:'B0',value:{paths:[],solved:false}}]})});assert.equal(r.response.status,200);ws=await connectRealtime(base,alice);ws.send({type:'viewport',minX:-5,minY:-5,maxX:5,maxY:5});await ws.waitFor('snapshot');ws.send({type:'claim',requestId:'c',boardId:'B0'});assert.equal((await ws.waitFor(m=>m.type==='claim-result'&&m.requestId==='c')).ok,true);
|
||||
const solved={paths:p.solution.map(q=>({startGate:q.startGate,endGate:q.endGate,cells:q.cells})),solved:true,scoreAwarded:Number.MAX_SAFE_INTEGER,store:{pathIndex:0,cellIndex:0,itemIds:['level-min-10'],priceCoefficient:0,purchases:[]}};
|
||||
r=await req('/api/cloud/push',{method:'POST',headers:auth(alice),body:JSON.stringify({baseRevision:1,global:{nextId:1},metas:[],states:[{id:'B0',value:solved}]})});assert.equal(r.response.status,200);const pulled=await req('/api/cloud/pull?since=0',{headers:auth(alice)}),state=pulled.body.page.states.B0;assert(state.scoreAwarded>0&&state.scoreAwarded<100000,'server must replace forged reward');assert(state.store&&state.store.priceCoefficient>=.8&&state.store.priceCoefficient<=1.2,'server must replace forged store pricing');assert.equal(state.store.itemIds.length,13);
|
||||
const econ=(await req('/api/player/state',{headers:auth(alice)})).body.player;assert.equal(econ.earnedScore,state.scoreAwarded);assert.equal(econ.availableScore,state.scoreAwarded);
|
||||
const forged=meta('B1',999999,123,p);r=await req('/api/cloud/push',{method:'POST',headers:auth(alice),body:JSON.stringify({baseRevision:2,global:{nextId:2},metas:[forged],states:[{id:'B1',value:{paths:[],solved:false}}]})});assert.notEqual(r.response.status,200,'non-adjacent forged board must be rejected');
|
||||
console.log('Server authority security smoke test passed');
|
||||
})().catch(e=>{console.error(e);if(stderr)console.error(stderr);process.exitCode=1}).finally(()=>{ws?.close();child.kill('SIGTERM');fs.rmSync(dataDir,{recursive:true,force:true})});
|
||||
69
test/server-smoke-test.js
Normal file
69
test/server-smoke-test.js
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
'use strict';
|
||||
const {spawn}=require('child_process');
|
||||
const fs=require('fs');
|
||||
const os=require('os');
|
||||
const path=require('path');
|
||||
const assert=require('assert/strict');
|
||||
const {root,starterPuzzle}=require('./helpers/app-source');
|
||||
const {connectRealtime}=require('./helpers/realtime-client');
|
||||
|
||||
const port=19000+Math.floor(Math.random()*10000);
|
||||
const dataDir=fs.mkdtempSync(path.join(os.tmpdir(),'bend-field-shared-world-'));
|
||||
const child=spawn(process.execPath,[path.join(root,'server.js')],{
|
||||
env:{...process.env,PORT:String(port),HOST:'127.0.0.1',BEND_FIELD_DATA_DIR:dataDir},stdio:['ignore','pipe','pipe']
|
||||
});
|
||||
let stderr='';child.stderr.on('data',chunk=>stderr+=chunk);
|
||||
const base=`http://127.0.0.1:${port}`;
|
||||
const sleep=ms=>new Promise(resolve=>setTimeout(resolve,ms));
|
||||
let aliceRealtime=null;
|
||||
async function request(url,options={}){
|
||||
const response=await fetch(base+url,options),body=await response.json();
|
||||
return{response,body};
|
||||
}
|
||||
async function requestText(url){const response=await fetch(base+url),body=await response.text();return{response,body}}
|
||||
function auth(session){return{authorization:`Bearer ${session.playerId}.${session.token}`,'content-type':'application/json'}}
|
||||
function boardMeta(id,x,seed,puzzle){return{id,x,y:0,chunks:[[0,0]],level:1,targetLevel:1,seed,axis:'MIX',sealedSides:[],puzzle,rev:1,revAuthor:'client'}}
|
||||
function solvedState(puzzle){return{paths:puzzle.solution.map(route=>({startGate:route.startGate,endGate:route.endGate,cells:route.cells.map(cell=>[...cell])})),solved:true,expanded:false,rev:2,revAuthor:'client'}}
|
||||
(async()=>{
|
||||
for(let i=0;i<60;i++){try{const {response}=await request('/api/cloud/status');if(response.ok)break}catch(_){}await sleep(50)}
|
||||
const status=await request('/api/cloud/status');assert.equal(status.response.status,200);assert.equal(status.body.sharedWorld,true);
|
||||
const page=await requestText('/');assert.equal(page.response.status,200);assert(page.body.indexOf('app-logic.js?v=47.77')>page.body.indexOf('puzzle-core.js?v=47.77-5')&&page.body.indexOf('app-logic.js?v=47.77')<page.body.indexOf('app.js?v=47.77'));
|
||||
const logicAsset=await requestText('/app-logic.js');assert.equal(logicAsset.response.status,200);assert.match(logicAsset.body,/BendAppLogic/);
|
||||
|
||||
const alice=(await request('/api/cloud/session',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({name:'Alice'})})).body;
|
||||
const bob=(await request('/api/cloud/session',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({name:'Bob'})})).body;
|
||||
for(const session of[alice,bob]){assert.match(session.playerId,/^[a-f0-9]{16,64}$/);assert.match(session.token,/^[a-f0-9]{32,128}$/)}
|
||||
assert.equal(alice.name,'Alice');assert.equal(bob.name,'Bob');
|
||||
|
||||
const starter=starterPuzzle(),b0=boardMeta('B0',0,123456,starter);
|
||||
const bootstrap={baseRevision:0,global:{schema:31,appVersion:'47.70',generatorVersion:5,worldGeneration:'v47-field-reset-20260728-interaction-fix',nextId:1,score:999,cursorStyle:'do-not-share',cloudProfile:{token:'do-not-store'}},metas:[b0],states:[{id:'B0',value:{paths:[],solved:false,rev:1,revAuthor:'alice'}}],deleted:[]};
|
||||
const pushed=await request('/api/cloud/push',{method:'POST',headers:auth(alice),body:JSON.stringify(bootstrap)});assert.equal(pushed.response.status,200);assert.equal(pushed.body.revision,1);assert.deepEqual(pushed.body.clearEvents,[]);
|
||||
|
||||
const bobInitial=await request('/api/cloud/pull?since=0&eventsSince=0',{headers:auth(bob)});assert.equal(bobInitial.response.status,200);assert.equal(bobInitial.body.changed,true);assert.equal(bobInitial.body.fullSnapshot,true);assert.equal(bobInitial.body.page.metas.B0.seed,123456);assert.equal(bobInitial.body.page.states.B0.solved,false);assert(bobInitial.body.page.metas.B0.rev>1_000_000_000_000);assert(bobInitial.body.page.states.B0.rev>1_000_000_000_000);assert.equal(bobInitial.body.page.global.score,undefined);assert.equal(bobInitial.body.page.global.cursorStyle,undefined);assert.equal(bobInitial.body.page.global.cloudProfile,null);assert.equal(bobInitial.body.player.name,'Bob');
|
||||
|
||||
aliceRealtime=await connectRealtime(base,alice);aliceRealtime.send({type:'viewport',minX:-10,minY:-10,maxX:10,maxY:10});await aliceRealtime.waitFor('snapshot');aliceRealtime.send({type:'claim',requestId:'server-smoke-claim',boardId:'B0'});const claimResult=await aliceRealtime.waitFor(message=>message.type==='claim-result'&&message.requestId==='server-smoke-claim');assert.equal(claimResult.ok,true);
|
||||
const clearPayload={baseRevision:1,global:{nextId:1,lastSolveAt:Date.now()},metas:[],states:[{id:'B0',value:solvedState(starter)}],deleted:[]};
|
||||
const cleared=await request('/api/cloud/push',{method:'POST',headers:auth(alice),body:JSON.stringify(clearPayload)});assert.equal(cleared.response.status,200);assert.equal(cleared.body.revision,2);assert.equal(cleared.body.clearEvents.length,1);assert.equal(cleared.body.clearEvents[0].playerName,'Alice');assert.equal(cleared.body.clearEvents[0].id,'B0');assert.equal(cleared.body.clearEvents[0].level,1);
|
||||
|
||||
const bobAfterClear=await request('/api/cloud/pull?since=1&eventsSince=0',{headers:auth(bob)});assert.equal(bobAfterClear.response.status,200);assert.equal(bobAfterClear.body.fullSnapshot,false);assert.equal(bobAfterClear.body.page.states.B0.solved,true);assert.equal(bobAfterClear.body.page.states.B0.solvedBy,'Alice');assert.equal(bobAfterClear.body.page.states.B0.solvedById,alice.playerId);assert(Number.isFinite(bobAfterClear.body.page.states.B0.solvedAt));assert.equal(bobAfterClear.body.clearEvents.length,1);assert.equal(bobAfterClear.body.clearEvents[0].playerName,'Alice');
|
||||
|
||||
const stale=await request('/api/cloud/push',{method:'POST',headers:auth(bob),body:JSON.stringify({...clearPayload,baseRevision:1})});assert.equal(stale.response.status,409);assert.equal(stale.body.revision,2);
|
||||
const repeat=await request('/api/cloud/push',{method:'POST',headers:auth(bob),body:JSON.stringify({...clearPayload,baseRevision:2})});assert.equal(repeat.response.status,200);assert.equal(repeat.body.revision,3);assert.deepEqual(repeat.body.clearEvents,[]);
|
||||
const preserved=await request('/api/cloud/pull?since=0&eventsSince=0',{headers:auth(bob)});assert.equal(preserved.body.page.states.B0.solvedBy,'Alice');
|
||||
|
||||
const renamed=await request('/api/cloud/profile',{method:'POST',headers:auth(bob),body:JSON.stringify({name:'Bob 2'})});assert.equal(renamed.response.status,200);assert.equal(renamed.body.name,'Bob 2');
|
||||
const unchanged=await request('/api/cloud/pull?since=3&eventsSince=2',{headers:auth(bob)});assert.equal(unchanged.body.changed,false);assert.equal(unchanged.body.player.name,'Bob 2');
|
||||
|
||||
const b1=boardMeta('B1',1,987654,starter);
|
||||
const generated=await request('/api/cloud/push',{method:'POST',headers:auth(alice),body:JSON.stringify({baseRevision:3,global:{nextId:2},metas:[b1],states:[{id:'B1',value:{paths:[],solved:false,rev:4,revAuthor:'alice'}}],deleted:[]})});assert.equal(generated.response.status,200);assert.equal(generated.body.revision,4);
|
||||
const bobGenerated=await request('/api/cloud/pull?since=3&eventsSince=2',{headers:auth(bob)});assert.equal(bobGenerated.response.status,200);assert.equal(bobGenerated.body.page.metas.B1.seed,987654);assert.equal(bobGenerated.body.page.states.B1.solved,false);
|
||||
|
||||
const invalid=await request('/api/cloud/push',{method:'POST',headers:auth(alice),body:JSON.stringify({baseRevision:4,global:{},metas:[{...b1,id:'B2',x:2,chunks:[]}],states:[],deleted:[]})});assert.equal(invalid.response.status,400);
|
||||
const overlap=await request('/api/cloud/push',{method:'POST',headers:auth(alice),body:JSON.stringify({baseRevision:4,global:{},metas:[boardMeta('B2',1,222,starter)],states:[],deleted:[]})});assert.equal(overlap.response.status,400);
|
||||
|
||||
|
||||
const aliceRecord=JSON.parse(fs.readFileSync(path.join(dataDir,`${alice.playerId}.json`),'utf8'));assert.equal(aliceRecord.name,'Alice');assert.notEqual(aliceRecord.tokenHash,alice.token);assert.equal(aliceRecord.boardVersions,undefined);assert.equal(aliceRecord.global,undefined);
|
||||
const world=JSON.parse(fs.readFileSync(path.join(dataDir,'shared-world.json'),'utf8'));assert.equal(world.revision,4);assert.equal(world.global.score,undefined);assert.equal(world.global.cursorStyle,undefined);assert.equal(world.boardVersions.B0,3);assert.equal(world.boardVersions.B1,4);assert.equal(world.clearEvents[0].playerName,'Alice');
|
||||
const b0Shard=JSON.parse(fs.readFileSync(path.join(dataDir,'shared-world.boards','B0.3.json'),'utf8'));assert.equal(b0Shard.state.solvedBy,'Alice');assert.equal(b0Shard.state.paths.length,starter.solution.length);
|
||||
console.log('BEND FIELD shared-world phase 2 server smoke test passed');
|
||||
})().catch(error=>{console.error(error);if(stderr)console.error(stderr);process.exitCode=1}).finally(()=>{aliceRealtime?.close();child.kill('SIGTERM');fs.rmSync(dataDir,{recursive:true,force:true})});
|
||||
72
test/shared-world-client-smoke-test.js
Normal file
72
test/shared-world-client-smoke-test.js
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
'use strict';
|
||||
const assert=require('assert/strict');
|
||||
const vm=require('vm');
|
||||
const {functionSource,loadAppLogic}=require('./helpers/app-source');
|
||||
|
||||
const outboxContext={
|
||||
data:{states:{B0:{solved:false},B1:{solved:true}}},cloudApiEnabled:true,
|
||||
cloudJournalMetaIds:new Set(),cloudJournalStateIds:new Set(),cloudJournalDeletedIds:new Set(),cloudOutboxDeleteKeys:new Set(),cloudJournalChangeSeq:0,cloudJournalGlobalChanged:false
|
||||
};
|
||||
vm.createContext(outboxContext);
|
||||
vm.runInContext(`${functionSource('currentCloudPending')}\n${functionSource('noteCloudRow')}\nthis.logic={currentCloudPending,noteCloudRow};`,outboxContext);
|
||||
outboxContext.logic.noteCloudRow('state','B0');
|
||||
assert.equal(outboxContext.cloudJournalStateIds.has('B0'),false,'Unsolved personal path entered the shared journal');
|
||||
assert.equal(outboxContext.cloudOutboxDeleteKeys.has('state:B0'),true,'Stale unsolved shared outbox row was not scheduled for deletion');
|
||||
outboxContext.logic.noteCloudRow('state','B1');
|
||||
assert.equal(outboxContext.cloudJournalStateIds.has('B1'),true,'Solved state was not added to the shared journal');
|
||||
assert.deepEqual([...outboxContext.logic.currentCloudPending().stateIds],['B1']);
|
||||
|
||||
const AppLogic=loadAppLogic(),now=1_800_000_000_000;
|
||||
const leaseContext={
|
||||
cloudAvailable:true,data:{cloudProfile:{playerId:'bbbbbbbbbbbbbbbb'}},currentPlayerId:()=> 'bbbbbbbbbbbbbbbb',trustedNow:()=>now,AppLogic,
|
||||
SHARED_EXPANSION_GRACE_MS:60_000,SHARED_EXPANSION_JITTER_MS:30_000
|
||||
};
|
||||
vm.createContext(leaseContext);
|
||||
vm.runInContext(`${functionSource('sharedExpansionRepairDelay')}\nthis.sharedExpansionRepairDelay=sharedExpansionRepairDelay;`,leaseContext);
|
||||
assert.equal(leaseContext.sharedExpansionRepairDelay({solved:true,solvedById:'bbbbbbbbbbbbbbbb',solvedAt:now}),0,'The solving client cannot expand its own clear');
|
||||
const wait=leaseContext.sharedExpansionRepairDelay({solved:true,solvedById:'aaaaaaaaaaaaaaaa',solvedAt:now});
|
||||
assert(wait>=60_000&&wait<90_000,'A non-solving client can race the solver before the recovery grace period');
|
||||
assert.equal(leaseContext.sharedExpansionRepairDelay({solved:true,solvedById:'aaaaaaaaaaaaaaaa',solvedAt:now-100_000}),0,'A disconnected solver can leave expansion permanently blocked');
|
||||
leaseContext.cloudAvailable=false;
|
||||
assert.equal(leaseContext.sharedExpansionRepairDelay({solved:true,solvedById:'aaaaaaaaaaaaaaaa',solvedAt:now}),0,'Offline expansion was incorrectly lease-gated');
|
||||
|
||||
const localMeta={id:'B0',x:99,y:99,seed:1,chunks:[[0,0]],sealedSides:[],rev:9_999};
|
||||
const remoteMeta={id:'B0',x:0,y:0,seed:2,chunks:[[0,0]],sealedSides:[],rev:2_000};
|
||||
const authoritativeContext={
|
||||
data:{
|
||||
metas:{B0:localMeta},states:{B0:{solved:true,solvedBy:'Local',paths:[{cells:[[0,0]]}],rev:9_999}},
|
||||
nextId:99,solved:1,lastSolveAt:123,specialMechanicsSeen:['warp'],quarantine:{local:true},
|
||||
playerName:'Player',score:777,cursorStyle:'flag-jp'
|
||||
},
|
||||
isPlainObject:value=>value&&typeof value==='object'&&!Array.isArray(value),lastRevision:0,normalizedStateObjects:new Set(),
|
||||
cloudJournalMetaIds:new Set(['B0']),cloudJournalStateIds:new Set(['B0']),cloudJournalDeletedIds:new Set(),cloudOutboxDeleteKeys:new Set(),
|
||||
sameMetaGeometry:(a,b)=>a.x===b.x&&a.y===b.y&&a.seed===b.seed&&JSON.stringify(a.chunks)===JSON.stringify(b.chunks)&&JSON.stringify(a.sealedSides||[])===JSON.stringify(b.sealedSides||[]),
|
||||
destroyBoard:()=>{},destroyStaticBoard:()=>{},rendered:new Map(),staticRendered:new Map(),deepClone:value=>JSON.parse(JSON.stringify(value)),
|
||||
compareRevisionVersions:(a,b)=>(a?.rev||0)-(b?.rev||0),
|
||||
mergeBoardStates:(current,incoming)=>{
|
||||
if(current?.solved&&!incoming?.solved)return JSON.parse(JSON.stringify(incoming));
|
||||
if(current&&!current.solved&&!incoming.solved)return{...JSON.parse(JSON.stringify(incoming)),paths:JSON.parse(JSON.stringify(current.paths||[]))};
|
||||
return JSON.parse(JSON.stringify(incoming));
|
||||
},
|
||||
markStateDirty:id=>authoritativeContext.dirtyStateIds.add(id),dirtyStateIds:new Set(),sanitizeStateForPuzzle:()=>{},
|
||||
mergeGlobalFields:()=>{throw new Error('Authoritative shared global unexpectedly used generic merge')},resolveMergedOverlaps:()=>[],statsDirty:false
|
||||
};
|
||||
vm.createContext(authoritativeContext);
|
||||
vm.runInContext(`${functionSource('clearSharedWorldJournalRow')}\n${functionSource('applyAuthoritativeSharedGlobal')}\n${functionSource('mergeSnapshotIntoData')}\nthis.mergeSnapshotIntoData=mergeSnapshotIntoData;`,authoritativeContext);
|
||||
authoritativeContext.mergeSnapshotIntoData({
|
||||
metas:{B0:remoteMeta},states:{B0:{solved:false,paths:[],rev:2_000}},
|
||||
nextId:2,solved:0,lastSolveAt:0,specialMechanicsSeen:['lock'],quarantine:{shared:true}
|
||||
},{finalize:false,authoritativeWorld:true});
|
||||
assert.equal(authoritativeContext.data.metas.B0.seed,2,'An existing local world row overrode the authoritative shared board');
|
||||
assert.equal(authoritativeContext.data.states.B0.solved,false,'An unconfirmed local clear survived authoritative shared adoption');
|
||||
assert.equal(authoritativeContext.data.nextId,2,'A private local board counter leaked into the shared world');
|
||||
assert.equal(authoritativeContext.data.playerName,'Player');assert.equal(authoritativeContext.data.score,777);assert.equal(authoritativeContext.data.cursorStyle,'flag-jp');
|
||||
assert.equal(authoritativeContext.cloudJournalMetaIds.size,0);assert.equal(authoritativeContext.cloudJournalStateIds.size,0);
|
||||
assert(authoritativeContext.cloudOutboxDeleteKeys.has('meta:B0')&&authoritativeContext.cloudOutboxDeleteKeys.has('state:B0'),'Adopted shared rows did not clear stale local outbox records');
|
||||
|
||||
// Matching unsolved boards keep the player's unfinished line locally while the board definition stays shared.
|
||||
authoritativeContext.data.metas.B0=remoteMeta;
|
||||
authoritativeContext.data.states.B0={solved:false,paths:[{startGate:0,cells:[[0,0],[0,1]]}],rev:3_000};
|
||||
authoritativeContext.mergeSnapshotIntoData({metas:{B0:{...remoteMeta,rev:4_000}},states:{B0:{solved:false,paths:[],rev:4_000}},nextId:2},{finalize:false,authoritativeWorld:true});
|
||||
assert.equal(authoritativeContext.data.states.B0.paths.length,1,'Authoritative shared refresh erased a matching board\'s personal unfinished path');
|
||||
console.log('Shared-world phase 1 client synchronization smoke test passed');
|
||||
47
test/shared-world-complete-smoke-test.js
Normal file
47
test/shared-world-complete-smoke-test.js
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
'use strict';
|
||||
const {spawn}=require('child_process');
|
||||
const fs=require('fs');
|
||||
const os=require('os');
|
||||
const path=require('path');
|
||||
const assert=require('assert/strict');
|
||||
const {root,starterPuzzle,read,functionSource}=require('./helpers/app-source');
|
||||
const {connectRealtime}=require('./helpers/realtime-client');
|
||||
|
||||
const app=read('app.js'),html=read('index.html'),css=read('style.css'),serverSource=read('server.js'),realtimeSource=read('realtime-server.js');
|
||||
assert(html.includes('id="reactionCanvas"')&&html.includes('id="reactionRadial"')&&css.includes('#reactionCanvas')&&css.includes('z-index:2'),'Reaction layer is not behind boards');
|
||||
assert(functionSource('beginReactionGesture').includes('REACTION_LONG_PRESS_MS')&&functionSource('reactionAllowedAt').includes('metaState(boardId).solved'),'Reaction click/long-press eligibility is missing');
|
||||
assert(functionSource('drawReactionLayer').includes('REALTIME_REACTION_DURATION')||app.includes('REALTIME_REACTION_DURATION=4500'),'Reaction animation is not bounded');
|
||||
assert(functionSource('inventoryEntries').includes('onlinePlayerEconomy()')&&functionSource('purchaseStoreItem').includes('buyPersonalStoreItem'),'Player inventory or personal purchasing is not server-backed');
|
||||
assert(serverSource.includes("url.pathname==='/api/player/purchase'")&&serverSource.includes('assertPlayerCanAfford')&&!serverSource.includes("url.pathname==='/api/player/place-field'"),'Player purchase validation is missing or retired field-placement API remains');
|
||||
assert(realtimeSource.includes("message.type === 'reaction'")&&realtimeSource.includes('REACTION_MIN_INTERVAL_MS')&&!realtimeSource.includes('broadcastFieldEffect'),'Realtime reaction throttling is missing or field broadcasts remain');
|
||||
|
||||
const port=24000+Math.floor(Math.random()*8000),dataDir=fs.mkdtempSync(path.join(os.tmpdir(),'bend-field-complete-'));
|
||||
const child=spawn(process.execPath,[path.join(root,'server.js')],{env:{...process.env,PORT:String(port),HOST:'127.0.0.1',BEND_FIELD_DATA_DIR:dataDir},stdio:['ignore','pipe','pipe']});
|
||||
let stderr='',aliceWs=null,bobWs=null;child.stderr.on('data',chunk=>stderr+=chunk);
|
||||
const base=`http://127.0.0.1:${port}`,sleep=ms=>new Promise(resolve=>setTimeout(resolve,ms));
|
||||
async function request(url,options={}){const response=await fetch(base+url,options),body=await response.json();return{response,body}}
|
||||
function auth(session){return{authorization:`Bearer ${session.playerId}.${session.token}`,'content-type':'application/json'}}
|
||||
function boardMeta(id,x,seed,puzzle){return{id,x,y:0,chunks:[[0,0]],level:1,targetLevel:1,seed,axis:'MIX',sealedSides:[],puzzle,rev:1,revAuthor:'client'}}
|
||||
function solvedState(puzzle){return{paths:puzzle.solution.map(route=>({startGate:route.startGate,endGate:route.endGate,cells:route.cells.map(cell=>[...cell])})),solved:true,expanded:false,scoreAwarded:100000,scoreVersion:6,store:{owner:'',pathIndex:0,cellIndex:0,itemIds:['score-lens'],purchases:[],priceVersion:1,priceCoefficient:1},rev:2,revAuthor:'client'}}
|
||||
|
||||
(async()=>{
|
||||
for(let i=0;i<100;i++){try{if((await request('/api/cloud/status')).response.ok)break}catch(_){}await sleep(40)}
|
||||
const status=(await request('/api/cloud/status')).body;assert.equal(status.reactions,true);assert.equal(status.playerEconomy,true);assert.equal(status.sharedItems,false);
|
||||
const alice=(await request('/api/cloud/session',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({name:'Alice'})})).body;
|
||||
const bob=(await request('/api/cloud/session',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({name:'Bob'})})).body;
|
||||
const puzzle=starterPuzzle(),b0=boardMeta('B0',0,15,puzzle);
|
||||
let pushed=await request('/api/cloud/push',{method:'POST',headers:auth(alice),body:JSON.stringify({baseRevision:0,global:{schema:31,appVersion:'47.70',generatorVersion:5,worldGeneration:'v47-field-reset-20260728-interaction-fix',nextId:1},metas:[b0],states:[{id:'B0',value:{paths:[],solved:false,rev:1,revAuthor:'alice'}}],deleted:[]})});assert.equal(pushed.response.status,200);
|
||||
aliceWs=await connectRealtime(base,alice);bobWs=await connectRealtime(base,bob);aliceWs.send({type:'viewport',minX:-8,minY:-8,maxX:8,maxY:8});bobWs.send({type:'viewport',minX:-8,minY:-8,maxX:8,maxY:8});await aliceWs.waitFor('snapshot');await bobWs.waitFor('snapshot');
|
||||
aliceWs.send({type:'reaction',id:'reaction-one',emoji:'🎉',x:.4,y:.6});const reaction=await bobWs.waitFor(message=>message.type==='reaction'&&message.reaction?.id==='reaction-one');assert.equal(reaction.reaction.emoji,'🎉');assert.equal(reaction.reaction.playerName,'Alice');assert(reaction.reaction.expiresAt>reaction.reaction.createdAt);
|
||||
aliceWs.send({type:'claim',requestId:'claim-b0',boardId:'B0'});assert.equal((await aliceWs.waitFor(message=>message.type==='claim-result'&&message.requestId==='claim-b0')).ok,true);
|
||||
pushed=await request('/api/cloud/push',{method:'POST',headers:auth(alice),body:JSON.stringify({baseRevision:1,global:{nextId:1},metas:[],states:[{id:'B0',value:solvedState(puzzle)}],deleted:[]})});assert.equal(pushed.response.status,200);let revision=pushed.body.revision;
|
||||
const alicePath=path.join(dataDir,`${alice.playerId}.json`),aliceRecord=JSON.parse(fs.readFileSync(alicePath,'utf8'));aliceRecord.earnedScore=250000;fs.writeFileSync(alicePath,JSON.stringify(aliceRecord));
|
||||
const purchase=await request('/api/player/purchase',{method:'POST',headers:auth(alice),body:JSON.stringify({boardId:'B0',itemId:'score-lens'})});assert.equal(purchase.response.status,201);assert.equal(purchase.body.player.purchases.length,1);assert.equal(purchase.body.purchase.itemId,'score-lens');
|
||||
const duplicate=await request('/api/player/purchase',{method:'POST',headers:auth(alice),body:JSON.stringify({boardId:'B0',itemId:'score-lens'})});assert.equal(duplicate.response.status,200);assert.equal(duplicate.body.purchase.purchaseId,purchase.body.purchase.purchaseId);
|
||||
const bobState=await request('/api/player/state',{headers:auth(bob)});assert.deepEqual(bobState.body.player.purchases,[]);
|
||||
const retiredPurchase=await request('/api/player/purchase',{method:'POST',headers:auth(alice),body:JSON.stringify({boardId:'B0',itemId:'level-min-10'})});assert.equal(retiredPurchase.response.status,400);
|
||||
const retiredPlacement=await request('/api/player/place-field',{method:'POST',headers:auth(alice),body:JSON.stringify({boardId:'B0',itemId:'level-min-10',x:4.25,y:-2.5})});assert.equal(retiredPlacement.response.status,404);
|
||||
const pull=await request('/api/cloud/pull?since=0&eventsSince=0',{headers:auth(bob)});assert.equal(pull.response.status,200);assert.equal(pull.body.page.global.fieldEffects,undefined);
|
||||
assert(!app.includes('level-min-10')&&!app.includes('level-max-10')&&!app.includes('fieldOverlayCanvas'),'Retired difficulty items remain in the client bundle');
|
||||
console.log('Shared-world reactions and personal item economy smoke test passed');
|
||||
})().catch(error=>{console.error(error);if(stderr)console.error(stderr);process.exitCode=1}).finally(()=>{aliceWs?.close();bobWs?.close();child.kill('SIGTERM');fs.rmSync(dataDir,{recursive:true,force:true})});
|
||||
163
test/source-smoke-test.js
Normal file
163
test/source-smoke-test.js
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
'use strict';
|
||||
const cp=require('child_process');
|
||||
const fs=require('fs');
|
||||
const {root,path,vm,app,html,css,worker,appLogicSource,assert,functionSource,loadBendPuzzle,loadAppLogic,starterPuzzle,read}=require('./helpers/app-source');
|
||||
const serverSource=read('server.js');
|
||||
const packageVersion=JSON.parse(read('package.json')).version,appVersion=packageVersion.split('.').slice(0,2).join('.');
|
||||
for(const file of ['app.js','app-logic.js','puzzle-core.js','puzzle-worker.js','field-persistence.js','field-persistence-worker.js','server.js'])cp.execFileSync(process.execPath,['--check',path.join(root,file)],{stdio:'inherit'});
|
||||
for(const marker of [
|
||||
`APP_VERSION='${appVersion}',SAVE_SCHEMA=31,STORAGE_SCHEMA=30,IDB_LAYOUT_VERSION=8,FIELD_STORAGE_FORMAT=2,GAMEPLAY_DATA_VERSION=3,WORLD_GENERATION='v47-field-reset-20260728-interaction-fix'`,
|
||||
'SPECIAL_CELL_MIN_LEVEL=5,SPECIAL_CELL_DEBUG_ALL_LEVELS=false',
|
||||
'shapeCandidatesForLevel','addSpecialCellPattern','addWarpSpecial','addLockSpecial','addCrossingSpecial',
|
||||
'specialPathValid','crossingsSatisfied','activateCrossing','pathRenderSegments','pathStrokePieces','pathProgressAtCell','specialCellInfoMap','normalizeSpecialCells'
|
||||
])assert(app.includes(marker),`Missing v47 marker: ${marker}`);
|
||||
assert(app.includes('const AppLogic=globalThis.BendAppLogic')&&app.includes('AppLogic.shapeCandidatesForLevel')&&app.includes('AppLogic.stateForStorage')&&app.includes('AppLogic.collectConnectedLineComponent'),'Application does not consume the shared pure-logic module');
|
||||
assert(html.includes(`style.css?v=${appVersion}`)&&html.includes(`puzzle-core.js?v=${appVersion}-5`)&&html.includes(`app-logic.js?v=${appVersion}`)&&html.includes(`field-persistence.js?v=${appVersion}`)&&html.includes(`app.js?v=${appVersion}`),'Web assets do not match package/app/generator version');
|
||||
assert(worker.includes(`puzzle-core.js?v=${appVersion}-5`),'Worker imports an old puzzle-core asset');
|
||||
assert(functionSource('createPuzzleWorker').includes('message.error?job.reject')&&!functionSource('createPuzzleWorker').includes('message.error?generatePuzzleOnMainThread'),'Algorithmic worker failures are retried redundantly on the main thread');
|
||||
assert(html.includes(`<small>v${appVersion}</small>`)&&html.includes(`v${appVersion}</title>`),'Visible version does not match package/app version');
|
||||
assert(html.includes('id="viewport" tabindex="-1"')&&['modal','storeModal','inventoryModal','timeAttackModal'].every(id=>html.includes(`id="${id}" aria-hidden="true" inert`)),'Hidden dialogs are not inert or the viewport is not programmatically focusable');
|
||||
assert(functionSource('closeDialogRoot').includes('focusOutsideDialog(root,preferredFocus)')&&functionSource('closeDialogRoot').indexOf('focusOutsideDialog')<functionSource('closeDialogRoot').indexOf("setAttribute('aria-hidden','true')"),'Dialog hiding occurs before focus leaves the dialog');
|
||||
assert(functionSource('openDialogRoot').includes('setDialogInert(root,false)')&&functionSource('closeDialogRoot').includes('setDialogInert(root,true)'),'Dialog inert state is not synchronized with visibility');
|
||||
assert(!app.includes('labyrinth-seed')&&!app.includes('giantCompactShapes')&&!app.includes("anomaly==='giant'"),'Retired special multi-section boards remain active');
|
||||
assert(!app.includes('anomalyAt')&&!app.includes('anomalyScoreMultiplier')&&!app.includes('renderAnomalyOverlays')&&!css.includes('.anomaly'),'Retired anomaly code remains');
|
||||
assert(!serverSource.includes('migrateLegacyPlayer')&&!serverSource.includes('value.metas')&&!serverSource.includes('value.states'),'Cloud server still reads or migrates the retired monolithic player format');
|
||||
assert(html.includes('id="clearFeed"')&&css.includes('.clear-feed-item'),'Shared clear feed is missing above the minimap');
|
||||
assert(serverSource.includes("const WORLD_FILE = path.join(DATA_DIR, 'shared-world.json')")&&serverSource.includes("url.pathname==='/api/cloud/profile'")&&serverSource.includes('withWorldQueue')&&serverSource.includes('clearEvents'),'Shared-world storage, profile naming, serialization, or clear feed API is missing');
|
||||
assert(functionSource('checkSolvedAndExpand').indexOf('pullCloudWorld(true)')<functionSource('checkSolvedAndExpand').indexOf('st.solved=true')&&functionSource('checkSolvedAndExpand').indexOf('pushCloudPending()')<functionSource('checkSolvedAndExpand').indexOf('expandMeta('),'Clear publication is not server-validated before shared expansion');
|
||||
assert(functionSource('mergeSnapshotIntoData').includes('authoritativeWorld')&&functionSource('pullCloudWorld').includes('initial&&previousRevision===0&&targetRevision>0')&&functionSource('clearSharedWorldJournalRow').includes('cloudOutboxDeleteKeys'),'Initial shared-world adoption does not replace stale local world rows or clean their outbox entries');
|
||||
assert(functionSource('noteCloudRow').includes("kind==='state'&&data?.states?.[id]?.solved!==true")&&functionSource('currentCloudPending').includes("solved===true"),'Unfinished personal paths can still enter the shared durable outbox');
|
||||
assert(functionSource('sharedExpansionRepairDelay').includes('SHARED_EXPANSION_GRACE_MS')&&functionSource('repairExpansions').includes('sharedExpansionRepairDelay(st)<=0'),'Non-solving clients can race the solver while publishing newly generated boards');
|
||||
assert(app.includes('solvedById')&&appLogicSource.includes('solvedById')&&serverSource.includes('state.solvedById=player.playerId'),'Shared solver identity is not persisted independently of the display name');
|
||||
assert(serverSource.includes('rowRevision=Math.max')&&serverSource.includes('serverTime()*1000'),'Server row revisions are not comparable with client revisions');
|
||||
assert(!app.includes('nearestUnselectedEndpointAtClient'),'Unselected endpoint clicks are still intercepted before dragging');
|
||||
assert(functionSource('extendPointerTo').includes('renderDragFrame(b)')&&functionSource('renderDragFrame').includes('liveEndpointPoint')&&!functionSource('renderDragFrame').includes('pathValid')&&!app.includes('path-live-tail'),'Pointer-following line does not use the lightweight live-endpoint renderer');
|
||||
assert(!app.includes('pathChangeMotion')&&!app.includes('renderDragTipMotion')&&!css.includes('.path-growth')&&!css.includes('.path-retraction'),'Line growth or retraction animation remains');
|
||||
assert(css.includes('.turn-count{fill:#fff;font-family:var(--dot-font)'),'Turn counts do not explicitly use the dot-styled game font');
|
||||
assert(functionSource('addWarpSpecial').includes('invalidateSpecialCellCaches'),'Warp insertion does not invalidate turn-analysis caches');
|
||||
assert(functionSource('specialCellInfoMap').includes('description')&&functionSource('specialInfoFromEvent').includes('b.specialInfo')&&html.includes('id="specialTooltip"'),'Special-cell hover descriptions are missing');
|
||||
for(const selector of ['.special-cross','.special-warp','.special-key','.special-door'])assert(css.includes(selector),`Missing special-cell style ${selector}`);
|
||||
|
||||
assert(functionSource('makeSpecialMarker').includes("class:'key-ring'")&&!functionSource('makeSpecialMarker').includes(String.raw`textContent='\u9375'`),'Key special cell still uses a kanji glyph');
|
||||
assert(css.includes('.board-card.solved .special-cell-layer'),'Solved boards do not hide special cells');
|
||||
assert(functionSource('selectBoard').includes('setActiveBoard(b.id)')&&functionSource('setActiveBoard').includes('previous.drawing?.pointerId==null'),'Board selection does not preserve an active pointer draw while reconciling inactive keyboard state');
|
||||
assert(!css.includes('.board-card:not(.active)'),'Unselected-board styling remains active');
|
||||
assert(css.includes('.board-card.hud-current:not(.solved) .board-label')&&functionSource('boardPlayHudVisible').includes('hudBoardId===b?.id')&&functionSource('renderBoardNow').includes("card.classList.toggle('hud-current',hudVisible)"),'Board HUD is not limited to an actively played board');
|
||||
assert(functionSource('gateFromCell').includes('maxPixels')&&functionSource('extendPointerTo').includes('active.startGate,20'),'Opposite gate selection is not distance-limited');
|
||||
assert(functionSource('renderBoardNow').includes('pathStrokePieces(segments,startColor,endColor)')&&functionSource('pathColorAtCell').includes('pathProgressAtCell'),'Line colors are not blended along cumulative route length');
|
||||
assert(functionSource('updateSelectedProgress').includes('b.meta.level')&&!functionSource('updateSelectedProgress').includes('filled'),'Top HUD includes information other than level');
|
||||
assert(functionSource('renderBoardNow').includes('label.replaceChildren')&&functionSource('makeBoard').includes('label.append(boardActions)'),'Board HUD does not contain the level and attached actions');
|
||||
assert(html.includes('id="noiseCanvas" width="80" height="64"')&&functionSource('paintNoiseBackground').includes("perfCount('noiseFrames')")&&!css.includes('starTwinkle'),'Low-resolution noise background is missing or the retired starfield remains');
|
||||
assert(functionSource('unresolvedExpansionCandidates').includes('gateFrontierCandidates(meta)')&&!functionSource('unresolvedExpansionCandidates').includes('frontierCandidates(meta)'),'Normal expansion still creates non-gate frontier boards');
|
||||
assert(functionSource('syncBoundaryConnections').includes('boundaryColorSource(meta,gi,hit)'),'Connected gate colors are not canonicalized');
|
||||
assert(!app.includes('level-min-10')&&!app.includes('level-max-10')&&!app.includes('fieldEffects')&&!app.includes('fieldOverlayCanvas')&&!serverSource.includes('/api/player/place-field'),'Difficulty adjustment items or their field implementation remain active');
|
||||
assert(functionSource('makeBoard').includes('cellShape')&&functionSource('makeBoard').includes('boardCellsPath')&&!functionSource('makeBoard').includes('cellHits'),'Detailed boards do not use compound SVG paths or still allocate per-cell hit nodes');
|
||||
assert(functionSource('desiredInteractiveBoardIds').includes('for(const id of ids)')&&!app.includes('INTERACTIVE_DETAIL_CELL_BUDGET'),'Visible puzzles are not all selected for detailed rendering');
|
||||
assert(functionSource('shouldTeleportToUnsolvedBoard').includes('return false')&&!app.includes('function promoteStaticBoard(')&&!functionSource('bindBoard').includes('centerMeta('),'Board input can still promote a summary or teleport the camera');
|
||||
assert(html.includes('id="fpsCounter"')&&functionSource('refreshFpsCounter').includes('FPS ${fpsLastValue}')&&app.includes('AUXILIARY_FPS=30')&&app.includes('DRAG_TARGET_FPS=60'),'Lightweight FPS display or split interaction budgets are missing');
|
||||
assert(css.includes('.gem-particle{position:fixed')&&css.includes('width:28px;height:28px')&&functionSource('completionEffect').includes('1800'),'Completion gems are not enlarged or retained long enough');
|
||||
assert(css.includes('#customEmojiCursor{')&&css.includes('overflow:visible')&&css.includes('#customEmojiCursor.flag-cursor{width:32px;height:32px;overflow:hidden'),'Non-flag custom cursors are still clipped or flag clipping is no longer isolated');
|
||||
|
||||
assert(functionSource('extendOne').includes('warpedDuringExtend=true')&&!functionSource('extendOne').includes('safeRelease(b.svg,pointerId)'),'Warp traversal still releases pointer capture');
|
||||
assert(functionSource('extendPointerTo').includes('pointerOffset'),'Warp continuation does not remap the pointer to the exit');
|
||||
assert(!app.includes('hysteresisDragPoint')&&!app.includes('DRAG_AXIS_LOCK_DISTANCE')&&!app.includes('POINTER_SNAP_RELEASE'),'Retired drawing hysteresis remains active');
|
||||
assert(functionSource('scheduleBoardDragFrame').includes('requestAnimationFrame')&&functionSource('queueBoardPointerMove').includes('scheduleBoardDragFrame')&&functionSource('bindBoard').includes('queueBoardPointerMove')&&functionSource('processBoardDragFrame').includes('const move=b.pendingPointerMove'),'Pointer movement is not frame-batched with ordered samples');
|
||||
assert(functionSource('queueCameraInteraction').includes('requestAnimationFrame')&&functionSource('movePan').includes('queueCameraInteraction'),'Camera panning is not frame-coalesced');
|
||||
assert(functionSource('leftFieldPanAllowed').includes("classList?.contains('solved')")&&functionSource('beginPan').includes('e.button!==2&&!leftFieldPanAllowed(e)'),'Left-drag field panning is not isolated to solved or undiscovered space');
|
||||
assert(functionSource('processBoardDragFrame').includes('edgePanVelocity')&&functionSource('processBoardDragFrame').includes('applyCamera(true)'),'Drag edge auto-pan is missing');
|
||||
assert(!functionSource('makeBoard').includes('darkness')&&!css.includes('.darkness'),'Retired darkness rendering remains');
|
||||
assert(functionSource('positionBoardLabel').includes('hudPlacementCandidates')&&functionSource('positionBoardLabel').includes("placement.side==='S'"),'HUD does not move to a free edge');
|
||||
assert(functionSource('positionBoardLabel').includes("b.label.style.width=innerWidth+'px'")&&functionSource('positionBoardLabel').includes("b.label.style.top=(PAD-22)+'px'"),'Top/bottom HUD does not span the board edge or clear upper gates');
|
||||
assert(functionSource('gateHitBox').includes("side==='N'")&&functionSource('makeBoard').includes('...gateHitBox(gp,g.side)'),'Gate hit areas are not constrained to the owning board');
|
||||
assert(!app.includes('sharedGateVisible'),'No-op shared-gate visibility wrapper remains');
|
||||
assert(functionSource('isSolved').includes('crossingsSatisfied(st,p)'),'Crossing is not a prerequisite for normal board completion');
|
||||
assert(functionSource('crossingsSatisfied').includes('crossingStateAtCell')&&functionSource('activateCrossing').includes('path.cells.push(cell)'),'Crossing is not derived from the live overlapping line state');
|
||||
assert(functionSource('extendOne').includes('lockForDoor')&&functionSource('extendOne').includes('pathHasLockKey'),'Door traversal does not require the same line to touch the key');
|
||||
assert(functionSource('extendOne').includes('warpPairForCell')&&functionSource('extendOne').includes('path.cells.push(cell,[...warpExit])'),'Warp traversal does not move to its paired cell');
|
||||
assert(functionSource('resetSelectedBoard').includes('specialProgress={crossings:[]}'),'Reset does not clear crossing progress');
|
||||
assert(functionSource('resetSelectedBoard').includes('renderBoardNow(b)')&&!functionSource('resetSelectedBoard').includes('syncBoundaryConnections'),'Reset is not immediate or still recreates inherited routes');
|
||||
assert(!app.toLowerCase().includes('undolastreset')&&!html.toLowerCase().includes('undo'),'Visible reset undo remains active');
|
||||
|
||||
assert(app.includes('STORE_CHANCE=1/30'),'Store appearance rate is not 1/30');
|
||||
assert(app.includes('MINIMAP_VIEW_CHUNKS_X=42'),'Minimap does not use the wider scale');
|
||||
assert(app.includes('SOUND_GAIN_MULTIPLIER=3.6')&&functionSource('soundTone').includes('Math.min(.28'),'Sound effects were not amplified');
|
||||
assert(app.includes('UNIQUE_SOLUTION_MIN_LEVEL=6')&&functionSource('placeChildAtFrontierAttempt').includes('level>=UNIQUE_SOLUTION_MIN_LEVEL'),'Unique-solution selection does not begin at level 6');
|
||||
assert(functionSource('shapeCandidatesForArea').includes('nearbyShapeFamilyCounts')&&appLogicSource.includes('generatedShapeFamilyKey')&&appLogicSource.includes('balancedShapeCandidates'),'Area-local board shape balancing is missing');
|
||||
assert(!html.includes('矢印キー:線を伸ばす')&&css.includes('.control-chips span{padding:9px 12px')&&css.includes('font-size:13px'),'Help controls are too small or still list arrow keys');
|
||||
assert(css.includes('.path.invalid{stroke:#cfa2a7')&&css.includes('.unfilled-warning-cells{fill:#3b3430')&&!css.includes('.num.turn-warning{fill:#ffbd69;animation'),'Rule warnings remain overly aggressive');
|
||||
assert(functionSource('addCrossingSpecial').includes('neighbors.some(candidate=>!valid.has')&&functionSource('addCrossingSpecial').includes('gateCells.has(key)'),'Crossing cells can still appear on a board edge or gate cell');
|
||||
assert(functionSource('updateZoomPresentation').includes('world-overview')&&functionSource('drawWorldOverview').includes('overviewCanvas')&&html.includes('id="overviewCanvas"')&&css.includes('#viewport.canvas-overview #world'),'Canvas overview mode is missing');
|
||||
assert(!functionSource('drawWorldOverview').includes('drawWorldOverviewPaths')&&functionSource('rebuildWorldOverviewCache').includes('drawMapLongLines'),'Far zoom does not use the minimap long-line renderer');
|
||||
assert(functionSource('rebuildWorldOverviewCache').includes('drawMapBoardCells')&&functionSource('rebuildWorldOverviewCache').includes('drawMapLongLines')&&!app.includes('function drawWorldOverviewShops('),'Zoomed-out rendering does not share the minimap renderer');
|
||||
assert(functionSource('makeStaticBoard').includes('renderedConnectedLineWidth(meta,index)')&&!functionSource('makeStaticBoard').includes('state.solved')&&css.includes('.static-summary-path{'),'Unsolved nearby boards do not preserve route thickness');
|
||||
assert(functionSource('updateTimeAttackUi').includes("classList.toggle('starting'")&&css.includes('@keyframes timeAttackStartEmphasis'),'Time-attack start clock emphasis is missing');
|
||||
const yellowFaces=app.match(/const YELLOW_FACE_CURSOR_SOURCE=`([\s\S]*?)`;/)?.[1]?.split('\n')||[];
|
||||
assert(yellowFaces.length===101&&yellowFaces.some(row=>row.startsWith('1FAE9|'))&&yellowFaces.some(row=>row.startsWith('1FAEA|')),'Complete Unicode Emoji 17.0 yellow-face cursor catalog is missing');
|
||||
assert(app.includes('MAX_FACE_CURSOR_PRICE=50000')&&app.includes('cost=MIN_CURSOR_PRICE+((index*61+37)%100)*MIN_CURSOR_PRICE')&&functionSource('storeItemPrice').includes('Math.min(MAX_FACE_CURSOR_PRICE,adjusted)'),'Yellow-face cursor prices are not deterministic random values spanning 500-50000 gems');
|
||||
const flagCodes=app.match(/const FLAG_REGION_CODES=`([^`]+)`\.split\(' '\)/)?.[1]?.split(' ')||[];
|
||||
assert(flagCodes.length===259&&new Set(flagCodes).size===259&&app.includes("['gbeng','England'],['gbsct','Scotland'],['gbwls','Wales']"),'Complete Unicode Emoji 17.0 flag cursor catalog is missing');
|
||||
const oecdCodes=app.match(/const OECD_FLAG_CODES=new Set\('([^']+)'\.split\(' '\)\)/)?.[1]?.split(' ')||[];
|
||||
assert(oecdCodes.length===38&&new Set(oecdCodes).size===38&&app.includes('OECD_FLAG_CURSOR_BASE_PRICE=20000')&&app.includes('FLAG_CURSOR_BASE_PRICE=10000'),'Flag cursor base prices or the 38-country OECD tier are missing');
|
||||
const flagAssetDir=path.join(root,'assets','flags'),flagAssets=fs.readdirSync(flagAssetDir).filter(name=>name.endsWith('.svg'));
|
||||
assert(flagAssets.length===262&&fs.existsSync(path.join(flagAssetDir,'LICENSE-TWEMOJI.txt')),'Bundled cross-platform flag SVG catalog or attribution is incomplete');
|
||||
assert(functionSource('syncCursorAppearance').includes("image.src=selected.flagAsset")&&functionSource('setItemIcon').includes("image.src=item.flagAsset")&&functionSource('syncCursorAppearance').includes('nativeSupported')&&css.includes('cursor:none!important')&&css.includes('#customEmojiCursor.flag-cursor{width:32px;height:32px;overflow:hidden')&&css.includes('border-radius:50%'),'Native/DOM SVG-backed circular flag cursor rendering is missing');
|
||||
assert(functionSource('buildDragCache').includes('endpoint-cursor-image')&&functionSource('buildDragCache').includes('DRAG_FLAG_CLIP_RADIUS')&&functionSource('buildDragCache').includes('x:-DRAG_FLAG_CURSOR_SIZE/2')&&functionSource('buildDragCache').includes("class:'drag-tip-group'")&&functionSource('updateDragCursorDesign').includes('activeCustomCursorItem')&&functionSource('renderDragFrame').includes('cache.tipGroup.style.transform')&&functionSource('bindBoard').includes('queueMicrotask(()=>updateCustomCursorFromPointer(e))')&&!css.includes('body.is-drawing #customEmojiCursor{display:none!important}'),'Grabbed cursor sizing, centering, clipping, or smooth cursor continuity is missing');
|
||||
assert(css.includes('@font-face{font-family:"DotGothic16Local"')&&css.includes('--emoji-font:')&&css.includes('body,button,input,select,textarea{font-family:var(--dot-font)}')&&!css.includes(':root{--dot-font:"DotGothic16"')&&html.includes('id="customEmojiCursor"'),'Bundled Japanese dot font is overridden or emoji-specific isolation is missing');
|
||||
assert(!html.includes('所持ジェム')&&!functionSource('completionEffect').includes('ジェム')&&!functionSource('updateScoreLensBadge').includes('予想ジェム'),'Standalone gem terminology remains in the reward UI');
|
||||
assert(functionSource('renderStorePanel').includes('storeInventoryItems(meta,store)')&&functionSource('purchaseStoreItem').includes('storeInventoryItems(meta,store).some'),'Store UI or purchase validation bypasses its seeded thirteen-item inventory');
|
||||
assert(functionSource('seededStoreItemIds').includes('.slice(0,12)')&&functionSource('seededStoreItemIds').includes('.slice(0,1)')&&functionSource('maybeOpenStore').includes('itemIds:seededStoreItemIds(meta.seed)'),'Stores do not persist twelve seeded cursors and one seeded non-cursor item');
|
||||
assert(functionSource('renderStorePanel').includes("{title:'アイテム'")&&functionSource('renderStorePanel').includes("{title:'カーソル'")&&functionSource('renderStorePanel').includes('if(category.cursor)card.append(icon,buy)')&&css.includes('.store-cursor-list{grid-template-columns:repeat(6'),'Shop is not split into item and horizontal twelve-cursor sections');
|
||||
assert(functionSource('renderInventoryPanel').includes("'inventory-cursor-grid'")&&functionSource('renderInventoryPanel').includes("option.classList.toggle('selected'")&&functionSource('useInventoryItemLoaded').includes("previousCursor===item.cursorStyle?'default':item.cursorStyle"),'Persistent click-to-toggle cursor inventory is missing');
|
||||
assert(functionSource('makeStaticBoard').includes("openStoreMeta(meta)")&&!functionSource('beginPan').includes('overviewShopAtClient'),'Distant overview still exposes a shop-only hit target instead of matching the minimap');
|
||||
assert(functionSource('discardUnmovedCreatedPath').includes('path.cells.length!==1')&&functionSource('bindBoard').includes('discardUnmovedCreatedPath(b)'),'Cancelled pickup creation can leave an orphan handle');
|
||||
assert(functionSource('renderDragFrame').includes('refreshDragNumberColors(b)'),'Number colors do not update in the live pickup renderer');
|
||||
assert(functionSource('detachPathFromStartGate').includes('path.detachedStart=true')&&functionSource('renderBoardNow').includes("'data-endpoint-side':'start'")&&!app.includes('whitePickupEnd')&&!functionSource('finalizeAtGate').includes("'#fff'"),'Two-ended colored pickup support is incomplete');
|
||||
assert(!functionSource('openTipMergePlan').includes('a.detachedStart||o.detachedStart')&&functionSource('openTipMergePlan').includes('tipDistance!==0')&&!app.includes('function gateConnectionSteps'),'Same-cell pickup joining or exact-cell gate snapping is incomplete');
|
||||
assert(functionSource('updateScoreLensBadge').includes('projectedScoreForBoard')&&functionSource('scoreLensVisibleForMeta').includes('SCORE_LENS_ZOOM_THRESHOLD')&&functionSource('useInventoryItemLoaded').includes('data.scoreLensEnabled=!previous')&&/id:'score-lens'[^\n]+scoreLens:true[^\n]+icon:/.test(app)&&!app.includes('SCORE_LENS_RADIUS')&&css.includes('.score-lens-badge'),'Persistent ON/OFF score lens display is missing or still asks for a position');
|
||||
assert(functionSource('renderStorePanel').includes('formatScore(price)')&&!functionSource('renderStorePanel').includes('price-data.score')&&!functionSource('purchaseStoreItem').includes('price-data.score'),'Store buttons do not always show the actual item price');
|
||||
assert(functionSource('zoomAt').includes('MIN_CAMERA_SCALE'),'Camera cannot zoom out to overview scale');
|
||||
assert(functionSource('addObstaclePattern').includes('puzzle.difficulty=sourcePuzzle.difficulty'),'Obstacle generation changes the displayed level and section constraint');
|
||||
assert(functionSource('addObstaclePattern').includes('largePuzzleBoost')&&functionSource('obstacleCellLimit').includes('Math.floor(total*.2)')&&functionSource('addCrossingSpecial').includes('obstacleCellLimit(p)'),'Large-puzzle obstacle scaling or the strict 20% cap is missing');
|
||||
assert(functionSource('scoreFromThickness').includes('hardMultiplier')&&functionSource('scoreFromThickness').includes('largeMultiplier'),'Hard and large puzzle score scaling is missing');
|
||||
const storeDescriptions=[...app.slice(app.indexOf('const STORE_ITEM_BASE='),app.indexOf('const YELLOW_FACE_CURSOR_SOURCE=')).matchAll(/description:'([^']*)'/g)].map(match=>match[1].replace(/\\u3002/g,'。'));
|
||||
assert(storeDescriptions.length===1&&storeDescriptions.every(description=>(description.match(/。/g)||[]).length===1),'Current non-cursor store items are missing or their descriptions are not exactly one sentence');
|
||||
assert([...appLogicSource].every(ch=>ch.charCodeAt(0)<128),'app-logic.js contains non-ASCII source');
|
||||
|
||||
const BendPuzzle=loadBendPuzzle(),AppLogic=loadAppLogic();
|
||||
assert(BendPuzzle?.GENERATOR_VERSION===5,'Generator version mismatch');
|
||||
const starter=starterPuzzle();assert(BendPuzzle.solverDifficulty(starter)===1,'Bundled origin puzzle is not level 1');
|
||||
const generatedA=BendPuzzle.generatePuzzle([[0,0]],123456,2,0,0),generatedB=BendPuzzle.generatePuzzle([[0,0]],123456,2,0,0);
|
||||
assert(JSON.stringify(generatedA)===JSON.stringify(generatedB),'Puzzle generation is not deterministic');
|
||||
const multi=BendPuzzle.generatePuzzle([[0,0],[1,0],[0,1]],987654,6,12,-9);
|
||||
assert(multi.valid.length===75,'Multi-section normal puzzle generation failed');
|
||||
assert(multi.difficulty>5&&multi.n.filter(clue=>clue[2]>=4).length>=Math.ceil(multi.n.length*.25),'A demanding high-level puzzle was incorrectly collapsed to level 5');
|
||||
assert(multi.complexity.rawRating===BendPuzzle.solutionComplexity(multi).rating&&BendPuzzle.difficultyFitsRegion(multi.difficulty,6),'Raw complexity is not retained or regional difficulty classification escaped its band');
|
||||
const highCluePuzzle=BendPuzzle.generatePuzzle([[2,0],[0,1],[1,1],[2,1],[0,2],[1,2],[0,3],[1,3]],7496588,9,27,-1);
|
||||
assert(highCluePuzzle.difficulty>5&&highCluePuzzle.n.filter(clue=>clue[2]>=4).length>=Math.ceil(highCluePuzzle.n.length*.25),'A valid level 6-10 puzzle lacks meaningful bend clues');
|
||||
assert(multi.solution.some(path=>path.cells.some((cell,index)=>index>0&&(Math.floor(cell[0]/5)!==Math.floor(path.cells[index-1][0]/5)||Math.floor(cell[1]/5)!==Math.floor(path.cells[index-1][1]/5)))),'Normal multi-section puzzle does not connect sections');
|
||||
|
||||
const shapesBySize=new Map();for(const shape of BendPuzzle.SHAPES){const size=shape.length;if(!shapesBySize.has(size))shapesBySize.set(size,[]);shapesBySize.get(size).push(shape)}
|
||||
const shapeDeps={shapesBySize,hash32:BendPuzzle.hash32,rngFrom:BendPuzzle.rngFrom,shuffle:BendPuzzle.shuffle};
|
||||
const familyA=[[0,0],[1,0],[2,0]],familyARotated=[[0,0],[0,1],[0,2]],familyB=[[0,0],[1,0],[0,1]];
|
||||
assert(AppLogic.generatedShapeFamilyKey(familyA)===AppLogic.generatedShapeFamilyKey(familyARotated)&&AppLogic.generatedShapeFamilyKey(familyA)!==AppLogic.generatedShapeFamilyKey(familyB),'Shape-family normalization does not combine rotations/reflections correctly');
|
||||
const balancedFamilies=AppLogic.balancedShapeCandidates([familyA,familyARotated,familyB],12345,{hash32:BendPuzzle.hash32,rngFrom:BendPuzzle.rngFrom,shuffle:BendPuzzle.shuffle}).slice(0,2).map(AppLogic.generatedShapeFamilyKey);
|
||||
assert(new Set(balancedFamilies).size===2,'Shape balancing still exhausts one orientation-rich family before another family');
|
||||
for(let level=1;level<=10;level++){
|
||||
const range=AppLogic.sectionCountRange(level);assert(range.max===level&&range.min===Math.max(1,level-3),`Level ${level} section range is invalid`);
|
||||
const candidates=AppLogic.shapeCandidatesForLevel(0x470000+level,level,8,shapeDeps);assert(candidates.length>0,`Level ${level} has no section shapes`);
|
||||
for(const shape of candidates){assert(shape.length>=range.min&&shape.length<=range.max,`Level ${level} generated ${shape.length} sections outside ${range.min}-${range.max}`);const set=new Set(shape.map(([x,y])=>`${x},${y}`));let reached=new Set([`${shape[0][0]},${shape[0][1]}`]),changed=true;while(changed){changed=false;for(const[x,y]of shape)if(!reached.has(`${x},${y}`)&&[[1,0],[-1,0],[0,1],[0,-1]].some(([dx,dy])=>reached.has(`${x+dx},${y+dy}`))){reached.add(`${x},${y}`);changed=true}}assert(reached.size===set.size,'Generated section shape is disconnected')}
|
||||
}
|
||||
|
||||
const normalizeContext={AppLogic,LINE_COLORS:Array(10).fill('#000'),isPlainObject:value=>!!value&&typeof value==='object'&&!Array.isArray(value),ckey:(r,c)=>`${r},${c}`,sameCell:(a,b)=>a[0]===b[0]&&a[1]===b[1],manhattan:(a,b)=>Math.abs(a[0]-b[0])+Math.abs(a[1]-b[1]),solverDifficulty:BendPuzzle.solverDifficulty,deepClone:value=>JSON.parse(JSON.stringify(value))};
|
||||
vm.createContext(normalizeContext);
|
||||
vm.runInContext([functionSource('normalizePath'),functionSource('normalizeSpecialCells'),functionSource('repairWarpNumberClues'),functionSource('normalizeStoredPuzzle'),functionSource('puzzleForStorage'),'this.logic={normalizeStoredPuzzle,puzzleForStorage}'].join('\n'),normalizeContext);
|
||||
const normalized=normalizeContext.logic.normalizeStoredPuzzle(starter,[[0,0]],1);assert(normalized,'Starter fails stored-puzzle validation');
|
||||
const stored=normalizeContext.logic.puzzleForStorage(normalized);assert(stored.solution.length===starter.solution.length,'Stored puzzle round-trip lost its solution');
|
||||
|
||||
assert(functionSource('gateCandidateAtPoint').includes('gateCandidatesInCell')&&functionSource('gateStartCandidate').includes('gateCandidateAtPoint')&&functionSource('bindBoard').includes('gateStartCandidate(b,point,hintCell,directGate)'),'Gate and gate-cell input do not share one selector');
|
||||
assert(functionSource('placeChildAtFrontierAttempt').includes('puzzleSupportsConnectionRequirements')&&functionSource('placeChildAtFrontierAttempt').includes('fallbackShape=[[0,0]]')&&functionSource('placeChildAtFrontier').includes('frontierGeometryStillViable')&&functionSource('expandMetaNow').includes('missingGateConnections(meta)'),'Expansion lacks validated safe fallback or actual connection verification');
|
||||
assert(functionSource('placeChildAtFrontierAttempt').includes('fixedPortProfilesForRequirements')&&functionSource('placeChildAtFrontierAttempt').includes('portSeed')&&functionSource('placeChildAtFrontierAttempt').includes('specialSeed')&&functionSource('placeChildAtFrontierAttempt').includes('generatedPuzzleIssue'),'Failed boards are not fully regenerated with provisional gates and special cells');
|
||||
assert(functionSource('closedVoidRepairCandidates').includes('closedVoidRepair:true')&&functionSource('repairExpansions').includes('closedVoidRepairCandidates().filter')&&functionSource('repairExpansions').includes('.slice(0,2)')&&functionSource('pendingExpansionCount').includes('closedVoidRepairCandidates().length'),'Saved fields do not detect and repair enclosed missing puzzle squares');
|
||||
assert(functionSource('generatePuzzleAsync').includes('generationOptions')&&worker.includes('generationOptions || null'),'Generation options are not passed through the worker');
|
||||
assert(functionSource('reopenMissingGateExpansions').includes('st.expanded=false')&&functionSource('reopenMissingGateExpansions').includes('missingGateConnections(meta)'),'Persisted false-positive expansion states are not reopened safely');
|
||||
console.log(`BEND FIELD v${appVersion} source and shared-logic smoke test passed`);
|
||||
88
test/special-cell-smoke-test.js
Normal file
88
test/special-cell-smoke-test.js
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
'use strict';
|
||||
const {vm,assert,functionSource,loadBendPuzzle,loadAppLogic}=require('./helpers/app-source');
|
||||
const BendPuzzle=loadBendPuzzle(),AppLogic=loadAppLogic();
|
||||
const context={
|
||||
AppLogic,
|
||||
SPECIAL_CELL_MIN_LEVEL:5,SPECIAL_CELL_DEBUG_ALL_LEVELS:false,data:{metas:{},specialMechanicsSeen:[]},
|
||||
hash32:BendPuzzle.hash32,rngFrom:BendPuzzle.rngFrom,
|
||||
deepClone:value=>JSON.parse(JSON.stringify(value)),key2:(x,y)=>`${x},${y}`,ckey:(r,c)=>`${r},${c}`,
|
||||
SIDE_D:{N:[-1,0],S:[1,0],W:[0,-1],E:[0,1]},sameCell:(a,b)=>a[0]===b[0]&&a[1]===b[1],manhattan:(a,b)=>Math.abs(a[0]-b[0])+Math.abs(a[1]-b[1])
|
||||
};
|
||||
vm.createContext(context);
|
||||
vm.runInContext([
|
||||
'specialCellSet','invalidateSpecialCellCaches','reservedSpecialKeys','cellSet','warpMap','warpPairForCell','isWarpTransition','pathCellsAdjacent','lockForDoor','pathHasLockKey','crossingKeys','pathIndexesAtCell','crossingStateAtCell','crossingsSatisfied','gateObj','outsidePoint','analyzeTurns','turnAnalysis','partialTurnCount','numbersForPath','specialPathValid','pathAxisAtCell','pathValid','usedGateSet','occupiedMap','isSolved','rebuildSolutionClues','addWarpSpecial','addLockSpecial','obstacleCellLimit','buildCrossingTemplate','addCrossingSpecial','recentSpecialMechanicTypes','addScheduledSpecial','specialCellUsage','addSpecialCellPattern'
|
||||
].map(name=>functionSource(name)).join('\n')+'\nthis.logic={specialCellSet,turnAnalysis,pathValid,isSolved,addWarpSpecial,addLockSpecial,addCrossingSpecial,addSpecialCellPattern,isWarpTransition,crossingStateAtCell,crossingsSatisfied};',context);
|
||||
const logic=context.logic;
|
||||
for(let level=1;level<=4;level++)assert(AppLogic.specialSchedule(level,100,level).types.length===0,`Level ${level} scheduled a production special`);
|
||||
for(const level of[5,6]){const schedule=AppLogic.specialSchedule(level,100,level,['warp','lock','crossing'],[]);assert(schedule.types.length===1&&schedule.setCount===1,`Level ${level} did not schedule exactly one minimum set`)}
|
||||
const level7=AppLogic.specialSchedule(7,200,7,['warp','lock','crossing'],[]),level8=AppLogic.specialSchedule(8,200,8,['warp','lock','crossing'],[]);
|
||||
assert(level7.setCount===2&&level8.setCount===3,'Level 7-8 special density does not increase deterministically');
|
||||
{
|
||||
const puzzle={valid:[[2,0],[2,1],[0,3],[0,4],[1,4],[2,4]],g:[[2,0,'W'],[2,4,'S']],n:[[0,4,1]],specialCells:{crossings:[],warps:[{a:[2,1],b:[0,3]}],locks:[]},solution:[]},path={startGate:0,endGate:1,openGate:null,cells:[[2,0],[2,1],[0,3],[0,4],[1,4],[2,4]]};
|
||||
assert(logic.turnAnalysis(path,puzzle).count===1,'Warp transition itself was counted as a turn');
|
||||
}
|
||||
|
||||
let base=null;
|
||||
const shapes=[[[0,0],[1,0],[0,1],[1,1]],[[0,0],[1,0],[2,0],[0,1],[1,1]],[[0,0],[1,0],[2,0],[0,1],[1,1],[2,1]]];
|
||||
for(const shape of shapes)for(let seed=1;seed<200&&!base;seed++){
|
||||
let puzzle;try{puzzle=BendPuzzle.generatePuzzle(shape,0x470000+seed,8,0,0)}catch(_){continue}
|
||||
const reserved=new Set(),warp=context.deepClone(puzzle),lock=context.deepClone(puzzle);
|
||||
if(logic.addWarpSpecial(warp,context.rngFrom(seed),reserved)&&logic.addLockSpecial(lock,context.rngFrom(seed+1),new Set()))base=puzzle;
|
||||
}
|
||||
assert(base,'Could not find a puzzle supporting warp and key/door special cells');
|
||||
{
|
||||
const single=BendPuzzle.generatePuzzle([[0,0]],0x47c205ed,5,0,0),cross=context.deepClone(single);
|
||||
assert(logic.addCrossingSpecial(cross,context.rngFrom(0x47c205ed),new Set()),'A one-section crossing template could not be generated within the obstacle cap');
|
||||
assert((cross.obstacles||[]).length<=Math.floor((cross.valid.length+(cross.obstacles||[]).length)*.2),'Crossing template exceeded the 20% obstacle cap');
|
||||
}
|
||||
{
|
||||
const cached=context.deepClone(base);cached.specialCells={crossings:[],warps:[],locks:[]};cached._warpMap=new Map();
|
||||
const added=logic.addWarpSpecial(cached,context.rngFrom(0x9e3779b9),new Set());assert(added,'Could not add a warp for stale-cache regression');
|
||||
assert(!Object.prototype.hasOwnProperty.call(cached,'_warpMap'),'Warp mutation did not invalidate the stale warp map');const addedPair=cached.specialCells.warps[0];assert(logic.isWarpTransition(cached,addedPair.a,addedPair.b),'Warp lookup did not rebuild after cache invalidation');
|
||||
const after=cached.solution.map(path=>logic.turnAnalysis(path,cached).count);assert(cached._warpMap&&typeof cached._warpMap.get==='function'&&cached._warpMap.size===2,'Warp map was not rebuilt after adding a warp');
|
||||
const warpedIndex=cached.solution.findIndex(path=>cached.specialCells.warps.some(pair=>path.cells.some(cell=>context.sameCell(cell,pair.a))));assert(warpedIndex>=0&&Number.isFinite(after[warpedIndex]),'Warp turn analysis did not run');
|
||||
}
|
||||
|
||||
for(let level=1;level<=4;level++){const low=logic.addSpecialCellPattern(base,0x700000+level,level),special=logic.specialCellSet(low);assert(!special.warps.length&&!special.locks.length&&!special.crossings.length,`Level ${level} generated a production special`)}
|
||||
const counts={warp:0,lock:0,cross:0,boards:0};let examples={};
|
||||
for(let seed=0;seed<900;seed++){
|
||||
context.data.specialMechanicsSeen=[];
|
||||
const puzzle=logic.addSpecialCellPattern(base,0x710000+seed,5);if(!puzzle)continue;const special=logic.specialCellSet(puzzle),
|
||||
present=[special.warps.length&&'warp',special.locks.length&&'lock',special.crossings.length&&'cross'].filter(Boolean);
|
||||
assert(present.length===1&&puzzle.specialSchedule?.introduction===true&&puzzle.specialSchedule?.setCount===1,'Level-5 first encounter was not one minimum special type');
|
||||
if(special.warps.length){counts.warp++;examples.warp||=puzzle}
|
||||
if(special.locks.length){counts.lock++;examples.lock||=puzzle}
|
||||
if(special.crossings.length){counts.cross++;examples.cross||=puzzle}
|
||||
if(special.warps.length||special.locks.length||special.crossings.length)counts.boards++;
|
||||
}
|
||||
for(const [type,count] of Object.entries({warp:counts.warp,lock:counts.lock}))assert(count>0,`${type} was never selected by deterministic first-encounter scheduling`);
|
||||
assert(counts.boards>0,'Level-5 scheduling generated no viable special boards');
|
||||
|
||||
function verifyPuzzle(puzzle){
|
||||
const special=logic.specialCellSet(puzzle),valid=new Set(puzzle.valid.map(cell=>context.ckey(...cell))),crossingKeys=new Set(special.crossings.map(cell=>context.ckey(...cell))),covered=new Map();
|
||||
assert((puzzle.obstacles||[]).length<=Math.floor((puzzle.valid.length+(puzzle.obstacles||[]).length)*.2),'Special-cell conversion exceeded the 20% obstacle cap');
|
||||
for(const path of puzzle.solution){
|
||||
for(let i=0;i<path.cells.length;i++){
|
||||
const cell=path.cells[i],key=context.ckey(...cell),visits=covered.get(key)||0;assert(valid.has(key),'Special solution leaves the valid area');assert(visits===0||visits===1&&crossingKeys.has(key),'Special solution overlaps a non-crossing cell or crosses more than twice');covered.set(key,visits+1);
|
||||
if(i){const prev=path.cells[i-1];assert(context.manhattan(prev,cell)===1||logic.isWarpTransition(puzzle,prev,cell),'Special solution has an invalid transition')}
|
||||
}
|
||||
assert(logic.pathValid(path,puzzle),'Special solution no longer satisfies its clue');
|
||||
}
|
||||
assert(covered.size===valid.size,'Special solution does not cover every valid cell');
|
||||
for(const pair of special.warps){let found=false;for(const path of puzzle.solution){const ai=path.cells.findIndex(cell=>context.sameCell(cell,pair.a)),bi=path.cells.findIndex(cell=>context.sameCell(cell,pair.b));if(ai>=0||bi>=0){assert(Math.abs(ai-bi)===1,'Warp endpoints are not consecutive');found=true}}assert(found,'Warp pair is absent from the solution')}
|
||||
for(const lock of special.locks){let found=false;for(const path of puzzle.solution){const ki=path.cells.findIndex(cell=>context.sameCell(cell,lock.key)),di=path.cells.findIndex(cell=>context.sameCell(cell,lock.door));if(ki>=0||di>=0){assert(ki>=0&&di>ki,'Key is not before its door on the solution line');found=true}}assert(found,'Key/door pair is absent from the solution')}
|
||||
const state={paths:context.deepClone(puzzle.solution),specialProgress:{crossings:special.crossings.map(cell=>context.ckey(...cell))}};assert(logic.isSolved(state,puzzle),'Generated special-cell solution does not solve its board');
|
||||
for(const cell of special.crossings){const key=context.ckey(...cell),validSet=new Set(puzzle.valid.map(candidate=>context.ckey(...candidate))),gateSet=new Set(puzzle.g.map(g=>context.ckey(g[0],g[1])));assert(covered.get(key)===2,'Crossing solution does not use the crossing cell exactly twice');assert(!gateSet.has(key),'Crossing cell was placed on a gate cell');for(const neighbor of[[cell[0]-1,cell[1]],[cell[0]+1,cell[1]],[cell[0],cell[1]-1],[cell[0],cell[1]+1]])assert(validSet.has(context.ckey(...neighbor)),'Crossing cell was placed on the board edge')}
|
||||
}
|
||||
|
||||
for(const puzzle of Object.values(examples))verifyPuzzle(puzzle);
|
||||
{
|
||||
const puzzle={valid:[[1,2],[2,1],[2,2],[2,3],[3,2]],g:[],n:[],specialCells:{crossings:[[2,2]],warps:[],locks:[]}},state={paths:[{cells:[[2,1],[2,2],[2,3]]},{cells:[[1,2],[2,2],[3,2]]}],specialProgress:{crossings:[]}};
|
||||
assert(logic.crossingStateAtCell(state,puzzle,[2,2]),'Perpendicular live crossing was not detected');
|
||||
state.paths[1].cells=[[1,2],[2,2],[2,3]];assert(!logic.crossingsSatisfied(state,puzzle),'Two lines with the same axis were accepted as a crossing');
|
||||
const solvedPuzzle={valid:[[2,0],[2,1],[2,2],[2,3],[1,3],[0,2],[1,2],[3,2],[3,1]],g:[[2,0,'W'],[1,3,'N'],[0,2,'N'],[3,1,'W']],n:[[2,3,1],[3,2,1]],specialCells:{crossings:[[2,2]],warps:[],locks:[]}},solvedState={paths:[{startGate:0,endGate:1,cells:[[2,0],[2,1],[2,2],[2,3],[1,3]]},{startGate:2,endGate:3,cells:[[0,2],[1,2],[2,2],[3,2],[3,1]]}],specialProgress:{crossings:[]}};
|
||||
assert(logic.isSolved(solvedState,solvedPuzzle),'A live perpendicular crossing did not permit normal board clear');
|
||||
solvedState.paths[1].cells=[[0,2],[1,2],[1,1],[2,1],[3,1]];solvedState.specialProgress.crossings=['2,2'];assert(!logic.isSolved(solvedState,solvedPuzzle),'Crossing history permitted clear after the crossing state was removed');
|
||||
}
|
||||
|
||||
console.log(`Special-cell generation passed: ${counts.warp} warp, ${counts.lock} key/door, ${counts.cross} crossing first-encounter boards`);
|
||||
39
test/stage34-smoke-test.js
Normal file
39
test/stage34-smoke-test.js
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
'use strict';
|
||||
const {app,assert,functionSource,loadAppLogic}=require('./helpers/app-source');
|
||||
const AppLogic=loadAppLogic();
|
||||
const placeSource=functionSource('placeChildAtFrontier'),expandSource=functionSource('expandMetaNow'),metaStateSource=functionSource('metaState'),snapshotSource=functionSource('snapshotForStorage');
|
||||
for(const forbidden of ['ensureBoards','renderAll','updateHud','nextPaint','save(','rebuildOccupancy'])assert(!placeSource.includes(forbidden),`Child placement still performs ${forbidden}`);
|
||||
assert((expandSource.match(/refreshWorldView\(/g)||[]).length===1,'Expansion does not use one presentation/persistence commit');
|
||||
for(const forbidden of ['ensureBoards','renderAll','updateHud'])assert(!expandSource.includes(forbidden),`Expansion bypasses the shared view pipeline with ${forbidden}`);
|
||||
assert(metaStateSource.includes('normalizeState(state)'),'Live state normalization is not delegated to normalizeState');
|
||||
assert(snapshotSource.includes('metaRowsForStorage()')&&snapshotSource.includes('stateRowsForStorage()'),'Snapshot serialization bypasses shared row serializers');
|
||||
assert(functionSource('pushCloudPending').includes('cloudRowsForStorage')&&functionSource('cloudRowsForStorage').includes('metaForStorage')&&functionSource('cloudRowsForStorage').includes('stateForStorage'),'Cloud serialization bypasses full-detail row serializers');
|
||||
assert(functionSource('stateForStorage').includes('AppLogic.stateForStorage'),'State serialization is not delegated to the shared module');
|
||||
assert(functionSource('collectConnectedLineComponent').includes('AppLogic.collectConnectedLineComponent'),'Connected-line traversal is not delegated to the shared module');
|
||||
|
||||
const data={
|
||||
metas:{A:{id:'A',puzzle:{}},B:{id:'B',puzzle:{}}},
|
||||
states:{A:{paths:[{startGate:0,endGate:1,cells:[[0,0],[0,1]]}]},B:{paths:[{startGate:0,endGate:1,cells:[[0,0],[1,0],[2,0]]}]}}
|
||||
};
|
||||
let matchingCalls=0;
|
||||
const environment={
|
||||
data,
|
||||
metaState:id=>data.states[id],
|
||||
matchingNeighborGate(meta,gate){
|
||||
matchingCalls++;
|
||||
if(meta.id==='A'&&gate===1)return{meta:data.metas.B,gateIndex:0};
|
||||
if(meta.id==='B'&&gate===0)return{meta:data.metas.A,gateIndex:1};
|
||||
return null;
|
||||
}
|
||||
};
|
||||
const componentCache=new Map(),lengthA=AppLogic.connectedLineLength(data.metas.A,0,componentCache,environment),callsAfterFirst=matchingCalls,lengthB=AppLogic.connectedLineLength(data.metas.B,0,componentCache,environment);
|
||||
assert(lengthA===5&&lengthB===5,'Connected-line component length is incorrect');
|
||||
assert(matchingCalls===callsAfterFirst,'Connected-line component was traversed again for a cached member');
|
||||
const widthCache=new Map(),widthA=AppLogic.renderedConnectedLineWidth(data.metas.A,0,widthCache,environment,componentCache),callsAfterWidth=matchingCalls,widthB=AppLogic.renderedConnectedLineWidth(data.metas.B,0,widthCache,environment,componentCache);
|
||||
assert(widthA===widthB&&matchingCalls===callsAfterWidth,'Connected-line width did not reuse the cached component');
|
||||
|
||||
const stateRow=AppLogic.stateForStorage({paths:[],specialProgress:{crossings:[]},solved:false,expanded:false,expansionRetryRound:7,solvedBy:null,scoreAwarded:0,scoreVersion:2,store:null,rev:5,transient:'remove'},{scoreVersion:2});
|
||||
assert(stateRow.rev===5&&stateRow.expansionRetryRound===7&&!('transient' in stateRow),'State serializer lost expansion repair progress or leaked transient fields');
|
||||
const emptyState=AppLogic.stateForStorage(null,{scoreVersion:3,normalizeState:()=>({paths:[],specialProgress:{crossings:[]},scoreVersion:3})});
|
||||
assert(emptyState.scoreVersion===3&&Array.isArray(emptyState.paths),'State serializer fallback is invalid');
|
||||
console.log('Stage 3/4 regression passed: batched expansion, shared traversal, normalization, serialization, and view pipeline');
|
||||
101
test/storage-smoke-test.js
Normal file
101
test/storage-smoke-test.js
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
'use strict';
|
||||
const {vm,assert,functionSource}=require('./helpers/app-source');
|
||||
const initSource=functionSource('init');
|
||||
const initialSource=functionSource('readInitialDataAsync');
|
||||
assert(initialSource.includes("Object.prototype.hasOwnProperty.call(bundle,'snapshot')?bundle.snapshot:bundle"),
|
||||
'A fresh database bundle with snapshot:null can be mistaken for game data');
|
||||
assert(!initSource.includes('fullResync')&&!initSource.includes("cloudOutboxDeleteKeys.add('full-resync')"),'Retired full-resync migration remains in startup');
|
||||
assert(initSource.includes('recoveredDeletionTombstones')&&initSource.includes('deletedBoardRevisions.set(id,tombstone.rev')&&initSource.includes('deletedBoardAuthors.set(id,tombstone.revAuthor'),'Recovered deletion tombstones are not restored into the durable deletion queue');
|
||||
async function runUpgradeCase(oldVersion){
|
||||
const worldWrites=[],deletedStores=[];let openedVersion=0;
|
||||
const genericStore=()=>({indexNames:{contains:()=>true},createIndex:()=>{},put:()=>{},clear:()=>{},get:()=>{const request={result:null};queueMicrotask(()=>request.onsuccess?.());return request}}),
|
||||
worldsStore={...genericStore(),put:row=>worldWrites.push(row)},
|
||||
stores=new Proxy({worlds:worldsStore},{get:(target,name)=>target[name]||(target[name]=genericStore())}),db={objectStoreNames:{contains:()=>true},deleteObjectStore:name=>deletedStores.push(name),close(){}},request={result:db,transaction:{objectStore:name=>stores[name]}};
|
||||
const context={
|
||||
Promise,console:{warn:()=>{}},idbAvailable:true,idbHealth:'unverified',worldDbAbandoned:false,worldDbPromise:null,worldDbName:'test-world',IDB_LAYOUT_VERSION:8,FIELD_STORAGE_FORMAT:2,SAVE_SCHEMA:31,WORLD_GENERATION:'current-only-test',IDB_STARTUP_TIMEOUT:8000,cloudApiEnabled:false,cloudOutboxReady:true,
|
||||
indexedDB:{open(_name,version){openedVersion=version;queueMicrotask(()=>{request.onupgradeneeded?.({oldVersion});queueMicrotask(()=>request.onsuccess?.())});return request}},
|
||||
validWorldEpoch:value=>typeof value==='string'&&value.startsWith('world:'),createWorldEpoch:()=> 'world:upgrade-test',rememberWorldEpoch:()=>true,
|
||||
defaultData:()=>({metas:{},states:{},worldEpoch:null}),globalForStorage:value=>({...value,metas:undefined,states:undefined}),
|
||||
requestValue:request=>new Promise((resolve,reject)=>{request.onsuccess=()=>resolve(request.result);request.onerror=()=>reject(request.error)}),
|
||||
queueMicrotask,setTimeout,clearTimeout
|
||||
};
|
||||
vm.createContext(context);vm.runInContext(`${functionSource('openWorldDb')}\nthis.openWorldDb=openWorldDb;`,context);await context.openWorldDb();
|
||||
return{openedVersion,worldWrites,deletedStores};
|
||||
}
|
||||
async function runCase({mirror,stored,loadError=null,journals=[],coverage=new Map()}){
|
||||
const removedKeys=[];
|
||||
const context={
|
||||
console:{warn:()=>{}},idbAvailable:true,idbHealth:'ready',indexedDbExpectedAtStartup:true,worldDbAbandoned:false,worldDbPromise:null,cloudApiEnabled:false,cloudOutboxReady:true,cloudAvailable:false,activeStorageFormat:2,FIELD_STORAGE_FORMAT:2,IDB_STARTUP_TIMEOUT:8000,
|
||||
storageKey:'save',loadNotices:[],readInitialData:()=>mirror,safeLocalGet:()=>mirror?'{mirror}':null,
|
||||
readRecoveryJournals:()=>journals,safeLocalRemove:key=>{removedKeys.push(key);return true},
|
||||
recoveryJournalsToCover:[],recoveredDeletionTombstones:new Map(),
|
||||
startupRecoveredMetaIds:new Set(),startupRecoveredStateIds:new Set(),startupRecoveredDeletedIds:new Set(),
|
||||
loadSnapshotFromDb:async()=>{if(loadError)throw loadError;return{snapshot:stored,coverage,databaseJournals:[],worldEpoch:'world:test-epoch'}},
|
||||
withStartupTimeout:promise=>promise,
|
||||
validWorldEpoch:value=>typeof value==='string'&&value.startsWith('world:'),storedWorldEpoch:()=>null,createWorldEpoch:()=> 'world:test-epoch',rememberWorldEpoch:()=>true,
|
||||
defaultData:()=>({metas:{},states:{}}),
|
||||
attachWorldEpoch:(snapshot,epoch)=>Object.assign(snapshot||{metas:{},states:{}},{worldEpoch:epoch}),abandonWorldDatabase:()=>{context.worldDbAbandoned=true;context.idbAvailable=false;context.idbHealth='uncertain'},
|
||||
isPlainObject:value=>!!value&&typeof value==='object'&&!Array.isArray(value),
|
||||
deepClone:value=>JSON.parse(JSON.stringify(value)),
|
||||
mergeBoardStates:(current,incoming)=>!current?JSON.parse(JSON.stringify(incoming)):!incoming?current:((incoming.rev||0)>(current.rev||0)?JSON.parse(JSON.stringify(incoming)):current),
|
||||
normalizeCloudPending:value=>({metaIds:[...new Set(value?.metaIds||[])],stateIds:[...new Set(value?.stateIds||[])],deleted:[...new Set(value?.deleted||[])],globalChanged:value?.globalChanged===true}),
|
||||
normalizeMeta:(_id,value)=>value,normalizeState:value=>value,
|
||||
normalizeSnapshot:value=>value
|
||||
};
|
||||
vm.createContext(context);
|
||||
vm.runInContext(`${functionSource('revisionVersion')}\n${functionSource('compareRevisionVersions')}\n${functionSource('setRecoveryJournalsToCover')}\n${functionSource('mergeRecoveryJournal')}\n${functionSource('mergeRecoveryJournals')}\n${functionSource('combineRecoveryJournals')}\n${functionSource('mergeV2RecoveryJournals')}\n${functionSource('readInitialDataAsync')}\nthis.logic={readInitialDataAsync,mergeRecoveryJournal};`,context);
|
||||
return{result:await context.logic.readInitialDataAsync(),context,removedKeys};
|
||||
}
|
||||
(async()=>{
|
||||
let test=await runCase({mirror:{metas:{},states:{}},stored:null});
|
||||
assert(test.result.metas&&test.result.states,'A fresh database bundle did not produce a valid default snapshot');
|
||||
const mirror={metas:{B0:{id:'B0'}},updatedAt:20},emptyDb={metas:{},updatedAt:30};
|
||||
test=await runCase({mirror,stored:emptyDb});
|
||||
assert(Object.keys(test.result.metas).length===0,'The retired compact mirror overrode the current database');
|
||||
const db={metas:{B0:{id:'B0'},B1:{id:'B1'}},updatedAt:40};
|
||||
test=await runCase({mirror,stored:db});
|
||||
assert(Object.keys(test.result.metas).length===2&&test.result.metas.B1,'Populated database was not loaded');
|
||||
const richerMirror={metas:{B0:{id:'B0'},B1:{id:'B1'},B2:{id:'B2'},B3:{id:'B3'}},updatedAt:35};
|
||||
const staleDb={metas:{B0:{id:'B0'}},updatedAt:50};
|
||||
test=await runCase({mirror:richerMirror,stored:staleDb});
|
||||
assert(Object.keys(test.result.metas).length===1&&test.result.metas.B0&&!test.result.metas.B3,'A stale richer mirror resurrected a board deleted from the newer database');
|
||||
const newerMirror={metas:{B0:{id:'B0'},B1:{id:'B1'}},updatedAt:60};
|
||||
const olderSameSizeDb={metas:{B0:{id:'B0'},B1:{id:'B1'}},updatedAt:55};
|
||||
test=await runCase({mirror:newerMirror,stored:olderSameSizeDb});
|
||||
assert(Object.keys(test.result.metas).length===2&&test.result.updatedAt===55,'The current database was not authoritative over a retired mirror');
|
||||
test=await runCase({mirror,stored:null,loadError:new Error('timeout')});
|
||||
assert(Object.keys(test.result.metas).length===1&&test.result.metas.B0,'Database failure did not fall back to the compact mirror');
|
||||
assert(test.context.idbAvailable===false&&test.context.idbHealth==='uncertain','Unverified database fallback remained writable');
|
||||
const journalBase={metas:{B0:{id:'B0',rev:100}},states:{B0:{rev:100}},updatedAt:100,nextId:2};
|
||||
const staleDelete={schema:30,worldGeneration:'test',updatedAt:101,sessionId:'delete-session',seq:1,deleted:[{id:'B0',rev:99}]};
|
||||
const freshDelete={...staleDelete,seq:2,deleted:[{id:'B0',rev:101}]};
|
||||
let merged=test.context.logic.mergeRecoveryJournal(journalBase,staleDelete);
|
||||
assert(merged.metas.B0&&merged.states.B0,'Older deletion tombstone removed a newer board revision');
|
||||
merged=test.context.logic.mergeRecoveryJournal(journalBase,freshDelete);
|
||||
assert(!merged.metas.B0&&!merged.states.B0,'Newer deletion tombstone did not remove the covered board and state');
|
||||
const currentDb={metas:{B0:{id:'B0',rev:10}},states:{B0:{rev:10}},updatedAt:100,nextId:2};
|
||||
const coveredJournal={schema:30,worldGeneration:'test',updatedAt:105,sessionId:'covered',seq:3,_storageKey:'journal:covered',metas:[{id:'B2',rev:1}],states:[],deleted:[]};
|
||||
const uncoveredJournal={schema:30,worldGeneration:'test',updatedAt:110,sessionId:'pending',seq:4,_storageKey:'journal:pending',metas:[{id:'B1',rev:11}],states:[],deleted:[]};
|
||||
test=await runCase({mirror:currentDb,stored:currentDb,journals:[coveredJournal,uncoveredJournal],coverage:new Map([['covered',3],['pending',2]])});
|
||||
assert(test.result.metas.B1&&!test.result.metas.B2,'Recovery coverage did not merge only the uncovered session journal');
|
||||
assert(test.removedKeys.includes('journal:covered')&&!test.removedKeys.includes('journal:pending'),'Covered journal cleanup removed the wrong per-session journal');
|
||||
assert(test.context.recoveryJournalsToCover.length===1&&test.context.recoveryJournalsToCover[0].sessionId==='pending','Uncovered journal was not retained for the next coverage commit');
|
||||
let upgrade=await runUpgradeCase(2);assert(upgrade.openedVersion===8&&['metas','states','global','recovery','tombstones','outbox'].every(name=>upgrade.deletedStores.includes(name))&&upgrade.worldWrites.some(row=>row.status==='active'),'Existing database did not delete retired stores and initialize a current-only world');
|
||||
upgrade=await runUpgradeCase(0);assert(upgrade.openedVersion===8&&upgrade.worldWrites.some(row=>row.status==='active'),'Fresh current-only world was not initialized');
|
||||
const outboxContext={normalizeCloudPending:value=>({metaIds:[...new Set(value?.metaIds||[])],stateIds:[...new Set(value?.stateIds||[])],deleted:[...new Set(value?.deleted||[])],globalChanged:value?.globalChanged===true})};
|
||||
vm.createContext(outboxContext);vm.runInContext(`${functionSource('cloudPendingFromOutboxRows')}\nthis.readOutbox=cloudPendingFromOutboxRows;`,outboxContext);
|
||||
const pending=outboxContext.readOutbox([{type:'meta',id:'B1'},{type:'deleted',id:'B2'},{type:'global'}]);
|
||||
assert(pending.metaIds[0]==='B1'&&pending.deleted[0]==='B2'&&pending.globalChanged,'Startup outbox rows were not integrated into pending cloud work');
|
||||
const journalPrefix='journal:',journalValues=new Map([
|
||||
['journal',JSON.stringify({schema:30,worldGeneration:'world',updatedAt:3,sessionId:'retired-single-key',seq:1})],
|
||||
[`${journalPrefix}one`,JSON.stringify({schema:30,worldGeneration:'world',updatedAt:1,sessionId:'one',seq:2})],
|
||||
[`${journalPrefix}two`,JSON.stringify({schema:30,worldGeneration:'world',updatedAt:2,sessionId:'two',seq:3})]
|
||||
]),journalContext={
|
||||
recoveryJournalKey:'journal',recoveryJournalPrefix:journalPrefix,LOCAL_MIRROR_MAX_BYTES:10000,SAVE_SCHEMA:30,WORLD_GENERATION:'world',
|
||||
safeLocalKeys:prefix=>[...journalValues.keys()].filter(key=>key.startsWith(prefix)),safeLocalGet:key=>journalValues.get(key)||null
|
||||
};
|
||||
vm.createContext(journalContext);vm.runInContext(`${functionSource('readRecoveryJournals')}\nthis.readRecoveryJournals=readRecoveryJournals;`,journalContext);
|
||||
const discovered=journalContext.readRecoveryJournals();
|
||||
assert(discovered.map(journal=>journal.sessionId).join(',')==='one,two'&&discovered.every(journal=>journal._storageKey),'Current per-session recovery journals were not discovered and ordered');
|
||||
console.log('Storage recovery integration passed');
|
||||
})().catch(error=>{console.error(error);process.exitCode=1});
|
||||
119
test/store-ui-browser-test.js
Normal file
119
test/store-ui-browser-test.js
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
'use strict';
|
||||
|
||||
const http=require('http');
|
||||
const path=require('path');
|
||||
const {spawn}=require('child_process');
|
||||
const {chromium}=require('playwright');
|
||||
const {assert,root}=require('./helpers/app-source');
|
||||
|
||||
const port=61000+Math.floor(Math.random()*1000);
|
||||
const url=`http://127.0.0.1:${port}/`;
|
||||
const edgePath=process.env.BEND_FIELD_BROWSER_PATH||process.env.BEND_FIELD_EDGE_PATH||'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe';
|
||||
const sleep=milliseconds=>new Promise(resolve=>setTimeout(resolve,milliseconds));
|
||||
const server=spawn(process.execPath,['server.js'],{cwd:root,env:{...process.env,HOST:'127.0.0.1',PORT:String(port)},stdio:'ignore'});
|
||||
let browser=null;
|
||||
|
||||
async function waitForServer(){
|
||||
for(let attempt=0;attempt<80;attempt++){
|
||||
const ready=await new Promise(resolve=>{
|
||||
const request=http.get(url,response=>{response.resume();resolve(response.statusCode===200)});
|
||||
request.on('error',()=>resolve(false));request.setTimeout(250,()=>{request.destroy();resolve(false)});
|
||||
});
|
||||
if(ready)return;
|
||||
await sleep(100);
|
||||
}
|
||||
throw new Error('Local game server did not start');
|
||||
}
|
||||
|
||||
(async()=>{
|
||||
try{
|
||||
await waitForServer();
|
||||
browser=await chromium.launch({executablePath:edgePath,headless:true});
|
||||
const page=await browser.newPage({viewport:{width:1000,height:900}});
|
||||
const browserErrors=[];page.on('pageerror',error=>browserErrors.push(String(error?.stack||error)));page.on('console',message=>{if(message.type()==='error')browserErrors.push(message.text())});
|
||||
await page.goto(url,{waitUntil:'domcontentloaded'});
|
||||
try{await page.waitForFunction(()=>document.body?.dataset?.ready==='true')}
|
||||
catch(error){console.error('Store UI startup diagnostics:',JSON.stringify({errors:browserErrors,state:await page.evaluate(()=>({ready:document.body?.dataset?.ready||null,status:document.querySelector('#statusMessage')?.textContent||'',text:document.body?.innerText?.slice(0,500)||''}))}));throw error}
|
||||
await page.evaluate(async()=>{await document.fonts.load('16px DotGothic16Local','日本語盤面');await document.fonts.ready});
|
||||
const result=await page.evaluate(async()=>{
|
||||
const meta=data.metas.B0,st=metaState('B0'),itemIds=seededStoreItemIds(meta.seed);
|
||||
st.store={owner:'UI TEST',pathIndex:0,cellIndex:0,itemIds,purchases:[],priceVersion:STORE_PRICE_VERSION,priceCoefficient:1};
|
||||
data.score=1e9;openStoreMeta(meta);
|
||||
const itemSection=document.querySelector('.store-items-section'),cursorSection=document.querySelector('.store-cursors-section'),
|
||||
cursorCards=[...cursorSection.querySelectorAll('.store-item')],itemList=itemSection.querySelector('.store-section-list'),
|
||||
cursorList=cursorSection.querySelector('.store-section-list'),flag=FLAG_CURSOR_ITEMS.find(item=>item.id==='cursor-flag-jp');
|
||||
const itemColumns=getComputedStyle(itemList).gridTemplateColumns.split(' ').length,cursorColumns=getComputedStyle(cursorList).gridTemplateColumns.split(' ').length,
|
||||
cursorVertical=cursorCards.every(card=>card.querySelector('.store-item-icon').getBoundingClientRect().bottom<=card.querySelector('.store-buy').getBoundingClientRect().top+1);
|
||||
const pricesBefore=[...document.querySelectorAll('.store-buy:not(:disabled)')].map(button=>button.textContent);
|
||||
data.score=0;renderStorePanel();
|
||||
const pricesAfter=[...document.querySelectorAll('.store-buy')].map(button=>button.textContent);
|
||||
const storeResult={
|
||||
headings:[...document.querySelectorAll('.store-section-title')].map(node=>node.textContent),
|
||||
items:itemSection.querySelectorAll('.store-item').length,cursors:cursorCards.length,
|
||||
cursorHasCopy:cursorCards.some(card=>card.querySelector('h4,p,strong,.store-item-copy')),
|
||||
cursorIcons:cursorCards.map(card=>Boolean(card.querySelector('.store-item-icon')?.textContent||card.querySelector('.store-item-icon img')?.getAttribute('src'))),
|
||||
itemColumns,cursorColumns,cursorVertical,
|
||||
pricesBefore,pricesAfter,actualPrices:pricesAfter.every(text=>/[\d,]+$/.test(text)&&!text.includes('あと')&&!text.endsWith('個'))
|
||||
};
|
||||
st.store.purchases.push({id:flag.id,buyer:'UI TEST',boughtAt:1,paidCost:3000,usedAt:0},{id:'score-lens',buyer:'UI TEST',boughtAt:2,paidCost:200000,usedAt:0});inventoryCache=null;applyCursorStyle('default');
|
||||
closeStore(false);renderInventoryPanel();
|
||||
const inventoryOptions=[...document.querySelectorAll('.inventory-cursor-option')],inventoryHasCopy=Boolean(document.querySelector('.inventory-cursors-section p,.inventory-cursors-section strong,.inventory-cursors-section .store-item-copy'));
|
||||
const persistentSave=save;save=async()=>true;
|
||||
await useInventoryItemLoaded(flag.id);const selectedOnce=data.cursorStyle;
|
||||
await useInventoryItemLoaded(flag.id);const selectedTwice=data.cursorStyle;
|
||||
const lensButton=[...document.querySelectorAll('.inventory-item h3')].find(node=>node.textContent.startsWith('ジェムレンズ'))?.closest('.inventory-item')?.querySelector('.inventory-use'),
|
||||
lensInitial=lensButton?.textContent;
|
||||
await useInventoryItemLoaded('score-lens');const lensOn=data.scoreLensEnabled,lensOnText=[...document.querySelectorAll('.inventory-item h3')].find(node=>node.textContent.startsWith('ジェムレンズ'))?.closest('.inventory-item')?.querySelector('.inventory-use')?.textContent;
|
||||
await useInventoryItemLoaded('score-lens');const lensOff=data.scoreLensEnabled,lensOffText=[...document.querySelectorAll('.inventory-item h3')].find(node=>node.textContent.startsWith('ジェムレンズ'))?.closest('.inventory-item')?.querySelector('.inventory-use')?.textContent;
|
||||
save=persistentSave;
|
||||
applyCursorStyle(flag.cursorStyle);
|
||||
viewport.dispatchEvent(new PointerEvent('pointermove',{bubbles:true,clientX:300,clientY:300,pointerType:'mouse'}));
|
||||
await new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)));
|
||||
applyCursorStyle(flag.cursorStyle);
|
||||
const board=rendered.get('B0'),dragCache=buildDragCache(board,0,false,1);updateDragCursorDesign(dragCache,[50,50]);
|
||||
const dragDesign={image:dragCache.cursorImage.getAttribute('href'),display:getComputedStyle(dragCache.cursorImage).display,clip:dragCache.cursorImage.getAttribute('clip-path')};
|
||||
clearDragRender(board);
|
||||
const gemAnimationStarted=playGemCollectionAnimation(board,1000),gemParticles=document.querySelectorAll('.gem-particle').length;document.querySelectorAll('.gem-particle').forEach(node=>node.remove());
|
||||
const normalDisplay=getComputedStyle(customEmojiCursor).display;document.body.classList.add('is-drawing');const drawingDisplay=getComputedStyle(customEmojiCursor).display;document.body.classList.remove('is-drawing');
|
||||
const firstNumber=board.numberNodes.values().next().value,firstWarning=board.numberWarningNodes.values().next().value;
|
||||
return{
|
||||
flags:FLAG_CURSOR_ITEMS.length,fontReady:document.fonts.check('16px DotGothic16Local'),fontFaces:[...document.fonts].map(face=>({family:face.family,status:face.status,weight:face.weight})),
|
||||
bodyFont:getComputedStyle(document.body).fontFamily,buttonFont:getComputedStyle(document.querySelector('button')).fontFamily,numberFont:getComputedStyle(firstNumber).fontFamily,
|
||||
faceMin:Math.min(...YELLOW_FACE_CURSOR_ITEMS.map(item=>item.cost)),faceMax:Math.max(...YELLOW_FACE_CURSOR_ITEMS.map(item=>item.cost)),
|
||||
flagBase:FLAG_CURSOR_ITEMS.find(item=>item.id==='cursor-flag-af').cost,oecdBase:flag.cost,shopChance:STORE_CHANCE,
|
||||
meta:document.querySelector('#storeMeta').textContent,
|
||||
...storeResult,inventoryOptions:inventoryOptions.length,inventoryHasCopy,selectedOnce,selectedTwice,lensInitial,lensOn,lensOnText,lensOff,lensOffText,
|
||||
customText:customEmojiCursor.textContent,customImage:customEmojiCursor.querySelector('img')?.getAttribute('src')||'',customVisible:customEmojiCursor.classList.contains('visible'),
|
||||
customOpacity:getComputedStyle(customEmojiCursor).opacity,customFont:getComputedStyle(customEmojiCursor).fontFamily,
|
||||
customWidth:getComputedStyle(customEmojiCursor).width,customRadius:getComputedStyle(customEmojiCursor).borderRadius,customFlagFit:getComputedStyle(customEmojiCursor.querySelector('img')).objectFit,
|
||||
normalDisplay,drawingDisplay,dragDesign,gemAnimationStarted,gemParticles,warningInFront:Boolean(firstNumber.compareDocumentPosition(firstWarning)&Node.DOCUMENT_POSITION_FOLLOWING),
|
||||
gemLabel:document.querySelector('.stat.score').textContent.trim()
|
||||
};
|
||||
});
|
||||
if(!result.fontReady)console.log('Store UI diagnostics:',JSON.stringify(result));
|
||||
assert(result.flags===262,'Flag cursor catalog is incomplete');
|
||||
assert(result.fontReady&&/DotGothic16Local/.test(result.bodyFont)&&/DotGothic16Local/.test(result.buttonFont)&&/DotGothic16Local/.test(result.numberFont),'Bundled Japanese dot font did not load or was overridden');
|
||||
assert(result.faceMin===500&&result.faceMax===50000&&result.flagBase===10000&&result.oecdBase===20000&&Math.abs(result.shopChance-1/30)<1e-12,'Cursor prices or the 1/30 shop chance are incorrect');
|
||||
assert(result.meta==='店主:UI TEST'&&!result.meta.includes('価格は固定'),'Fixed-price shop copy remains');
|
||||
assert(result.headings.join('|')==='アイテム|カーソル','Shop sections are not separated');
|
||||
assert(result.items===2&&result.cursors===12,'Shop does not render its 2+12 inventory');
|
||||
assert(!result.cursorHasCopy&&result.cursorIcons.every(Boolean)&&result.actualPrices,'Cursor cards still expose names/descriptions, lack designs, or do not show actual prices');
|
||||
assert(result.itemColumns===1&&result.cursorColumns===6&&result.cursorVertical,'Shop cursor designs are not horizontally arranged above their purchase buttons');
|
||||
assert(result.inventoryOptions===1&&!result.inventoryHasCopy&&result.selectedOnce==='flag-jp'&&result.selectedTwice==='default',`Inventory cursor grid is not persistent or click-to-toggle: ${JSON.stringify({inventoryOptions:result.inventoryOptions,inventoryHasCopy:result.inventoryHasCopy,selectedOnce:result.selectedOnce,selectedTwice:result.selectedTwice})}`);
|
||||
assert(result.lensInitial==='OFF'&&result.lensOn===true&&result.lensOnText==='ON'&&result.lensOff===false&&result.lensOffText==='OFF',`Score lens is not an ON/OFF inventory toggle: ${JSON.stringify({lensInitial:result.lensInitial,lensOn:result.lensOn,lensOnText:result.lensOnText,lensOff:result.lensOff,lensOffText:result.lensOffText})}`);
|
||||
assert(!result.customText&&result.customImage.endsWith('assets/flags/1f1ef-1f1f5.svg')&&result.customVisible&&result.customOpacity==='0.76'&&result.customWidth==='14px'&&result.customRadius==='50%'&&result.customFlagFit==='cover','Japanese flag cursor is not a translucent circular knob-shaped SVG overlay');
|
||||
assert(result.normalDisplay==='grid'&&result.drawingDisplay==='none'&&result.dragDesign.display!=='none'&&result.dragDesign.image.endsWith('assets/flags/1f1ef-1f1f5.svg')&&/^url\(#/.test(result.dragDesign.clip),`The custom cursor does not transfer to the grabbed knob: ${JSON.stringify({normalDisplay:result.normalDisplay,drawingDisplay:result.drawingDisplay,dragDesign:result.dragDesign})}`);
|
||||
assert(result.gemAnimationStarted&&result.gemParticles>=4,'Purple gem collection particles did not spawn from the board');
|
||||
assert(result.warningInFront&&!result.gemLabel.includes('ジェム')&&result.gemLabel.startsWith('◆'),'The 曲がる warning layer or standalone currency label is incorrect');
|
||||
if(process.env.BEND_FIELD_UI_SCREENSHOT){
|
||||
const base=path.resolve(process.env.BEND_FIELD_UI_SCREENSHOT);
|
||||
await page.evaluate(()=>openStoreMeta(data.metas.B0));await page.screenshot({path:`${base}-store.png`});
|
||||
await page.evaluate(()=>{closeStore(false);closeHelp(false,true);const flag=FLAG_CURSOR_ITEMS.find(item=>item.id==='cursor-flag-jp');applyCursorStyle(flag.cursorStyle);viewport.dispatchEvent(new PointerEvent('pointermove',{bubbles:true,clientX:500,clientY:450,pointerType:'mouse'}))});
|
||||
await page.screenshot({path:`${base}-cursor.png`});
|
||||
}
|
||||
console.log(`Store UI browser test passed: ${result.flags} flags, ${result.items}+${result.cursors} entries, ${result.cursorColumns} cursor columns`);
|
||||
}finally{
|
||||
if(browser)await browser.close();
|
||||
server.kill();
|
||||
}
|
||||
})().catch(error=>{console.error(error);process.exitCode=1});
|
||||
13
test/v4771-ui-input-smoke-test.js
Normal file
13
test/v4771-ui-input-smoke-test.js
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
'use strict';
|
||||
const fs=require('fs'),path=require('path');
|
||||
const {assert,functionSource,app,css,html}=require('./helpers/app-source');
|
||||
const root=path.resolve(__dirname,'..'),catalog=JSON.parse(fs.readFileSync(path.join(root,'store-catalog.json'),'utf8'));
|
||||
assert(functionSource('positionBoardLabel').includes('viewportRect')&&functionSource('applyCamera').includes('repositionActiveBoardHud'),'Board HUD is not repositioned against viewport bounds');
|
||||
assert(css.includes('.fps-stat{position:fixed')&&css.includes('left:calc(6px')&&css.includes('bottom:calc(5px')&&functionSource('refreshFpsCounter').includes('FPS 待機'),'FPS display is not bottom-left or idle-aware');
|
||||
assert(functionSource('pointerEventSamples').includes('coalesced[coalesced.length-1]')&&functionSource('appendBoardPointerSamples').includes('setBoardPointerSample')&&functionSource('setBoardPointerSample').includes('b.pendingPointerMove=sample')&&functionSource('renderDragFrame').includes('renderGeometryRevision'),'Knob dragging still processes stale samples or recomputes static geometry every frame');
|
||||
assert(functionSource('makeBoard').includes("class:'board-input-surface'")&&css.includes('.board-input-surface{fill:transparent;cursor:crosshair;pointer-events:fill}')&&css.includes('.board-svg{cursor:crosshair;pointer-events:none}'),'Board input remains active across overlapping transparent SVG padding');
|
||||
assert(css.includes('#customEmojiCursor.flag-cursor{width:32px;height:32px')&&css.includes('body[data-cursor-mode="dom"] *{cursor:none!important}')&&functionSource('updateCustomCursorFromPointer').includes("dataset.cursorMode==='dom'"),'Custom cursor size or UI-wide DOM fallback is missing');
|
||||
assert(catalog.find(item=>item.id==='score-lens')?.cost===200000&&app.includes("id:'score-lens',name:'ジェムレンズ',cost:200000"),'Gem lens base price is not 200000');
|
||||
assert(app.includes('TIME_ATTACK_MINUTES=Object.freeze([3,5,10])')&&html.includes('data-time-minutes="3"')&&html.includes('data-time-minutes="5"')&&html.includes('data-time-minutes="10"'),'Time attack durations are not 3, 5, and 10 minutes');
|
||||
assert(css.includes('.time-attack-panel{width:min(760px')&&html.includes('📋 結果をコピー')&&functionSource('timeAttackResultText').includes('⏱️'),'Time attack UI/result decoration is incomplete');
|
||||
console.log('v47.71 HUD, input, cursor, economy, and time-attack regression test passed');
|
||||
29
test/v4772-ownership-reaction-name-smoke-test.js
Normal file
29
test/v4772-ownership-reaction-name-smoke-test.js
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
'use strict';
|
||||
const {assert,functionSource,app,css,html,read,loadBendPuzzle,root,fs,path}=require('./helpers/app-source');
|
||||
assert(app.includes("const APP_VERSION='47.77'")&&html.includes('v47.77'),'Version was not advanced to v47.77');
|
||||
assert(functionSource('makeBoard').includes('board-input-clip-')&&functionSource('renderBoardNow').includes("'clip-path':`url(#${b.inputClipId})`") ,'Endpoint hit regions are not clipped to their owning board');
|
||||
assert(functionSource('sharedBoundaryColorIndex').includes('boundaryColorSource')&&functionSource('renderBoardNow').includes('sharedBoundaryColorIndex(meta,i)'),'Facing gate colors are not unified');
|
||||
assert(functionSource('renderBoardNow').includes('else renderDragFrame(b)'),'Stationary drag cursor is not restored after a full board redraw');
|
||||
assert(functionSource('bindBoard').includes('b.drawing=null;b.armedGate=null')&&!functionSource('bindBoard').includes('b.drawing.pointerId=e.pointerId'),'Released drawings can still be resumed from unrelated cells');
|
||||
assert(functionSource('radialReactionIndex').includes('bestDistance')&&functionSource('beginReactionGesture').includes('setPointerCapture')&&app.includes("window.addEventListener('pointermove',moveReactionGesture,true)"),'Radial hold-drag-release selection is incomplete');
|
||||
assert(html.includes('id="settingsBtn"')&&html.includes('id="settingsPlayerName"')&&html.includes('id="lightweightRenderingToggle"')&&html.includes('id="soundEnabledToggle"')&&functionSource('saveSettings').includes('/api/cloud/profile')&&functionSource('init').includes('createAutomaticPlayerName'),'Settings-based player naming, rendering, sound, or automatic initial name is missing');
|
||||
assert(functionSource('soundTone').includes('if(!uiSettings.soundEnabled)return')&&functionSource('applyUiSettings').includes("classList.toggle('lightweight-rendering'"),'Settings are not applied to sound and rendering');
|
||||
assert(functionSource('renderBoardNow').includes('st.solvedBy!==LEGACY_LOCAL_SOLVER'),'Legacy 「あなた」 solver labels are still displayed');
|
||||
assert(css.includes('.solver-badge strong{display:block;max-width:100%;white-space:nowrap')&&css.includes('text-overflow:ellipsis'),'Solver names can still wrap repeatedly');
|
||||
assert(functionSource('positionBoardLabel').includes("b.label.style.width=innerWidth+'px'"),'North/south board HUD does not extend to the board edge');
|
||||
assert(css.includes('#customEmojiCursor.flag-cursor img{position:absolute;left:50%;top:50%')&&css.includes('transform:translate(-50%,-50%)'),'Flag cursor is not centered');
|
||||
assert(css.includes('.fps-stat{position:fixed')&&css.includes('background:none')&&css.includes('font-size:8px'),'FPS display is not subtle and background-free');
|
||||
assert(html.includes('長くつながって太くなった線ほど高得点です。'),'Help does not explain long/thick-line scoring');
|
||||
assert(functionSource('rebuildMinimapWorld').includes('drawMapBoardCells')&&!functionSource('drawMapBoardCells').includes('stroke('),'Minimap board outlines are still drawn');
|
||||
assert(functionSource('rebuildWorldOverviewCache').includes('drawMapBoardCells')&&functionSource('rebuildWorldOverviewCache').includes('drawMapLongLines')&&!app.includes('function drawWorldOverviewShops('),'Zoomed-out rendering does not match the minimap');
|
||||
assert(functionSource('maybeGrantGenerationFailureBonus').includes('GENERATION_FAILURE_MIN_MS')&&functionSource('maybeGrantGenerationFailureBonus').includes('unresolvedExpansionCandidates(meta).length'),'Generation failure bonus is not limited to long unresolved expansion attempts');
|
||||
const server=read('server.js');assert(server.includes("row.state.expanded===true")&&server.includes("Expansion already succeeded")&&server.includes('GENERATION_FAILURE_BONUS = 2500'),'Generation bonus server guard or level-6-equivalent amount is missing');
|
||||
const internal=read('docs/internal-system.md');assert(internal.includes('Do not add changelog')&&internal.includes('update the relevant current specification in place'),'Documentation policy does not prohibit update-history documents');
|
||||
for(const name of fs.readdirSync(path.join(root,'docs')))assert(!/(changelog|release|history|update|v\d+|phase\d+)/i.test(name),`Historical update document remains: ${name}`);
|
||||
const BendPuzzle=loadBendPuzzle();let hasLevel10Region=false,hasLevel6Region=false;
|
||||
for(let y=-100;y<=100;y++)for(let x=-100;x<=100;x++){const level=BendPuzzle.macroDifficulty(x,y);if(level>=6)hasLevel6Region=true;if(level===10)hasLevel10Region=true}
|
||||
assert(hasLevel6Region&&hasLevel10Region,'World difficulty map still lacks level 6+ or level 10 regions');
|
||||
const high=BendPuzzle.generatePuzzle([[0,0],[1,0],[0,1]],987654,6,12,-9);
|
||||
assert(high.difficulty===6&&high.complexity.rawRating>=6,'Level-6 regional generation is still discarded or misclassified');
|
||||
assert(functionSource('normalizeStoredPuzzle').includes('solverDifficulty(puzzle,targetLevel)'),'Stored high-level boards lose their regional level after reload');
|
||||
console.log('v47.77 settings, map rendering, generation bonus, docs policy, and high-level generation regression test passed');
|
||||
11
test/v4774-drag-overview-smoke-test.js
Normal file
11
test/v4774-drag-overview-smoke-test.js
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
'use strict';
|
||||
const {assert,functionSource,app,css}=require('./helpers/app-source');
|
||||
assert(functionSource('boardPlayHudVisible').includes('hudBoardId===b?.id')&&functionSource('selectBoard').includes('setActiveBoard(b.id)'),'Board HUD does not persist on the selected board');
|
||||
assert(functionSource('rebuildWorldOverviewCache').includes('{showLevels:true}')&&functionSource('drawMapBoardCells').includes('showLevels&&!solved')&&functionSource('rebuildMinimapWorld').includes('drawMapBoardCells(base,visibleMetas,mapX,mapY,scale)'),'Overview levels are missing or leaked into the minimap');
|
||||
assert(functionSource('directionFromDelta').includes("preferredAxis!=='V'")&&!functionSource('directionFromDelta').includes('largest<smallest*POINTER_DOMINANT_RATIO'),'Diagonal pointer motion is still rejected');
|
||||
assert(functionSource('extendPointerTo').includes('from=boardCellCenter(path.cells[path.cells.length-1])'),'Diagonal traversal still starts from a stale raw pointer location');
|
||||
assert(functionSource('renderDragFrame').includes("setSvgAttr(cache.liveTail,'x2'")&&functionSource('renderDragFrame').includes('cache.geometryRevision!==logicalRevision')&&functionSource('buildDragCache').includes("class:'path drag-path drag-live-tail'"),'Drag renderer still rebuilds the full route every visual frame');
|
||||
assert(functionSource('renderDragFrame').includes('blended=false')&&functionSource('renderBoardNow').includes('blended=!uiSettings.lightweightRendering')&&functionSource('pathColorAtCell').includes('uiSettings.lightweightRendering'),'Lightweight rendering still blends line colors');
|
||||
assert(functionSource('refreshDragNumberColors').includes('drawing.dragNumberKeys')&&!functionSource('refreshDragNumberColors').includes('for(const[key,node]of b.numberNodes'),'Drag number styling still scans every number each step');
|
||||
assert(css.includes('.drag-tip-group{pointer-events:none;will-change:transform;transform-origin:0 0}')&&css.includes('body.lightweight-rendering .path{filter:none!important;mix-blend-mode:normal!important}'),'Drag transform or lightweight path styles are missing');
|
||||
console.log('v47.75 selected HUD, cached overview, diagonal drag, and drag performance regression test passed');
|
||||
12
test/v4775-pan-cursor-performance-smoke-test.js
Normal file
12
test/v4775-pan-cursor-performance-smoke-test.js
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
'use strict';
|
||||
const {assert,functionSource,app}=require('./helpers/app-source');
|
||||
assert(functionSource('boardPlayHudVisible').includes('hudBoardId===b?.id')&&functionSource('selectBoard').includes("classList.add('hud-current')"),'HUD must remain attached to the selected board');
|
||||
assert(functionSource('drawWorldOverview').includes('overviewCache')&&functionSource('positionCachedWorldOverview').includes('overviewCanvas.style.transform')&&functionSource('drawWorldOverview').includes('drawImage(overviewBase'),'Overview panning must move a cached raster layer without viewport-sized frame copies');
|
||||
assert(functionSource('rebuildWorldOverviewCache').includes('drawMapBoardCells'),'Overview cache rebuild is missing');
|
||||
assert(!functionSource('queueRealtimeCursor').includes('schedulePresenceRender'),'Local cursor movement must not repaint the remote-presence canvas');
|
||||
assert(functionSource('applyCamera').includes('shiftOnlineLayersForCamera')&&functionSource('redrawOnlineLayersAfterCamera').includes('schedulePresenceRender'),'Camera movement must transform online layers and repaint only after interaction');
|
||||
assert(!functionSource('applyCamera').includes('queueMicrotask(repositionActiveBoardHud)'), 'Camera frames must not force HUD layout');
|
||||
assert(functionSource('applyCamera').includes('visibilityTimer=setTimeout'),'Board LOD updates must be deferred until interaction settles');
|
||||
assert(app.includes('OVERVIEW_CACHE_OVERSCAN_PX=192'),'Overview cache overscan is missing');
|
||||
assert(!functionSource('visibleMetaIds').includes('ids.add(activeBoard)'),'A far selected board must not enlarge the composited world layer');
|
||||
console.log('v47.75 pan, cursor, overview cache, and HUD regression test passed');
|
||||
25
test/v4776-frame-pipeline-smoke-test.js
Normal file
25
test/v4776-frame-pipeline-smoke-test.js
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
'use strict';
|
||||
const {assert,functionSource,app,css}=require('./helpers/app-source');
|
||||
for(const name of ['scheduleBoardDragFrame','queueCameraInteraction','scheduleWorldOverview','schedulePresenceRender','scheduleReactionRender']){
|
||||
const source=functionSource(name);
|
||||
if(name==='queueCameraInteraction')assert(source.includes('CAMERA_DISPLAY_WATCHDOG_MS')&&source.includes('cancelAnimationFrame')&&source.includes('clearTimeout'),'Camera missed-vsync fallback does not cancel its paired scheduler');
|
||||
else if(name==='scheduleBoardDragFrame')assert(source.includes('DRAG_DISPLAY_WATCHDOG_MS')&&source.includes('cancelAnimationFrame')&&source.includes('clearTimeout'),'Pickup missed-vsync fallback does not cancel its paired scheduler');
|
||||
else if(name!=='scheduleWorldOverview')assert(!source.includes('setTimeout('),`${name} still double-throttles through setTimeout plus requestAnimationFrame`);
|
||||
assert(source.includes('requestAnimationFrame'),`${name} must remain frame-synchronized`);
|
||||
}
|
||||
const minimap=functionSource('scheduleMinimap');
|
||||
assert(minimap.includes("classList?.contains?.('is-interacting')"),'Minimap must not repaint during an active gesture');
|
||||
const overview=functionSource('drawWorldOverview');
|
||||
assert(functionSource('positionCachedWorldOverview').includes('overviewCanvas.style.transform'),'Overview panning must move the cached bitmap as a compositor layer');
|
||||
assert(functionSource('scheduleWorldOverview').includes('requestIdleCallback'),'Overview cache rebuilds must be deferred to idle time');
|
||||
assert(overview.includes('overviewBitmapCopies'),'Overview bitmap copy must only occur during cache rebuild');
|
||||
assert(!overview.includes('sourceX='),'Overview must not copy a viewport-sized sub-rectangle every pan frame');
|
||||
const camera=functionSource('applyCamera');
|
||||
assert(camera.includes('translate3d('),'Nearby field panning must use a compositor transform');
|
||||
assert(camera.includes('shiftOnlineLayersForCamera'),'Online canvases must move without full repaint during camera gestures');
|
||||
assert(camera.includes('minimapDirty=true'),'Camera movement must mark, not immediately repaint, the minimap');
|
||||
assert(functionSource('boardPlayHudVisible').includes('hudBoardId===b?.id'),'Selected-board HUD persistence changed unexpectedly');
|
||||
assert(css.includes('#viewport.panning::after{display:none}'),'Full-screen vignette must be suppressed while panning');
|
||||
assert(css.includes('#presenceCanvas,#reactionCanvas{transform-origin:0 0'),'Online canvases are not compositor-ready');
|
||||
assert(app.includes("const APP_VERSION='47.77'"),'Application version was not advanced');
|
||||
console.log('v47.77 compositor frame-pipeline regression test passed');
|
||||
20
test/v4777-hud-input-performance-smoke-test.js
Normal file
20
test/v4777-hud-input-performance-smoke-test.js
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
'use strict';
|
||||
const {assert,functionSource,app,css}=require('./helpers/app-source');
|
||||
const hud=functionSource('boardPlayHudVisible'),select=functionSource('selectBoard'),center=functionSource('centerMeta');
|
||||
assert(hud.includes('hudBoardId===b?.id'),'HUD must use explicit session selection, not drag/claim state');
|
||||
assert(select.includes('hudBoardId=b.id')&&select.includes("classList.add('hud-current')"),'Click selection must persist the board HUD');
|
||||
assert(center.includes('select=true')&&app.includes("centerMeta(initialMeta||data.metas.B0,{select:false})"),'Startup camera centering must not select an untouched board');
|
||||
assert(functionSource('resetSelectedBoard').includes('rendered.get(hudBoardId)'),'Reset must target the persistent HUD selection');
|
||||
assert(!css.includes("body.is-drawing .board-card:not(.input-active) .static-layer"),'Non-selected boards must not be dimmed while drawing');
|
||||
const sanitize=functionSource('sanitizeStateForPuzzle');
|
||||
assert(sanitize.includes('path.detachedStart&&path.endGate==null')&&sanitize.includes('warpKeys.has'),'Loose lines with both knobs on warp cells must be removed');
|
||||
const realtime=functionSource('queueRealtimeCursor');
|
||||
assert(realtime.includes("classList.contains('is-drawing')")&&realtime.includes('realtimePendingCursorClient')&&realtime.indexOf('worldUnitAtClient')>realtime.indexOf('setTimeout'),'Raw pointer events must not perform realtime world conversion or send during drawing');
|
||||
const custom=functionSource('updateCustomCursorFromPointer');
|
||||
assert(custom.includes("dataset.cursorMode==='dom'")&&custom.includes('requestAnimationFrame')&&!custom.includes('FRAME_INTERVAL'),'DOM cursor fallback must remain visible during drag and commit at display cadence');
|
||||
assert(functionSource('syncCursorAppearance').includes('nativeSupported')&&css.includes('data-cursor-mode="native"'),'Static cursor skins must prefer the native cursor path');
|
||||
const drag=functionSource('extendPointerTo'),render=functionSource('renderDragFrame');
|
||||
assert(drag.includes('lastModelProbeKey')&&drag.includes('probeStep=6'),'Drag model work must be quantized instead of repeated for every raw move');
|
||||
assert(render.includes('blended=false')&&render.includes('lastRenderedTip')&&render.includes('setSvgAttr'),'Live drag rendering must avoid gradients and redundant SVG writes');
|
||||
assert(app.includes("const APP_VERSION='47.77'"),'Application version was not advanced');
|
||||
console.log('v47.77 HUD, warp cleanup, cursor, and drag performance regression test passed');
|
||||
144
test/v4778-interaction-scheduler-smoke-test.js
Normal file
144
test/v4778-interaction-scheduler-smoke-test.js
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
'use strict';
|
||||
const {assert,functionSource,app,css,vm}=require('./helpers/app-source');
|
||||
const fs=require('fs'),path=require('path'),browserBenchmark=fs.readFileSync(path.join(__dirname,'browser-performance-benchmark.js'),'utf8');
|
||||
|
||||
assert(app.includes('AUXILIARY_FPS=30')&&app.includes('DRAG_TARGET_FPS=60')&&app.includes('DRAG_FRAME_INTERVAL=1000/DRAG_TARGET_FPS')&&app.includes('CAMERA_DISPLAY_WATCHDOG_MS=18'),'Interaction and auxiliary frame budgets are not separated');
|
||||
assert(app.includes('BEND_INTERACTION_SCHEDULER')&&app.includes('bend-field-interaction-scheduler-variant')&&app.includes('batteryDischargingTime'),'Scheduler rollout variant or battery telemetry guardrail is missing');
|
||||
assert(app.includes('DRAG_MAX_CATCHUP_CELLS=24')&&functionSource('extendPointerTo').includes('catchupPending')&&functionSource('processBoardDragFrame').includes('catchupPending'),'Large pointer jumps are not bounded and resumed across drag frames');
|
||||
|
||||
const cursor=functionSource('updateCustomCursorFromPointer'),cursorSync=functionSource('syncCursorAppearance');
|
||||
assert(cursor.includes('requestAnimationFrame')&&!cursor.includes('FRAME_INTERVAL'),'DOM cursor fallback is not synchronized to every display frame');
|
||||
assert(app.includes("addEventListener('pointermove',updateCustomCursorFromPointer")&&app.includes("addEventListener('pointerrawupdate',updateCustomCursorFromPointer"),'DOM cursor does not tolerate sparse raw-input delivery');
|
||||
assert(cursor.includes('customCursorActiveUntil=perfNow()+40')&&cursor.includes('customCursorFrame=requestAnimationFrame(step)')&&cursor.includes('Math.exp(-elapsed/8)'),'DOM cursor does not keep and smooth its display-rAF loop between active pointer events');
|
||||
assert(!cursor.includes("classList.contains('is-drawing')")&&!css.includes('body.is-drawing #customEmojiCursor'),'Custom cursor is hidden during pickup dragging');
|
||||
assert(cursorSync.includes('nativeSupported')&&cursorSync.includes('--active-native-cursor')&&css.includes('data-cursor-mode="native"'),'Native cursor assets or the DOM fallback switch are missing');
|
||||
|
||||
const cursorClasses=new Set(),cursorFrames=[],cursorRafQueue=[],cursorTransforms=[],cursorPerfSamples=new Map();
|
||||
let cursorClock=0,cursorRafId=0;
|
||||
const cursorContext={
|
||||
document:{body:{dataset:{cursorMode:'dom'}}},
|
||||
customEmojiCursor:{classList:{contains:name=>cursorClasses.has(name),add:name=>cursorClasses.add(name),remove:name=>cursorClasses.delete(name)},style:{set transform(value){cursorTransforms.push(value)}}},
|
||||
perfNow:()=>cursorClock,
|
||||
requestAnimationFrame:callback=>{cursorRafQueue.push(callback);return++cursorRafId},
|
||||
markVisualFrame:timestamp=>cursorFrames.push(timestamp),
|
||||
perfObserve:(name,value)=>{const samples=cursorPerfSamples.get(name)||[];samples.push(value);cursorPerfSamples.set(name,samples);return value},
|
||||
perfCount:()=>{}
|
||||
};
|
||||
vm.createContext(cursorContext);
|
||||
vm.runInContext(`const interactionCommitAt=Object.create(null),interactionInputAt=Object.create(null);${functionSource('recordInteractionCommit')}let customCursorFrame=0,customCursorX=0,customCursorY=0,customCursorInputAt=0,customCursorActiveUntil=0,customCursorRenderedX=NaN,customCursorRenderedY=NaN,customCursorLastFrameAt=0,customCursorInputRevision=0,customCursorCommittedRevision=0;${cursor};this.updateCustomCursorFromPointer=updateCustomCursorFromPointer;`,cursorContext);
|
||||
let cursorEventAt=0;
|
||||
for(let frameAt=1000/60;frameAt<=1100;frameAt+=1000/60){
|
||||
while(cursorEventAt<=1000&&cursorEventAt<=frameAt+.001){
|
||||
cursorClock=cursorEventAt;cursorContext.updateCustomCursorFromPointer({clientX:cursorEventAt,clientY:cursorEventAt/2,timeStamp:cursorEventAt||.001,pointerType:'mouse',target:{isConnected:true}});
|
||||
cursorEventAt+=1000/30;
|
||||
}
|
||||
cursorClock=frameAt;
|
||||
for(const callback of cursorRafQueue.splice(0))callback(frameAt);
|
||||
}
|
||||
const cursorFrameGaps=cursorFrames.slice(1).map((timestamp,index)=>timestamp-cursorFrames[index]).sort((a,b)=>a-b),
|
||||
cursorMedianGap=cursorFrameGaps[Math.floor((cursorFrameGaps.length-1)*.5)],
|
||||
sortedCursorAges=[...(cursorPerfSamples.get('cursorInputAge')||[])].sort((a,b)=>a-b),cursorP95Age=sortedCursorAges[Math.floor((sortedCursorAges.length-1)*.95)],
|
||||
measuredCursorGaps=[...(cursorPerfSamples.get('cursorFrameGap')||[])].sort((a,b)=>a-b),measuredCursorMedianGap=measuredCursorGaps[Math.floor((measuredCursorGaps.length-1)*.5)],
|
||||
changedCursorTransforms=cursorTransforms.filter((value,index)=>index===0||value!==cursorTransforms[index-1]).length;
|
||||
assert(cursorFrames.length>=60&&cursorMedianGap<=20,`Warm DOM cursor loop produced ${cursorFrames.length} frames with a ${cursorMedianGap} ms median gap`);
|
||||
assert(measuredCursorGaps.length>=30&&measuredCursorMedianGap<=20,`Production cursor instrumentation captured ${measuredCursorGaps.length} gaps with a ${measuredCursorMedianGap} ms median`);
|
||||
assert(changedCursorTransforms>=55,`Warm DOM cursor loop changed its transform on only ${changedCursorTransforms} display frames`);
|
||||
assert(sortedCursorAges.length>=25,`Production cursor instrumentation captured only ${sortedCursorAges.length} fresh-input latency samples`);
|
||||
assert(cursorP95Age<25,`Warm DOM cursor loop produced ${cursorP95Age} ms p95 input age`);
|
||||
|
||||
const cameraQueue=functionSource('queueCameraInteraction'),cameraApply=functionSource('applyCamera');
|
||||
assert(cameraQueue.includes('pendingCameraInteraction=next')&&cameraQueue.includes('requestAnimationFrame')&&cameraQueue.includes('CAMERA_DISPLAY_WATCHDOG_MS')&&cameraQueue.includes('cancelAnimationFrame')&&cameraQueue.includes('cameraInteractionLastDraw+DRAG_FRAME_INTERVAL'),'Camera does not use a latest-input 60 Hz queue with a missed-vsync watchdog');
|
||||
assert(cameraApply.includes('positionCachedWorldOverview')&&functionSource('positionCachedWorldOverview').includes('translate3d'),'Overview position is not updated in the camera fast path');
|
||||
assert(functionSource('scheduleWorldOverview').includes('requestIdleCallback')&&functionSource('scheduleWorldOverview').includes("classList.contains('is-interacting')"),'Overview rebuilds are not idle and interaction-safe');
|
||||
|
||||
const dragSchedule=functionSource('scheduleBoardDragFrame'),dragFrame=functionSource('processBoardDragFrame');
|
||||
assert(dragSchedule.includes('requestAnimationFrame')&&dragSchedule.includes('DRAG_DISPLAY_WATCHDOG_MS')&&dragSchedule.includes('cancelAnimationFrame')&&dragSchedule.includes('clearTimeout')&&dragSchedule.includes('b.dragVisualLastFrameAt+DRAG_FRAME_INTERVAL')&&dragSchedule.includes('INTERACTION_FRAME_TOLERANCE_MS')&&dragFrame.includes('DRAG_FRAME_INTERVAL'),'Pickup visuals or logic are not capped to their 60 Hz lane');
|
||||
assert(functionSource('queueBoardPointerMove').includes('appendBoardPointerSamples')&&functionSource('queueBoardPointerMove').includes('commitBoardDragFromInputDeadline')&&functionSource('queueBoardPointerMove').includes('scheduleBoardDragFrame')&&!functionSource('queueBoardPointerMove').includes('updatePickupHandleOverlay')&&!functionSource('queueBoardPointerMove').includes('markVisualFrame')&&!functionSource('queueBoardPointerMove').includes('extendPointerTo'),'Pointer events mutate presentation or model work outside the capped scheduler');
|
||||
assert(functionSource('commitBoardDragFromInputDeadline').includes('b.dragVisualLastFrameAt+DRAG_FRAME_INTERVAL')&&functionSource('commitBoardDragFromInputDeadline').includes('INTERACTION_FRAME_TOLERANCE_MS')&&functionSource('commitBoardDragFromInputDeadline').includes('processBoardDragFrame(b,now)'),'Pickup input deadline fallback is not governed by the 60 Hz presentation ceiling');
|
||||
assert(!app.includes('DRAG_INPUT_RESCUE_MS')&&!app.includes('function rescuePickupVisualFromInput('),'An input-rate pickup presentation path can still exceed 60 Hz');
|
||||
assert(functionSource('setBoardPointerSample').includes('dragVisualActiveUntil=perfNow()+40')&&dragFrame.includes('Math.exp(-visualElapsed/8)')&&dragFrame.includes('updateDrawingHandlePosition')&&dragFrame.includes('perfNow()<b.dragVisualActiveUntil'),'Pickup visual lane does not remain warm and ease toward sparse pointer samples at display cadence');
|
||||
assert(functionSource('renderDragFrame').includes('translate3d')&&functionSource('renderDragFrame').includes('tailNow-drawing.lastTailRenderAt>=32'),'Complex pickup handles are not compositor-driven or the SVG live tail is repainted every display frame');
|
||||
assert(dragFrame.includes('updatePickupHandleOverlay')&&dragFrame.includes('usesLightweightDragOverlay')&&dragFrame.includes('dirtyBoards.delete(b)')&&app.includes('LIGHTWEIGHT_DRAG_BOARD_CELLS=120')&&functionSource('updatePickupHandleOverlay').includes('translate3d')&&css.includes('#pickupHandleOverlay{position:fixed'),'Large-board pickup still repaints the SVG during the gesture instead of using the lightweight display-rate handle');
|
||||
assert(functionSource('activateBoardPointerDrag').includes('setPickupScenePresentation(b,true)')&&functionSource('setLightweightDragPresentation').includes("style.opacity=next?'0':''")&&functionSource('setPickupScenePresentation').includes('setLightweightDragPresentation(board,next&&board===activeBoard)')&&functionSource('scheduleInteractionSettlePresentation').includes('setPickupScenePresentation(null,false)'),'Active large-board decorative layers are not compositor-suppressed during pickup and restored after settlement');
|
||||
assert(dragFrame.includes("recordInteractionCommit('pickupVisual'")&&dragFrame.includes('workDuration'),'Pickup cadence, input age, or callback work is not instrumented');
|
||||
assert(dragFrame.includes('timestamp<=b.dragVisualActiveUntil')&&dragFrame.includes("recordInteractionCommit('pickupVisual',timestamp,move.inputAt,false)")&&css.includes('transition:transform 16.67ms linear'),'Pickup sample gaps are not compositor-interpolated or warm visual ticks are not measured');
|
||||
assert(dragFrame.includes('freshVisualInput')&&dragFrame.includes('freshLogicalInput'),'Pickup latency instrumentation resamples stale input on warm display frames');
|
||||
assert(app.includes('DRAG_MAX_LOGICAL_SAMPLES_PER_FRAME=4')&&app.includes('DRAG_MODEL_BUDGET_MS=.5')&&dragFrame.includes('b.pointerMoveSamples.shift()')&&dragFrame.includes('perfNow()-modelStarted<DRAG_MODEL_BUDGET_MS')&&dragFrame.includes('logicalSlotDue&&(freshLogicalInput||velocity[0]||velocity[1]||b.drawing?.catchupPending||b.pointerMoveSamples?.length)'),'Pickup logic does not time-bound ordered-sample catch-up or warm frames still run model traversal without pending work');
|
||||
assert(functionSource('setBoardPointerSample').includes('trimBoardPointerSamples(samples)')&&functionSource('trimBoardPointerSamples').includes('leastTurn')&&functionSource('trimBoardPointerSamples').includes('samples.splice(removeIndex,1)'),'Bounded pickup input overflow does not preserve sharp turns');
|
||||
assert(dragFrame.includes('pointerInsideBoardScreen(b,logicalMove)?eventToSvg')&&functionSource('pointerInsideBoardScreen').includes('boardScreenRect'),'Off-board edge panning can still traverse and prematurely finish the pickup path');
|
||||
assert(dragFrame.includes("perfEnd('pickupModelWork'")&&dragFrame.includes("perfEnd('pickupVisualWork'"),'Pickup model and visual callback costs are not independently instrumented');
|
||||
assert(functionSource('clearDragRender').includes("style.display='none'")&&!functionSource('clearDragRender').includes('replaceChildren'),'Drag cache nodes are destroyed between gestures');
|
||||
|
||||
const bind=functionSource('bindBoard');
|
||||
assert(bind.indexOf('beginPendingClaimPointer')<bind.indexOf('await ensureBoardClaimForInput'),'Shared-session pickup preview does not start before the ownership round-trip');
|
||||
assert(bind.includes('updatePendingClaimPointer')&&bind.includes('b.pendingClaimPointer?.pointerId')&&functionSource('updatePendingClaimPointer').includes('pending.samples')&&functionSource('updatePendingClaimPointer').includes('trimBoardPointerSamples'),'Pending claim movement, turns, or release are not buffered');
|
||||
assert(bind.includes('activateBoardPointerDrag')&&functionSource('activateBoardPointerDrag').includes('cancelBoardDragFrame(b)')&&functionSource('activateBoardPointerDrag').includes('for(const sample of samples)setBoardPointerSample')&&functionSource('activateBoardPointerDrag').includes('scheduleBoardDragFrame(b)'),'Approved pending claims do not enter the ordered drag fast path');
|
||||
assert(functionSource('selectBoard').includes('alreadySelected')&&functionSource('renderDragFrame').includes('pickupStartFullBoardRenders'),'Pickup start does not avoid and measure full-board redraws');
|
||||
assert(functionSource('applyBoardCommand').includes('pointerInteraction&&b.drawing?.pointerId==null')&&bind.includes('b.drawing=null;b.armedGate=null;refreshInteractionState()')&&bind.includes('scheduleBoardCommandSettlement(b,{paint:true,invalidate:true,pathIndex:finishedPathIndex})'),'Pickup completion does not end interaction state before deferring broad render and cache work');
|
||||
assert(functionSource('safeRelease').includes('scheduleInteractionSettlePresentation')&&!functionSource('safeRelease').includes('queueMicrotask')&&functionSource('scheduleInteractionSettlePresentation').includes('requestAnimationFrame')&&!functionSource('scheduleInteractionSettlePresentation').includes('ensureBoards()'),'Pickup release still runs broad settlement work inside the pointer task');
|
||||
assert(functionSource('finalizeAtGate').includes('deferSettlement:true')&&functionSource('scheduleBoardCommandSettlement').includes('requestAnimationFrame')&&functionSource('scheduleBoardCommandSettlement').includes("classList.contains('is-interacting')"),'Pickup completion still performs render, persistence, or solve settlement inside the active drag callback');
|
||||
assert(functionSource('finalizeAtGate').includes('usesLightweightDragOverlay(b)')&&functionSource('finalizeAtGate').includes('requestAnimationFrame(()=>gateConnectEffect'),'Large-board gate decoration still mutates the SVG inside the finishing interaction task');
|
||||
|
||||
assert(functionSource('runDeferredSave').includes("classList?.contains('is-interacting')"),'Ordinary persistence is not deferred during interactions');
|
||||
assert(functionSource('applyWorldSignal').includes('await waitForInteractionSettle()')&&functionSource('pullCloudWorld').includes('await waitForInteractionSettle()'),'Cross-tab or cloud reconciliation can still run broad refresh work during a gesture');
|
||||
assert(functionSource('createPuzzleWorker').includes("perfCount('workerResultsDeferredDuringInteraction')")&&functionSource('createPuzzleWorker').includes('waitForInteractionSettle().then(deliver)'),'Puzzle-worker promise continuations can still run during an active gesture');
|
||||
assert(functionSource('desiredInteractiveBoardIds').includes('for(const id of ids)')&&!app.includes('INTERACTIVE_DETAIL_CELL_BUDGET')&&!functionSource('ensureBoards').includes('makeStaticBoard(meta)'),'Visible puzzles can still be hidden behind a clicked-only summary LOD');
|
||||
assert(functionSource('observeInteractionFrame').includes('workDuration>slowWorkThreshold')&&!functionSource('observeInteractionFrame').includes('elapsed>24'),'Quality fallback still mistakes frame spacing for callback overload');
|
||||
assert(functionSource('refreshInteractionState').includes('interactionQualityDowngradePending'),'Quality changes are not deferred until the gesture ends');
|
||||
assert(functionSource('refreshInteractionState').includes("topbar?.classList.toggle('drawing-active'")&&functionSource('refreshInteractionState').includes("world?.classList.toggle('camera-interacting'")&&functionSource('refreshInteractionState').includes('!usesLightweightDragOverlay(drawingBoard)')&&!css.includes('body.is-interacting .gate-dot')&&!css.includes('body.is-drawing #topbar'),'Pickup styling still invalidates the entire document or a lightweight large-board subtree');
|
||||
assert(functionSource('recordInteractionCommit').includes('InputAge')&&functionSource('observeInteractionFrame').includes('interactionDroppedFrameRatio')&&app.includes("perfCount('longTasks')"),'Interaction latency, dropped-frame, or long-task diagnostics are missing');
|
||||
assert(functionSource('ensureBoards').includes("classList?.contains?.('is-interacting')")&&functionSource('ensureBoards').includes('lodPassesDeferredDuringInteraction'),'Detailed and overview LOD work is not deferred during gestures');
|
||||
|
||||
assert(functionSource('drawingForPath').includes('pathCellIndex:new Map')&&functionSource('extendOne').includes('pathCellIndex?.set'),'Long pickup paths do not retain a cell index');
|
||||
assert(functionSource('rewindActivePathToCell').includes('index===path.cells.length-1)return false'),'A pickup hovering on its current tip is incorrectly classified as a geometry-changing rewind');
|
||||
assert(functionSource('makeBoard').includes('gateIndexesByCell')&&functionSource('gateCandidatesInCell').includes('gateIndexesByCell'),'Gate lookup is not indexed by cell');
|
||||
assert(functionSource('makeBoard').includes('endpointIndexesByCell')&&functionSource('nearestEndpointAtPoint').includes('endpointCandidatesNearPoint'),'Open endpoint lookup is not indexed by cell');
|
||||
assert(app.includes('REALTIME_CURSOR_INTERVAL=50')&&functionSource('queueRealtimeCursor').includes('vx:')&&functionSource('applyRemotePlayer').includes('lead=.035'),'Remote cursors are not sent at 20 Hz with velocity-based short extrapolation');
|
||||
for(const probe of['measureDisplayCadence','measureCursorCadence','claimLatencyProbe','pickupEdgePanProbe','pinchZoomProbe','panCadenceProbe'])
|
||||
assert(browserBenchmark.includes(`function ${probe}`)||browserBenchmark.includes(`function ${probe}(`),`Browser performance matrix is missing ${probe}`);
|
||||
assert(browserBenchmark.includes('--force-device-scale-factor=1'),'Browser performance matrix must normalize device scale for stable display-cadence measurements');
|
||||
const pickupCadenceProbeSource=browserBenchmark.slice(browserBenchmark.indexOf('async function pickupCadenceProbe'),browserBenchmark.indexOf('async function claimLatencyProbe'));
|
||||
assert(pickupCadenceProbeSource.includes("deepClone(metaState('B0'))")&&pickupCadenceProbeSource.includes('data.states.B0=')&&pickupCadenceProbeSource.includes("persistNow({skipCloud:true})")&&pickupCadenceProbeSource.includes("snapshot:await client.evaluate('BEND_PERF.snapshot()')"),'Continuous pickup benchmark does not capture clean cadence data and restore its original board state');
|
||||
assert(browserBenchmark.includes('async function restoreOriginState')&&browserBenchmark.includes('replaceObjectContents(current,deepClone(snapshot))')&&browserBenchmark.includes('setTimeout(resolve,80)')&&browserBenchmark.includes("applySnapshot();changed('B0')")&&browserBenchmark.includes("centerMeta(data.metas.B0,{select:false})")&&browserBenchmark.match(/restoreOriginState\(client,pristineOriginState\)/g)?.length>=4,'Browser matrix does not settle, revision-stamp, and canonically reset origin state and camera between pickup probes');
|
||||
assert(browserBenchmark.includes('pickup probes did not restore a pristine origin board')&&browserBenchmark.includes("metaState('B0').solved===true"),'Browser matrix does not verify scenario isolation and solve completion before panning');
|
||||
assert(browserBenchmark.includes("spawnSync(taskkill,['/pid',String(child.pid),'/T','/F']")&&browserBenchmark.includes('stopBrowserTree(edge)')&&browserBenchmark.includes('fs.rmSync(temporaryRoot,{recursive:true,force:true})'),'Browser benchmark does not guarantee process-tree and temporary-profile cleanup');
|
||||
for(const profile of['small','medium','large','complex','long-line'])assert(browserBenchmark.includes(`name:'${profile}'`),`Browser benchmark profile ${profile} is missing`);
|
||||
const longCells=Array.from({length:250},(_,index)=>[0,index]),longPath={startGate:0,endGate:null,openGate:null,cells:longCells.map(cell=>[...cell])},
|
||||
longState={paths:[longPath]},longOccupancy=new Map(longCells.map(cell=>[`${cell[0]},${cell[1]}`,0])),longDrawing={pathIndex:0,pointerId:1,pathCellIndex:new Map(longCells.map((cell,index)=>[`${cell[0]},${cell[1]}`,index]))},
|
||||
longBoard={id:'L',p:{g:Array.from({length:100},()=>[0,0,'N'])},drawing:longDrawing,crossingKeySet:new Set()},longContext={
|
||||
activePath:()=>longPath,ckey:(r,c)=>`${r},${c}`,warpPairForCell:()=>null,isWarpTransition:()=>false,pathIndexesAtCell:()=>[],metaState:()=>longState,
|
||||
applyCalls:0,applyBoardCommand:(_board,mutate)=>{longContext.applyCalls++;mutate();return true}
|
||||
};
|
||||
vm.createContext(longContext);vm.runInContext(`${functionSource('rewindActivePathToCell')}\nthis.rewindActivePathToCell=rewindActivePathToCell;`,longContext);
|
||||
assert(longContext.rewindActivePathToCell(longBoard,[0,10],longOccupancy)&&longPath.cells.length===11&&longDrawing.pathCellIndex.size===11&&longOccupancy.size===11&&longContext.applyCalls===1,'A 250-cell pickup does not rewind with one suffix splice');
|
||||
|
||||
const traversalContext={PAD:0,CELL:1,ckey:(r,c)=>`${r},${c}`};vm.createContext(traversalContext);
|
||||
vm.runInContext(`${functionSource('cellsCrossedBySegment')}\nthis.cellsCrossedBySegment=cellsCrossedBySegment;`,traversalContext);
|
||||
const validLongCells=new Set(longCells.map(([r,c])=>`${r},${c}`)),forward=traversalContext.cellsCrossedBySegment([.5,.5],[249.5,.5],{preferredAxis:'H',validCells:validLongCells});
|
||||
assert(forward.length===249&&forward[248][1]===249,'A 250-cell pointer segment does not preserve every forward grid cell');
|
||||
let remainingCatchup=forward.length,batches=0,maxBatch=0;while(remainingCatchup){const batch=Math.min(24,remainingCatchup);maxBatch=Math.max(maxBatch,batch);remainingCatchup-=batch;batches++}
|
||||
assert(maxBatch===24&&batches===11,'A 250-cell catch-up is not bounded to constant-size frame batches');
|
||||
|
||||
const endpointState={paths:Array.from({length:100},(_,index)=>({endGate:null,detachedStart:index%2===0,cells:[[0,index],[1,index]]}))},
|
||||
endpointContext={Map,ckey:(r,c)=>`${r},${c}`,metaState:()=>endpointState};
|
||||
vm.createContext(endpointContext);vm.runInContext(`${functionSource('rebuildEndpointIndexes')}\nthis.rebuildEndpointIndexes=rebuildEndpointIndexes;`,endpointContext);
|
||||
const endpointBoard={id:'E',endpointIndexesByCell:new Map()},endpointIndex=endpointContext.rebuildEndpointIndexes(endpointBoard);
|
||||
assert(endpointIndex.size===150&&endpointIndex.get('1,99')[0].index===99,'The 100-path endpoint index is incomplete');
|
||||
|
||||
const remoteContext={Number,Map,realtimePresenceId:null,remotePlayers:new Map(),schedulePresenceRender:()=>{},scheduleMinimap:()=>{}};
|
||||
vm.createContext(remoteContext);vm.runInContext(`${functionSource('applyRemotePlayer')}\nthis.applyRemotePlayer=applyRemotePlayer;`,remoteContext);
|
||||
assert(remoteContext.applyRemotePlayer({presenceId:'R',x:10,y:20,vx:2,vy:-1,sentAt:123,name:'R'}),'Remote cursor sample was rejected');
|
||||
const remoteSample=remoteContext.remotePlayers.get('R');
|
||||
assert(Math.abs(remoteSample.targetX-10.07)<1e-9&&Math.abs(remoteSample.targetY-19.965)<1e-9&&remoteSample.sentAt===123,'Remote cursor velocity extrapolation is incorrect');
|
||||
|
||||
for(const refreshRate of[60,120,144]){
|
||||
const displayInterval=1000/refreshRate,visualCommits=[],logicalCommits=[];let nextVisual=0,nextLogical=0;
|
||||
for(let timestamp=0;timestamp<1000;timestamp+=displayInterval){
|
||||
if(!nextVisual||timestamp+.01>=nextVisual){visualCommits.push(timestamp);do nextVisual=(nextVisual||timestamp)+1000/60;while(nextVisual<=timestamp+.01)}
|
||||
if(!nextLogical||timestamp+2>=nextLogical){logicalCommits.push(timestamp);do nextLogical=(nextLogical||timestamp)+1000/60;while(nextLogical<=timestamp+2)}
|
||||
}
|
||||
assert(visualCommits.length>=59&&visualCommits.length<=61,`${refreshRate} Hz display produced ${visualCommits.length} pickup presentation frames instead of about 60`);
|
||||
assert(logicalCommits.length>=59&&logicalCommits.length<=61,`${refreshRate} Hz display does not retain a 60 Hz pickup logic lane`);
|
||||
}
|
||||
|
||||
console.log('Display-rate cursor/pan and 60 Hz pickup scheduler regression test passed');
|
||||
Loading…
Add table
Add a link
Reference in a new issue