d
This commit is contained in:
parent
c3a6f5ff37
commit
4c4e767ec6
73 changed files with 3502 additions and 741 deletions
|
|
@ -13,7 +13,7 @@ const browserPath=process.env.BEND_FIELD_BROWSER_PATH||process.env.BEND_FIELD_ED
|
|||
);
|
||||
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 worldDbName='bend-field:v30:linkfield-single-world-20260801: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){
|
||||
|
|
|
|||
|
|
@ -12,9 +12,12 @@ 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 effectVisualFixture=JSON.parse(fs.readFileSync(path.join(root,'test','fixtures','effect-visual-checkpoints.json'),'utf8'));
|
||||
const benchmarkOutputPath=process.env.BEND_FIELD_BENCHMARK_OUTPUT||path.join(root,'test-results','browser-performance-benchmark.json');
|
||||
function writeBenchmarkReport(report){fs.mkdirSync(path.dirname(benchmarkOutputPath),{recursive:true});fs.writeFileSync(benchmarkOutputPath,`${JSON.stringify(report,null,2)}\n`)}
|
||||
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 worldDbName='bend-field:v30:linkfield-single-world-20260801:world';
|
||||
const allProfiles=[
|
||||
{name:'small',boards:16,mode:'grid'},
|
||||
{name:'medium',boards:128,mode:'grid'},
|
||||
|
|
@ -278,10 +281,10 @@ async function panCadenceProbe(client,steps=24){
|
|||
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)});
|
||||
applyRealtimeReaction({id:'benchmark-reaction',emoji:REACTION_EMOJIS[0],style:'firework',x:worldX,y:worldY,createdAt:reactionNow,expiresAt:reactionNow+Math.max(3400,${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)}};
|
||||
const tick=()=>{index++;dispatch('pointermove',index,2);if(index<${steps})requestAnimationFrame(tick);else{dispatch('pointerup',index,0);setTimeout(()=>{remotePlayers.delete(remoteId);realtimeReactions.delete('benchmark-reaction');resolve(index)},80)}};
|
||||
requestAnimationFrame(tick);
|
||||
})`);
|
||||
}
|
||||
|
|
@ -295,7 +298,9 @@ async function pinchZoomProbe(client){
|
|||
})`);
|
||||
}
|
||||
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}"]`),
|
||||
const originalState=await client.evaluate("deepClone(metaState('B0'))");
|
||||
await client.evaluate("(()=>{const r=document.querySelector('#viewport').getBoundingClientRect(),p=worldUnitAtClient(r.left+r.width/2,r.top+r.height/2),now=trustedNow();applyRealtimeReaction({id:'benchmark-edge-pan-effect',emoji:REACTION_EMOJIS[0],style:'comet',x:p[0],y:p[1],createdAt:now,expiresAt:now+12000});return true})()");
|
||||
const 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});
|
||||
|
|
@ -306,7 +311,7 @@ async function pickupEdgePanProbe(client,pathRow){
|
|||
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(`(()=>{realtimeReactions.delete('benchmark-edge-pan-effect');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})()");
|
||||
}
|
||||
}
|
||||
|
|
@ -314,6 +319,7 @@ 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.evaluate(`(()=>{const p=worldUnitAtClient(${start.x},${start.y}),now=trustedNow();applyRealtimeReaction({id:'benchmark-pickup-effect',emoji:REACTION_EMOJIS[0],style:'firework',x:p[0],y:p[1],createdAt:now,expiresAt:now+${Math.max(12000,steps*sampleDelay*2)}});return true})()`);
|
||||
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'});
|
||||
|
|
@ -327,7 +333,7 @@ async function pickupCadenceProbe(client,pathRow,steps=120,sampleDelay=8){
|
|||
}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(`(()=>{realtimeReactions.delete('benchmark-pickup-effect');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})()");
|
||||
}
|
||||
}
|
||||
|
|
@ -398,12 +404,89 @@ async function measureCursorCadence(client,steps=180){
|
|||
const snapshot=BEND_PERF.snapshot(),commits=globalThis.__benchmarkCursorCommits||[],gaps=commits.slice(1).map((entry,index)=>entry.timestamp-commits[index].timestamp);
|
||||
return{snapshot,diagnostic:{interval:DRAG_FRAME_INTERVAL,tolerance:INTERACTION_FRAME_TOLERANCE_MS,commitCount:commits.length,gaps:gaps.slice(0,20),lastDraw:customCursorLastDraw,inputRevision:customCursorInputRevision,committedRevision:customCursorCommittedRevision}};
|
||||
})()`),snapshot=measured.snapshot,gap=timing(snapshot,'cursorFrameGap'),age=timing(snapshot,'cursorInputAge');
|
||||
assert(gap.count>=30&&gap.p50<=20,`DOM cursor cadence missed acceptance: ${JSON.stringify({gap,diagnostic:measured.diagnostic})}`);
|
||||
assert(age.count>=25&&age.p95<30,`DOM cursor input age missed acceptance: ${JSON.stringify(age)}`);
|
||||
assert(gap.count>=6&&gap.p50<=40,`DOM cursor cadence missed the capped 30 FPS acceptance: ${JSON.stringify({gap,diagnostic:measured.diagnostic})}`);
|
||||
assert(age.count>=8&&age.p95<45,`DOM cursor input age missed acceptance: ${JSON.stringify(age)}`);
|
||||
return snapshot;
|
||||
}finally{await client.evaluate("if(globalThis.__benchmarkCommitCustomCursorFrame)commitCustomCursorFrame=globalThis.__benchmarkCommitCustomCursorFrame;delete globalThis.__benchmarkCommitCustomCursorFrame;delete globalThis.__benchmarkCursorCommits;localStorage.removeItem('bend-field-cursor-renderer');syncCursorAppearance('default');true")}
|
||||
}
|
||||
|
||||
async function measureEffectsAndCosmetics(client,{cpuRate=1,visual=true,inventory=true,aurora=true,memory=true,singles=true}={}){
|
||||
await client.send('Emulation.setCPUThrottlingRate',{rate:cpuRate});
|
||||
return client.evaluate(`(async()=>{
|
||||
const options=${JSON.stringify({cpuRate,visual,inventory,aurora,memory,singles,visualCases:effectVisualFixture.cases,visualWidth:effectVisualFixture.width,visualHeight:effectVisualFixture.height,perChannelTolerance:effectVisualFixture.perChannelTolerance,maximumChangedPixelRatio:effectVisualFixture.maximumChangedPixelRatio})},wait=milliseconds=>new Promise(resolve=>setTimeout(resolve,milliseconds)),
|
||||
styles=['classic','giant','laser','orbit','firework','comet'],emoji=REACTION_EMOJIS[0],
|
||||
runSet=async(runStyles,label,duration=900)=>{
|
||||
for(const style of new Set(runStyles))BEND_PERF.warmEffectCache(emoji,style);await new Promise(resolve=>requestAnimationFrame(resolve));await wait(80);
|
||||
BEND_PERF.reset();const rect=document.querySelector('#viewport').getBoundingClientRect(),center=worldUnitAtClient(rect.left+rect.width/2,rect.top+rect.height/2),now=trustedNow();
|
||||
runStyles.forEach((style,index)=>{const effectDuration=reactionDurationForStyle(style),angle=index*Math.PI*2/Math.max(1,runStyles.length),radius=runStyles.length>1?1.35:0;applyRealtimeReaction({id:'effect-benchmark-'+label+'-'+index,emoji,style,x:center[0]+Math.cos(angle)*radius,y:center[1]+Math.sin(angle)*radius,createdAt:now-effectDuration*.22,expiresAt:now+effectDuration*.78})});
|
||||
await wait(duration);const snapshot=BEND_PERF.snapshot();realtimeReactions.clear();reactionDirty=true;scheduleReactionRender(true);await wait(90);return snapshot;
|
||||
},
|
||||
loadPixels=dataUrl=>new Promise((resolve,reject)=>{const image=new Image();image.onload=()=>{const canvas=document.createElement('canvas');canvas.width=image.naturalWidth;canvas.height=image.naturalHeight;const context=canvas.getContext('2d',{willReadFrequently:true});context.drawImage(image,0,0);resolve({width:canvas.width,height:canvas.height,data:context.getImageData(0,0,canvas.width,canvas.height).data})};image.onerror=reject;image.src=dataUrl}),
|
||||
compareSamples=async(directUrl,cachedUrl)=>{const[a,b]=await Promise.all([loadPixels(directUrl),loadPixels(cachedUrl)]);let changed=0,active=0,alphaDelta=0,minX=a.width,minY=a.height,maxX=-1,maxY=-1;for(let offset=0;offset<a.data.length;offset+=4){const pixel=offset/4,x=pixel%a.width,y=Math.floor(pixel/a.width),visible=a.data[offset+3]>0||b.data[offset+3]>0;if(visible){active++;minX=Math.min(minX,x);minY=Math.min(minY,y);maxX=Math.max(maxX,x);maxY=Math.max(maxY,y)}if(Math.abs(a.data[offset]-b.data[offset])>options.perChannelTolerance||Math.abs(a.data[offset+1]-b.data[offset+1])>options.perChannelTolerance||Math.abs(a.data[offset+2]-b.data[offset+2])>options.perChannelTolerance||Math.abs(a.data[offset+3]-b.data[offset+3])>options.perChannelTolerance)changed++;alphaDelta+=Math.abs(a.data[offset+3]-b.data[offset+3])}return{changed,total:a.width*a.height,ratio:changed/(a.width*a.height),active,alphaDelta,bounds:[minX,minY,maxX,maxY]}},
|
||||
result={cpuRate:options.cpuRate,singles:{},visual:[],visualGate:{perChannelTolerance:options.perChannelTolerance,maximumChangedPixelRatio:options.maximumChangedPixelRatio,width:options.visualWidth,height:options.visualHeight},inventory:null,aurora:null,memory:null};
|
||||
BEND_PERF.clearEffectCaches();
|
||||
if(options.singles)for(const style of styles)result.singles[style]=await runSet([style],style,style==='classic'?620:1200);
|
||||
result.overlap4=await runSet(['giant','laser','orbit','firework'],'overlap4',1100);
|
||||
result.overlap8=await runSet(['giant','laser','orbit','firework','comet','laser','orbit','firework'],'overlap8',1100);
|
||||
if(options.cpuRate===1){
|
||||
const board=rendered.get('B0'),setup={};BEND_PERF.reset();for(let index=0;index<40;index++){const style=styles[index%styles.length],duration=reactionDurationForStyle(style),id='effect-publish-'+index,now=trustedNow();applyRealtimeReaction({id,emoji,style,x:0,y:0,createdAt:now,expiresAt:now+duration});realtimeReactions.delete(id)}cancelReactionRenderScheduler(true);setup.reaction=BEND_PERF.snapshot();
|
||||
if(board){cleanupGemEffects();BEND_PERF.reset();for(let index=0;index<30;index++){playGemCollectionAnimation(board,100000);cleanupGemEffects()}setup.gem=BEND_PERF.snapshot();skipCompletionVisuals();BEND_PERF.reset();for(let index=0;index<30;index++){completionEffect(board,1000);finishCompletionVisual(board.id,false)}setup.completion=BEND_PERF.snapshot()}
|
||||
result.setup=setup;
|
||||
}
|
||||
if(options.visual){
|
||||
for(const{style,life}of options.visualCases){
|
||||
const direct=BEND_PERF.renderReactionSample({style,emoji,life,width:options.visualWidth,height:options.visualHeight,glyphCache:false,pathCache:false}),cached=BEND_PERF.renderReactionSample({style,emoji,life,width:options.visualWidth,height:options.visualHeight,glyphCache:true,pathCache:true});
|
||||
result.visual.push({style,life,...await compareSamples(direct.dataUrl,cached.dataUrl)});
|
||||
}
|
||||
}
|
||||
if(options.inventory){
|
||||
const previousCursor=data.cursorStyle,previousLineColor=data.lineColorStyle,previousInventoryEntries=inventoryEntries,syntheticEntries=STORE_ITEMS.map((item,index)=>({meta:null,st:null,store:null,purchase:{id:item.id,itemId:item.id,boughtAt:index+1,paidCost:0},personal:true}));inventoryEntries=itemId=>itemId?syntheticEntries.filter(entry=>entry.purchase.id===itemId):[...syntheticEntries];invalidateEconomyCaches();await wait(100);BEND_PERF.reset();const resourcesBefore=performance.getEntriesByType('resource').filter(entry=>entry.name.includes('/assets/flags/')).length;
|
||||
openInventory();await new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)));const renderSnapshot=BEND_PERF.snapshot();inventoryPanel.scrollTop=Math.min(480,Math.max(0,inventoryPanel.scrollHeight-inventoryPanel.clientHeight));const scrollBefore=inventoryPanel.scrollTop,auroraItem=STORE_ITEMS.find(item=>item.aurora===true),focusTarget=auroraItem?inventoryItemViews.get(auroraItem.id)?.use:null,nodesBefore=inventoryList.querySelectorAll('*').length;focusTarget?.focus({preventScroll:true});BEND_PERF.reset();
|
||||
for(let index=0;index<40;index++){data.lineColorStyle=index%2?previousLineColor:auroraItem.id;patchInventoryItems([auroraItem.id,previousLineColor])}await new Promise(resolve=>requestAnimationFrame(resolve));
|
||||
const patchSnapshot=BEND_PERF.snapshot(),resourcesAfter=performance.getEntriesByType('resource').filter(entry=>entry.name.includes('/assets/flags/')).length;result.inventory={renderSnapshot,patchSnapshot,scrollBefore,scrollAfter:inventoryPanel.scrollTop,focusRetained:!focusTarget||document.activeElement===focusTarget,resourceDelta:resourcesAfter-resourcesBefore,mounted:inventoryList.querySelectorAll('[data-item-id]').length,nodesBefore,nodesAfter:inventoryList.querySelectorAll('*').length};
|
||||
closeInventory(false);inventoryEntries=previousInventoryEntries;invalidateEconomyCaches();data.cursorStyle=previousCursor;data.lineColorStyle=previousLineColor;syncCursorAppearance(previousCursor);syncCosmeticAppearance();renderInventoryPanel();
|
||||
}
|
||||
if(options.aurora){
|
||||
const svg=document.createElementNS('http://www.w3.org/2000/svg','svg'),fragment=document.createDocumentFragment();svg.setAttribute('aria-hidden','true');svg.style.cssText='position:absolute;width:1px;height:1px;overflow:visible;pointer-events:none';
|
||||
for(let index=0;index<500;index++){const path=document.createElementNS('http://www.w3.org/2000/svg','path');path.setAttribute('class','path line-effect-aurora');path.setAttribute('d',\`M0 \${index%25} L100 \${index%25}\`);fragment.append(path)}svg.append(fragment);world.append(svg);const previousCount=auroraVisiblePathCount;auroraVisiblePathCount+=500;BEND_PERF.reset();updateAuroraAnimationState();await wait(4300);const active=BEND_PERF.snapshot();auroraVisiblePathCount=0;updateAuroraAnimationState();BEND_PERF.reset();await wait(2300);const inactive=BEND_PERF.snapshot();
|
||||
Object.defineProperty(document,'visibilityState',{value:'hidden',configurable:true});auroraVisiblePathCount=500;document.dispatchEvent(new Event('visibilitychange'));BEND_PERF.reset();await wait(2300);const hidden=BEND_PERF.snapshot();delete document.visibilityState;auroraVisiblePathCount=previousCount;svg.remove();document.dispatchEvent(new Event('visibilitychange'));updateAuroraAnimationState();result.aurora={active,inactive,hidden};
|
||||
}
|
||||
if(options.memory&&globalThis.gc&&performance.memory){
|
||||
const board=rendered.get('B0');BEND_PERF.clearEffectCaches();cleanupGemEffects();skipCompletionVisuals();globalThis.gc();await wait(80);const before=performance.memory.usedJSHeapSize;
|
||||
for(let index=0;index<100;index++){const style=styles[index%styles.length],duration=reactionDurationForStyle(style),id='effect-memory-'+index,now=trustedNow();applyRealtimeReaction({id,emoji,style,x:0,y:0,createdAt:now-duration*.5,expiresAt:now+duration*.5});reactionDirty=true;drawReactionLayer();realtimeReactions.delete(id);cancelReactionRenderScheduler(true);if(board){playGemCollectionAnimation(board,100000);cleanupGemEffects();completionEffect(board,1000);finishCompletionVisual(board.id,false)}}
|
||||
reactionDirty=true;drawReactionLayer();cancelReactionRenderScheduler(true);cleanupGemEffects();skipCompletionVisuals();BEND_PERF.clearEffectCaches();globalThis.gc();await wait(120);globalThis.gc();result.memory={before,after:performance.memory.usedJSHeapSize,delta:performance.memory.usedJSHeapSize-before,active:realtimeReactions.size,gemBatches:activeGemBatches.size,completionVisuals:activeCompletionVisuals.size,effectNodes:document.querySelectorAll('.gem-particle,.completion-flash,.completion-burst').length,cacheEntries:(BEND_PERF.snapshot().gauges.reactionGlyphCacheEntries||0)+(BEND_PERF.snapshot().gauges.reactionStaticPathCacheEntries||0)};
|
||||
}
|
||||
return result;
|
||||
})()`);
|
||||
}
|
||||
|
||||
function validateEffectsAndCosmetics(result,{mobile=false}={}){
|
||||
const cpuRate=result.cpuRate,normalSpeed=cpuRate===1,singleLimit=normalSpeed?6:40,fourLimit=normalSpeed?12:80,longTaskLimit=normalSpeed?50:500,minFrames=normalSpeed?8:5,minGaps=normalSpeed?7:4;
|
||||
for(const[style,snapshot]of Object.entries(result.singles||{})){
|
||||
const draw=timing(snapshot,`reactionStyle.${style}`),gap=timing(snapshot,'reactionFrameGap');
|
||||
assert(draw.count>=minFrames&&(!normalSpeed||draw.p95<=singleLimit&&draw.p99<=8),`${mobile?'mobile ':''}${style}/${cpuRate}x reaction work missed acceptance: ${JSON.stringify({draw,singleLimit,minFrames})}`);
|
||||
if(normalSpeed)assert(gap.count>=minGaps&&gap.p50<=45,`${style}/${cpuRate}x single-effect median cadence missed acceptance: ${JSON.stringify({gap,draw:timing(snapshot,'reactionFrame'),rates:snapshot.rates,counters:snapshot.counters})}`);
|
||||
assert(timing(snapshot,'reactionFrame').max<longTaskLimit,`${style}/${cpuRate}x reaction callback exceeded its stress ceiling: ${JSON.stringify(timing(snapshot,'reactionFrame'))}`);
|
||||
}
|
||||
const four=timing(result.overlap4,'reactionFrame'),eight=timing(result.overlap8,'reactionFrame'),fourGap=timing(result.overlap4,'reactionFrameGap');
|
||||
assert(four.count>=minFrames&&(!normalSpeed||four.p95<=fourLimit&&four.p99<=20)&&four.max<longTaskLimit,`${mobile?'mobile ':''}four-effect/${cpuRate}x reaction missed its work budget: ${JSON.stringify({four,fourGap,fourLimit,minFrames,rates:result.overlap4.rates,counters:result.overlap4.counters})}`);
|
||||
assert(eight.count>=(normalSpeed?6:4)&&eight.max<longTaskLimit&&(result.overlap8.gauges.peakVisibleReactions||0)>=8,`Eight-effect overload did not render every valid reaction: ${JSON.stringify({eight,peakVisible:result.overlap8.gauges.peakVisibleReactions,visible:result.overlap8.gauges.visibleReactions})}`);
|
||||
for(const snapshot of[result.overlap4,result.overlap8]){
|
||||
assert((snapshot.rates.reactionDeadlineTimerCallbacksPerSecond||0)<=37.5,`Reaction deadline timer exceeded the 37.5 callbacks/s short-window envelope: ${snapshot.rates.reactionDeadlineTimerCallbacksPerSecond}`);
|
||||
assert((snapshot.rates.reactionDrawRafCallbacksPerSecond||0)<=37.5,`Reaction draw RAF exceeded the 37.5 callbacks/s short-window envelope: ${snapshot.rates.reactionDrawRafCallbacksPerSecond}`);
|
||||
}
|
||||
if(result.visual?.length){const worst=result.visual.reduce((a,b)=>a.ratio>b.ratio?a:b),limit=result.visualGate?.maximumChangedPixelRatio??.005;assert(worst.ratio<=limit,`Cached effect pixels changed beyond tolerance: ${JSON.stringify({worst,gate:result.visualGate})}`)}
|
||||
if(result.setup){assert(timing(result.setup.reaction,'reactionPublish').p95<=2,`Reaction publication exceeded 2 ms: ${JSON.stringify(timing(result.setup.reaction,'reactionPublish'))}`);assert(timing(result.setup.gem,'gemEffectSetup').p95<=3,`Gem setup exceeded 3 ms: ${JSON.stringify(timing(result.setup.gem,'gemEffectSetup'))}`);assert(timing(result.setup.completion,'completionEffectSetup').p95<=3,`Completion setup exceeded 3 ms: ${JSON.stringify(timing(result.setup.completion,'completionEffectSetup'))}`)}
|
||||
if(result.inventory){
|
||||
const render=timing(result.inventory.renderSnapshot,'inventoryRender'),initialPatch=timing(result.inventory.renderSnapshot,'inventoryPatch'),initial=render.count?render:initialPatch,patch=timing(result.inventory.patchSnapshot,'inventoryPatch');
|
||||
assert(initial.count&&initial.p95<=100,`Full cosmetic inventory render exceeded 100 ms: ${JSON.stringify({render,initialPatch})}`);assert(patch.count>=20&&patch.p95<=8,`Cosmetic equip patch exceeded 8 ms: ${JSON.stringify({patch})}`);
|
||||
assert(result.inventory.scrollAfter===result.inventory.scrollBefore,`Cosmetic equip moved inventory scroll from ${result.inventory.scrollBefore} to ${result.inventory.scrollAfter}`);assert(result.inventory.focusRetained,'Cosmetic equip moved keyboard focus');assert(result.inventory.nodesAfter===result.inventory.nodesBefore,'Cosmetic equip rebuilt inventory nodes');
|
||||
assert((result.inventory.renderSnapshot.counters.longTasks||0)===0&&(result.inventory.patchSnapshot.counters.longTasks||0)===0,`Full cosmetic inventory produced a long task: ${JSON.stringify({render,patch,mounted:result.inventory.mounted})}`);
|
||||
}
|
||||
if(result.aurora){const active=timing(result.aurora.active,'auroraTick');assert(active.p95<=.25,`Aurora tick exceeded 0.25 ms: ${JSON.stringify(active)}`);const writes=result.aurora.active.counters.auroraColorWrites||0;assert(writes>=2&&writes<=3,`Aurora did not select one curated color every two seconds: ${writes}`);for(const[name,snapshot]of Object.entries({inactive:result.aurora.inactive,hidden:result.aurora.hidden}))assert(!(snapshot.counters.auroraColorWrites||0)&&!timing(snapshot,'auroraTick').count,`Aurora performed work while ${name}: ${JSON.stringify(snapshot)}`)}
|
||||
if(result.memory)assert(result.memory.active===0&&result.memory.gemBatches===0&&result.memory.completionVisuals===0&&result.memory.effectNodes===0&&result.memory.cacheEntries===0&&result.memory.delta<=2*1024*1024,`Effect cleanup retained too much state: ${JSON.stringify(result.memory)}`);
|
||||
}
|
||||
|
||||
async function zoom(client,deltaY,repetitions){
|
||||
const center=await viewportCenter(client);
|
||||
for(let index=0;index<repetitions;index++){
|
||||
|
|
@ -413,7 +496,7 @@ async function zoom(client,deltaY,repetitions){
|
|||
await sleep(300);
|
||||
}
|
||||
|
||||
function timing(snapshot,name){return snapshot.timings?.[name]||{count:0,p50:0,p95:0,max:0}}
|
||||
function timing(snapshot,name){return snapshot.timings?.[name]||{count:0,p50:0,p95:0,p99: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};
|
||||
|
|
@ -470,14 +553,14 @@ function validateMeasurement(result){
|
|||
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,functionalDragLimit=cpuRate===1?16:24,minimapLimit=cpuRate===1?20:33,lodLimit=cpuRate===1?40:100;
|
||||
dragLimit=cpuRate===1?8:16,functionalDragLimit=cpuRate===1?18:24,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})`);
|
||||
assert(cameraGap.count>=4&&(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)}`);
|
||||
|
|
@ -490,10 +573,10 @@ function validateMeasurement(result){
|
|||
assert((snapshot.counters.overviewBuildsDuringInteraction||0)>=1&&(snapshot.counters.overviewBuildsDuringInteraction||0)<=60,`${profile}/${cpuRate}x long overview pan did not use a bounded in-gesture cache refresh`);
|
||||
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<=28,`${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(dragGap.p50<=40&&dragGap.p95<=50,`${profile} pickup visual median/p95 gap ${dragGap.p50.toFixed(2)}/${dragGap.p95.toFixed(2)} ms exceeded the capped 30 FPS cadence budget; active timings ${JSON.stringify(cadenceHot)}`);
|
||||
assert(cameraGap.p50<=40,`${profile} camera median gap ${cameraGap.p50.toFixed(2)} ms exceeded the capped 30 FPS cadence budget`);
|
||||
assert(dragAge.p95<45,`${profile} pickup input age ${dragAge.p95.toFixed(2)} ms exceeded 45 ms`);
|
||||
assert(cameraAge.p95<45,`${profile} camera input age ${cameraAge.p95.toFixed(2)} ms exceeded 45 ms`);
|
||||
}
|
||||
assert(drag.p95<=functionalDragLimit&&probeDrag.p95<=dragLimit,`${profile}/${cpuRate}x drag work exceeded acceptance (functional ${drag.p95.toFixed(2)}/${functionalDragLimit} ms, cadence ${probeDrag.p95.toFixed(2)}/${dragLimit} ms, model ${timing(cadenceSnapshot,'pickupModelWork').p95.toFixed(2)}, visual ${timing(cadenceSnapshot,'pickupVisualWork').p95.toFixed(2)}, render ${timing(cadenceSnapshot,'renderDragFrame').p95.toFixed(2)})`);
|
||||
assert(camera.p95<=dragLimit,`${profile}/${cpuRate}x camera p95 ${camera.p95.toFixed(2)} ms exceeded acceptance`);
|
||||
|
|
@ -506,9 +589,8 @@ function validateMeasurement(result){
|
|||
}
|
||||
for(const name of ['minimapDrawsDuringInteraction','lodPassesDuringInteraction','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((cadenceSnapshot.rates.dragFramesPerSecond||0)<=35,`${profile}/${cpuRate}x pickup presentation exceeded the 30 FPS ceiling (${(cadenceSnapshot.rates.dragFramesPerSecond||0).toFixed(1)} FPS)`);
|
||||
assert(snapshot.gauges.domNodes<18000,`${profile}/${cpuRate}x DOM size is not viewport-bounded`);
|
||||
}
|
||||
|
||||
|
|
@ -545,6 +627,7 @@ async function measureScenario(client,starter,profile,cpuRate){
|
|||
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 client.evaluate("(()=>{const r=document.querySelector('#viewport').getBoundingClientRect(),p=worldUnitAtClient(r.left+r.width/2,r.top+r.height/2),now=trustedNow();applyRealtimeReaction({id:'benchmark-overview-effect',emoji:REACTION_EMOJIS[0],style:'orbit',x:p[0],y:p[1],createdAt:now,expiresAt:now+20000});return true})()");
|
||||
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');
|
||||
|
|
@ -554,7 +637,7 @@ async function measureScenario(client,starter,profile,cpuRate){
|
|||
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;
|
||||
const panLongTasks=(await client.evaluate('BEND_PERF.snapshot().counters.interactionLongTasks||0'))-panLongTaskBaseline;await client.evaluate("realtimeReactions.delete('benchmark-overview-effect');true");
|
||||
await zoom(client,-180,15);
|
||||
await sleep(650);
|
||||
try{await waitFor(()=>client.evaluate("!hasPendingPersistence()"),{timeout:20000,label:'durable persistence drain'})}
|
||||
|
|
@ -564,9 +647,26 @@ async function measureScenario(client,starter,profile,cpuRate){
|
|||
}
|
||||
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;
|
||||
return{profile:profile.name,boards:profile.boards,cpuRate,snapshot,pickupCadence,pickupProbe,overviewPathsObserved,overviewBuildBaseline,pickupProbeLongTasks,pickupLongTasks,panLongTasks,claimApproved,claimDenied,edgePan,pinch};
|
||||
}
|
||||
function profileSummaryRow(result){
|
||||
return{
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
async function main(runProfiles=profiles,cpuRates=[1,4]){
|
||||
|
|
@ -577,6 +677,7 @@ async function main(runProfiles=profiles,cpuRates=[1,4]){
|
|||
`--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});
|
||||
const report={capturedAt:new Date().toISOString(),browserPath:edgePath,benchmarkHost,status:'running',effects:{},profiles:[]};
|
||||
let client=null;
|
||||
try{
|
||||
const target=await endpoint();client=new CdpClient(target.webSocketDebuggerUrl);await client.connect();
|
||||
|
|
@ -585,45 +686,41 @@ async function main(runProfiles=profiles,cpuRates=[1,4]){
|
|||
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.83'&&state.boards===1&&state.origin&&state.worldGeneration==='v47-field-reset-20260728-interaction-fix',`Real-browser startup state is incomplete: ${JSON.stringify(state)}`);
|
||||
assert(state.ready==='true'&&state.version==='v47.87'&&state.boards===1&&state.origin&&state.worldGeneration==='linkfield-single-world-20260801',`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);
|
||||
const displayCadence=await measureDisplayCadence(client);report.displayCadence=displayCadence;
|
||||
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);
|
||||
const gameplayBudgets=await measureGameplaySimplificationBudgets(client);report.gameplayBudgets=gameplayBudgets;
|
||||
const cursorModes=await measureCursorModes(client);report.cursorModes=cursorModes;
|
||||
assert(cursorModes.defaultMode==='default'&&cursorModes.emojiMode==='dom'&&cursorModes.flagMode==='dom'&&cursorModes.domMode==='dom'&&cursorModes.visibleBeforeDrag&&!cursorModes.visibleDuringDrag&&!cursorModes.movedDuringDrag,`Cursor mode runtime probe failed: ${JSON.stringify(cursorModes)}`);
|
||||
const cursorCadence=await measureCursorCadence(client);
|
||||
const cursorCadence=await measureCursorCadence(client);report.cursorCadence=cursorCadence;
|
||||
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 effects1x=await measureEffectsAndCosmetics(client,{cpuRate:1,visual:true,inventory:true,aurora:true,memory:true,singles:true});report.effects.desktop1x=effects1x;validateEffectsAndCosmetics(effects1x);
|
||||
const effects4x=await measureEffectsAndCosmetics(client,{cpuRate:4,visual:false,inventory:false,aurora:false,memory:false,singles:true});report.effects.desktop4x=effects4x;validateEffectsAndCosmetics(effects4x);
|
||||
await client.send('Emulation.setDeviceMetricsOverride',{width:390,height:844,deviceScaleFactor:1,mobile:true,screenWidth:390,screenHeight:844});await sleep(220);
|
||||
const effectsMobile=await measureEffectsAndCosmetics(client,{cpuRate:1,visual:false,inventory:false,aurora:false,memory:false,singles:false});report.effects.mobile=effectsMobile;validateEffectsAndCosmetics(effectsMobile,{mobile:true});
|
||||
await client.send('Emulation.clearDeviceMetricsOverride');await client.send('Emulation.setCPUThrottlingRate',{rate:1});await sleep(220);
|
||||
console.log(`Effects | single firework ${timing(effects1x.singles.firework,'reactionStyle.firework').p95.toFixed(2)} ms | overlap4 ${timing(effects1x.overlap4,'reactionFrame').p95.toFixed(2)} ms | overlap8 ${timing(effects1x.overlap8,'reactionFrame').p95.toFixed(2)} ms | visual max ${(Math.max(...effects1x.visual.map(row=>row.ratio))*100).toFixed(3)}%`);
|
||||
console.log(`EFFECT_BENCHMARK_JSON=${JSON.stringify({desktop1x:{single:Object.fromEntries(Object.entries(effects1x.singles).map(([style,snapshot])=>[style,timing(snapshot,`reactionStyle.${style}`)])),overlap4:timing(effects1x.overlap4,'reactionFrame'),overlap8:timing(effects1x.overlap8,'reactionFrame'),inventory:effects1x.inventory,memory:effects1x.memory,aurora:timing(effects1x.aurora.active,'auroraTick'),setup:effects1x.setup},desktop4x:{overlap4:timing(effects4x.overlap4,'reactionFrame'),overlap8:timing(effects4x.overlap8,'reactionFrame')},mobile:{overlap4:timing(effectsMobile.overlap4,'reactionFrame'),overlap8:timing(effectsMobile.overlap8,'reactionFrame')}})}`);
|
||||
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 result=await measureScenario(client,starter,profile,cpuRate);results.push(result);report.profiles=results.map(profileSummaryRow);validateMeasurement(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
|
||||
})))}`);
|
||||
const profileSummary=results.map(profileSummaryRow);report.profiles=profileSummary;console.log(`BROWSER_BENCHMARK_JSON=${JSON.stringify(profileSummary)}`);
|
||||
report.status='passed';writeBenchmarkReport(report);console.log(`Browser benchmark report: ${benchmarkOutputPath}`);
|
||||
console.log('Real-browser performance benchmark passed');
|
||||
}catch(error){
|
||||
report.status='failed';report.failure={name:error?.name||'Error',message:error?.message||String(error),stack:error?.stack||''};
|
||||
try{writeBenchmarkReport(report);console.error(`Browser benchmark failure report: ${benchmarkOutputPath}`)}catch(reportError){console.error(`Browser benchmark report write failed: ${reportError.message}`)}
|
||||
throw error;
|
||||
}finally{
|
||||
client?.close();
|
||||
stopBrowserTree(edge);
|
||||
|
|
|
|||
|
|
@ -37,13 +37,13 @@ assert(functionSource('evictHydratedBoardDetails').includes('hydratedBoardLru.si
|
|||
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'));
|
||||
assert(functionSource('applyCamera').includes('GLOBAL_FRAME_INTERVAL')&&app.includes('AUXILIARY_FPS=30')&&app.includes('DRAG_TARGET_FPS=30'));
|
||||
|
||||
// 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('playGemCollectionAnimation').includes('_gemDuration=reduced?520:1450'));
|
||||
assert(!functionSource('bindBoard').includes('skipCompletionVisuals'));
|
||||
|
||||
// Purchase normalization must remain functional after deleting field purchases.
|
||||
|
|
|
|||
|
|
@ -16,18 +16,18 @@ 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(functionSource('runStatusRetry').includes('statusRetryAction')&&!app.includes('復元用バックアップがありません。'),
|
||||
'The shared-server retry button still attempts obsolete local-backup recovery');
|
||||
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');
|
||||
assert(!app.includes('worldInitReady')&&!app.includes('deferredWorldSignals')&&!app.includes('BroadcastChannel'),
|
||||
'Retired cross-tab board synchronization remains');
|
||||
assert(functionSource('fetchCurrentSharedWorldStatus').includes("status.singleWorld===true")&&functionSource('initCloudSync').includes('resetClientToSingleSharedWorld()'),
|
||||
'Startup does not require and adopt the single server-authoritative world');
|
||||
|
||||
const request=result=>({result});
|
||||
function memoryStore(initial=[],keyOf=row=>row.id){
|
||||
|
|
@ -176,28 +176,6 @@ function verifyGlobalMerge(){
|
|||
'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={
|
||||
|
|
@ -231,7 +209,6 @@ function verifyRevisionSeed(){
|
|||
(async()=>{
|
||||
await verifyPersistenceConflicts();
|
||||
verifyGlobalMerge();
|
||||
await verifySignalRetry();
|
||||
await verifyDurableBackup();
|
||||
verifyRevisionSeed();
|
||||
console.log('Concurrency and replacement safety test passed');
|
||||
|
|
|
|||
51
test/effects-performance-smoke-test.js
Normal file
51
test/effects-performance-smoke-test.js
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
'use strict';
|
||||
const {assert,app,css,functionSource,read}=require('./helpers/app-source');
|
||||
const browserBenchmark=read('test/browser-performance-benchmark.js');
|
||||
|
||||
const scheduler=functionSource('scheduleReactionRender');
|
||||
assert(app.includes('REACTION_TARGET_FPS=30')&&scheduler.includes('reactionDelayTimer=setTimeout')&&scheduler.includes('requestAnimationFrame')&&scheduler.includes('reactionNextDrawAt'),'Reaction rendering is not deadline-scheduled at the existing 30 FPS cadence');
|
||||
assert(scheduler.includes("document.visibilityState==='hidden'")&&functionSource('cancelReactionRenderScheduler').includes('reactionLastDraw=0'),'Reaction scheduling is not suspended and reset across hidden-page lifecycle changes');
|
||||
|
||||
const prepare=functionSource('prepareReactionModel');
|
||||
for(const token of ['preparedReactionCracks','preparedLaserRays','preparedOrbitModel','preparedFireworkBloom','preparedReactionBurst'])assert(prepare.includes(token)||app.includes(token),`Prepared reaction model is missing ${token}`);
|
||||
assert(functionSource('normalizeRealtimeReaction').includes('reaction.prepared=prepareReactionModel(reaction)')&&functionSource('drawStyledReaction').includes('reaction.prepared'),'Immutable reaction work is not prepared once and reused');
|
||||
|
||||
assert(app.includes('REACTION_GLYPH_CACHE_MAX_ENTRIES=160')&&app.includes('REACTION_GLYPH_CACHE_MAX_BYTES=12*1024*1024'),'Reaction glyph cache is not explicitly bounded');
|
||||
assert(app.includes('REACTION_STATIC_PATH_CACHE_MAX_ENTRIES=32')&&functionSource('reactionStaticPath').includes('reactionStaticPathCacheEvictions'),'Reaction static Path2D cache is not bounded or measured');
|
||||
assert(functionSource('drawOrbitReaction').includes('reactionOrbitRingPath')&&functionSource('drawFireworkReaction').includes('reactionFireworkLaunchPath'),'Static reaction geometry does not use the exact Path2D cache');
|
||||
const glyph=functionSource('reactionGlyphSprite');
|
||||
assert(glyph.includes('reactionGlyphCacheHits')&&glyph.includes('reactionGlyphCacheMisses')&&glyph.includes('reactionGlyphCacheEvictions'),'Reaction glyph cache metrics or eviction are missing');
|
||||
assert(functionSource('drawReactionEmoji').includes('drawImage')&&functionSource('drawReactionEmoji').includes('fillText'),'Cached glyph drawing lacks its exact direct-render fallback');
|
||||
|
||||
const reactionLayer=functionSource('drawReactionLayer');
|
||||
for(const metric of ['reactionFrame','reactionComposite','activeReactions','visibleReactions','peakVisibleReactions','preparedReactionModels','reactionOverload'])assert(app.includes(metric),`Effect performance metric is missing: ${metric}`);
|
||||
for(const style of ['giant','laser','orbit','firework','comet'])assert(functionSource('drawStyledReaction').includes(`style==='${style}'`),`Full reaction renderer is missing: ${style}`);
|
||||
assert(!functionSource('drawStyledReaction').match(/interactionActive|lightweightRendering|autoReduced|fallbackStyle|visibleReactionLimit/),'Reaction renderer contains automatic or interaction-driven visual degradation');
|
||||
assert(reactionLayer.includes('for(const[id,reaction]of realtimeReactions)')&&!reactionLayer.match(/slice\(|break;|visibleReactionLimit/),'Valid overlapping reactions can be dropped from rendering');
|
||||
|
||||
const aurora=functionSource('startAuroraRgbAnimation'),auroraState=functionSource('updateAuroraAnimationState');
|
||||
assert(aurora.includes('AURORA_COLOR_INTERVAL')&&auroraState.includes('auroraVisiblePathCount')&&!aurora.includes('querySelector'),'Aurora is not controlled by tracked visible paths');
|
||||
assert(functionSource('writeAuroraRgb').includes('auroraColorHost().style.setProperty')&&!functionSource('writeAuroraRgb').includes('document.body.style'),'Aurora color invalidation is not scoped to the world');
|
||||
assert(functionSource('useInventoryItemLoaded').slice(functionSource('useInventoryItemLoaded').indexOf('if(item.lineColor)'),functionSource('useInventoryItemLoaded').indexOf('if(item.lineEffect)')).includes('renderAll()'),'Equipping a line color does not immediately repaint lines and gates');
|
||||
|
||||
assert(app.includes('GEM_PARTICLE_POOL_LIMIT=72')&&functionSource('playGemCollectionAnimation').includes('document.createDocumentFragment()')&&functionSource('releaseGemParticle').includes('gemParticlePool.push'),'Gem animations do not use a bounded, batched node pool');
|
||||
assert(app.includes('GEM_PARTICLE_KEYFRAME_TEMPLATE=Object.freeze([')&&app.includes('GEM_PARTICLE_ANIMATION_OPTIONS_TEMPLATE=Object.freeze(')&&functionSource('takeGemParticle').includes('if(!particle._gemKeyframes)')&&functionSource('animateGemParticle').includes('particle.animate(keyframes,options)'),'Gem animation templates are still allocated for every playback or shared unsafely across concurrent particles');
|
||||
assert(app.includes('COMPLETION_NODE_POOL_LIMIT=16')&&functionSource('finishCompletionVisual').includes('releaseCompletionNode'),'Completion visuals do not return their nodes to a bounded pool');
|
||||
|
||||
const inventory=functionSource('renderInventoryPanel');
|
||||
assert(app.includes('inventoryCategoryViews=new Map()')&&app.includes('inventoryItemViews=new Map()')&&!inventory.includes('inventoryList.replaceChildren()'),'Owned cosmetic rendering still rebuilds the full list');
|
||||
assert(functionSource('setItemIcon').includes("image.loading='lazy'")&&functionSource('setItemIcon').includes("image.decoding='async'"),'Flag assets are not lazy-loaded and asynchronously decoded');
|
||||
assert(css.includes('content-visibility:auto')&&css.includes('contain-intrinsic-size'),'Offscreen cosmetic cards do not use render containment');
|
||||
assert(functionSource('syncInventoryCursorSelection').includes('inventorySelectedCursorItemId')&&!functionSource('syncInventoryCursorSelection').includes('querySelectorAll'),'Cursor equip still scans the entire catalog');
|
||||
assert(functionSource('renderInventoryPanel').includes('inventoryItemsUnchanged')||functionSource('updateInventoryItemView').includes('inventoryItemsUnchanged'),'Inventory reconciliation does not skip unchanged cards');
|
||||
const inventoryUse=functionSource('useInventoryItemLoaded');
|
||||
for(const marker of ['if(item.lineColor)','if(item.reactionStyle)','if(item.scoreLens)'])assert(inventoryUse.slice(inventoryUse.indexOf(marker)).includes('patchInventoryItems'),'Cosmetic equip is not locally patched');
|
||||
|
||||
assert(app.includes('renderReactionSample:options=>renderReactionSample(options)')&&functionSource('renderReactionSample').includes('normalizedLife'),'Exact-lifetime visual-equivalence test hook is missing');
|
||||
|
||||
for(const gate of ["timing(result.setup.reaction,'reactionPublish').p95<=2","timing(result.setup.gem,'gemEffectSetup').p95<=3","timing(result.setup.completion,'completionEffectSetup').p95<=3","patch.p95<=8","focusRetained","auroraColorWrites","gemBatches===0","effectNodes===0"])assert(browserBenchmark.includes(gate),`Browser release gate is missing: ${gate}`);
|
||||
for(const interaction of ['benchmark-pickup-effect','benchmark-edge-pan-effect','benchmark-overview-effect'])assert(browserBenchmark.includes(interaction),`Effect interaction probe is missing: ${interaction}`);
|
||||
|
||||
assert(!app.includes('autoReducedEffects')&&!app.includes('reactionQualityTier')&&!app.includes('dropReactionForPerformance'),'Automatic effect quality degradation was introduced');
|
||||
|
||||
console.log('Effects and cosmetics performance architecture smoke test passed');
|
||||
|
|
@ -7,6 +7,7 @@ const chosen=[],attemptBases=[];
|
|||
let revision=0,missing=true,hydrations=0;
|
||||
const context={
|
||||
metaState:()=>state,
|
||||
canExpandSharedBoard:()=>true,
|
||||
hydrateMeta:async target=>{hydrations++;target.puzzle={}},
|
||||
hydrateAdjacentMetas:async()=>{},
|
||||
rebuildOccupancy:()=>{},
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ context.addMetaToOccupancy=meta=>{for(const[dx,dy]of meta.chunks)context.occupan
|
|||
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.canExpandSharedBoard=()=>true;
|
||||
context.ensureMetaState=id=>context.metaState(id);
|
||||
context.unsolvedBoardCount=()=>Object.keys(context.data.metas).filter(id=>!context.metaState(id).solved).length;
|
||||
vm.createContext(context);
|
||||
|
|
|
|||
|
|
@ -33,6 +33,6 @@ assert(persistNow.includes('result.count>0')&&persistNow.includes('verifyActiveW
|
|||
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(app.includes('cameraAnchor')&&app.includes('selectedBoardId')&&init.includes('randomUnsolvedMeta')&&loadV2.includes('selectedIndex'),'Saved board hints or randomized unsolved-board startup are missing');
|
||||
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');
|
||||
|
|
|
|||
130
test/fixtures/effect-visual-checkpoints.json
vendored
Normal file
130
test/fixtures/effect-visual-checkpoints.json
vendored
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
{
|
||||
"version": 1,
|
||||
"referenceRenderer": "direct-main-thread",
|
||||
"width": 900,
|
||||
"height": 700,
|
||||
"perChannelTolerance": 8,
|
||||
"maximumChangedPixelRatio": 0.005,
|
||||
"cases": [
|
||||
{
|
||||
"style": "classic",
|
||||
"life": 0.1
|
||||
},
|
||||
{
|
||||
"style": "classic",
|
||||
"life": 0.25
|
||||
},
|
||||
{
|
||||
"style": "classic",
|
||||
"life": 0.5
|
||||
},
|
||||
{
|
||||
"style": "classic",
|
||||
"life": 0.75
|
||||
},
|
||||
{
|
||||
"style": "classic",
|
||||
"life": 0.95
|
||||
},
|
||||
{
|
||||
"style": "giant",
|
||||
"life": 0.1
|
||||
},
|
||||
{
|
||||
"style": "giant",
|
||||
"life": 0.25
|
||||
},
|
||||
{
|
||||
"style": "giant",
|
||||
"life": 0.5
|
||||
},
|
||||
{
|
||||
"style": "giant",
|
||||
"life": 0.75
|
||||
},
|
||||
{
|
||||
"style": "giant",
|
||||
"life": 0.95
|
||||
},
|
||||
{
|
||||
"style": "laser",
|
||||
"life": 0.1
|
||||
},
|
||||
{
|
||||
"style": "laser",
|
||||
"life": 0.25
|
||||
},
|
||||
{
|
||||
"style": "laser",
|
||||
"life": 0.5
|
||||
},
|
||||
{
|
||||
"style": "laser",
|
||||
"life": 0.75
|
||||
},
|
||||
{
|
||||
"style": "laser",
|
||||
"life": 0.95
|
||||
},
|
||||
{
|
||||
"style": "orbit",
|
||||
"life": 0.1
|
||||
},
|
||||
{
|
||||
"style": "orbit",
|
||||
"life": 0.25
|
||||
},
|
||||
{
|
||||
"style": "orbit",
|
||||
"life": 0.5
|
||||
},
|
||||
{
|
||||
"style": "orbit",
|
||||
"life": 0.75
|
||||
},
|
||||
{
|
||||
"style": "orbit",
|
||||
"life": 0.95
|
||||
},
|
||||
{
|
||||
"style": "firework",
|
||||
"life": 0.1
|
||||
},
|
||||
{
|
||||
"style": "firework",
|
||||
"life": 0.25
|
||||
},
|
||||
{
|
||||
"style": "firework",
|
||||
"life": 0.5
|
||||
},
|
||||
{
|
||||
"style": "firework",
|
||||
"life": 0.75
|
||||
},
|
||||
{
|
||||
"style": "firework",
|
||||
"life": 0.95
|
||||
},
|
||||
{
|
||||
"style": "comet",
|
||||
"life": 0.1
|
||||
},
|
||||
{
|
||||
"style": "comet",
|
||||
"life": 0.25
|
||||
},
|
||||
{
|
||||
"style": "comet",
|
||||
"life": 0.5
|
||||
},
|
||||
{
|
||||
"style": "comet",
|
||||
"life": 0.75
|
||||
},
|
||||
{
|
||||
"style": "comet",
|
||||
"life": 0.95
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -92,8 +92,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],
|
||||
const cursorItems=Array.from({length:6},(_,index)=>({id:`C${index}`,cursorStyle:`face-${index}`})),
|
||||
otherItems=Array.from({length:12},(_,index)=>({id:`O${index}`})),allStoreItems=[...cursorItems,...otherItems],
|
||||
shopContext={
|
||||
CURSOR_ITEMS:cursorItems,STORE_ITEMS:allStoreItems,
|
||||
hash32:BendPuzzle.hash32,rngFrom:BendPuzzle.rngFrom,shuffle:BendPuzzle.shuffle,
|
||||
|
|
@ -104,7 +104,7 @@ vm.runInContext(`${functionSource('normalizeStoreItemIds')}\n${functionSource('s
|
|||
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(seedA.length===12&&new Set(seedA).size===12&&selected.filter(item=>item.cursorStyle).length===6&&selected.filter(item=>!item.cursorStyle).length===6,'Seeded store inventory is not exactly six cursors and six cosmetic/tool items');
|
||||
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.
|
||||
|
|
@ -116,9 +116,9 @@ assert(functionSource('evictHydratedBoardDetails').includes('hydratedBoardLru.si
|
|||
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('.board-card{filter:drop-shadow'),'Non-selected boards are dimmed or global presentation still creates costly filter surfaces');
|
||||
|
||||
// 15. Skippable completion independent of persistence.
|
||||
// 15. Completion is shown only after durable shared-world confirmation.
|
||||
const solveSource=functionSource('checkSolvedAndExpand');
|
||||
assert(solveSource.indexOf('completionEffect(immediateBoard,award)')<solveSource.indexOf('persistence=save(true)'),'Completion waits for persistence');
|
||||
assert(solveSource.indexOf('persistence=save(true)')<solveSource.indexOf('completionEffect(immediateBoard,award)')&&solveSource.indexOf('pushCloudPending()')<solveSource.indexOf('completionEffect(immediateBoard,award)'),'Completion is displayed before local persistence and shared-world confirmation');
|
||||
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');
|
||||
|
|
@ -130,7 +130,7 @@ assert(!functionSource('repairExpansions').includes('&&meta.puzzle')&&!functionS
|
|||
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(html.includes('id="fpsCounter"')&&functionSource('refreshFpsCounter').includes('FPS ${fpsLastValue}')&&app.includes('MAX_RENDER_FPS=30')&&app.includes('AUXILIARY_FPS=30')&&app.includes('DRAG_TARGET_FPS=30'),'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.
|
||||
|
|
|
|||
|
|
@ -158,8 +158,8 @@ assert(resetState.paths.length===0&&resetState.specialProgress.crossings.length=
|
|||
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 shopItems=[...Array.from({length:12},(_,index)=>({id:`O${index+1}`})),...Array.from({length:6},(_,index)=>({id:`C${index+1}`,cursorStyle:`face-${index+1}`}))];
|
||||
assert(functionSource('renderStorePanel').includes('storeInventoryItems(meta,store)')&&functionSource('renderStorePanel').includes("category.compact?' store-compact'"),'Shop rendering bypasses the fixed 12+6 item inventory or compact cosmetic layout');
|
||||
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}),puzzleOf:meta=>meta.puzzle
|
||||
|
|
@ -208,7 +208,7 @@ 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')};
|
||||
const normalizeDetachedContext={isPlainObject:value=>value&&typeof value==='object'&&!Array.isArray(value),LINE_COLORS:Array(10).fill('#000'),LINE_EFFECT_IDS:new Set(['glow','neon'])};
|
||||
vm.createContext(normalizeDetachedContext);
|
||||
vm.runInContext(`${functionSource('normalizePath')}\nthis.normalizePath=normalizePath;`,normalizeDetachedContext);
|
||||
const normalizedDetached=normalizeDetachedContext.normalizePath(detachedPath);
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ assert(app.includes('REALTIME_CURSOR_INTERVAL=50')&&app.includes('REALTIME_CURSO
|
|||
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('remotePlayers'),'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(functionSource('requestBoardClaim').indexOf("fetchJson('/api/realtime/claim'")<functionSource('requestBoardClaim').indexOf('requestBoardClaimThroughRealtime')&&functionSource('requestBoardClaimThroughRealtime').includes("type:'claim'")&&functionSource('touchBoardClaim').includes("type:'claim-touch'"),'Client claim lease requests 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');
|
||||
|
|
|
|||
|
|
@ -23,10 +23,11 @@ const {connectRealtime}=require('./helpers/realtime-client');
|
|||
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 bob.waitFor(message=>message.type==='claim-release'&&message.boardId==='B0'&&message.reason==='disconnected');
|
||||
bob.send({type:'claim',requestId:'lease-b-after-disconnect',boardId:'B0'});const transferred=await bob.waitFor(message=>message.type==='claim-result'&&message.requestId==='lease-b-after-disconnect');assert.equal(transferred.ok,true);
|
||||
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');
|
||||
console.log('Realtime lease expiry and disconnect release semantics passed');
|
||||
}finally{
|
||||
alice?.close();bob?.close();hub.close();await new Promise(resolve=>server.close(resolve));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ 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']
|
||||
env:{...process.env,PORT:String(port),HOST:'127.0.0.1',LINK_FIELD_TEST_DATA_ROOT: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}`;
|
||||
|
|
@ -26,9 +26,12 @@ function solvedState(puzzle){return{paths:puzzle.solution.map(route=>({startGate
|
|||
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:[]})});
|
||||
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:'linkfield-single-world-20260801',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);
|
||||
|
||||
const directClaim=await request('/api/realtime/claim',{method:'POST',headers:auth(alice),body:JSON.stringify({boardId:'B0'})});assert.equal(directClaim.response.status,200);assert.equal(directClaim.body.ok,true);assert.equal(directClaim.body.claim.playerName,'Alice');
|
||||
const directDenied=await request('/api/realtime/claim',{method:'POST',headers:auth(bob),body:JSON.stringify({boardId:'B0'})});assert.equal(directDenied.response.status,200);assert.equal(directDenied.body.ok,false);assert.equal(directDenied.body.reason,'occupied');
|
||||
|
||||
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');
|
||||
|
|
@ -63,8 +66,9 @@ function solvedState(puzzle){return{paths:puzzle.solution.map(route=>({startGate
|
|||
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);
|
||||
await bobRealtime.waitFor(message=>message.type==='claim-release'&&message.boardId==='B1'&&message.reason==='disconnected');
|
||||
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');
|
||||
const transferred=await bobRealtime.waitFor(message=>message.type==='claim-result'&&message.requestId==='bob-b1-after-disconnect');assert.equal(transferred.ok,true);assert.equal(transferred.claim.playerName,'Bob');
|
||||
|
||||
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})});
|
||||
|
|
|
|||
|
|
@ -20,5 +20,5 @@ for(const item of retired){
|
|||
assert(deleted.includes(`${prefix}:world`),'Old IndexedDB was not deleted');
|
||||
}
|
||||
assert(removed.includes(staleSessionJournal),'Old per-session recovery journal was not removed');
|
||||
assert(buildMeta.SAVE_SCHEMA===31&&buildMeta.STORAGE_SCHEMA===30&&buildMeta.IDB_LAYOUT_VERSION===8&&buildMeta.FIELD_STORAGE_FORMAT===2&&buildMeta.GAMEPLAY_DATA_VERSION===3&&buildMeta.WORLD_GENERATION==='v47-field-reset-20260728-interaction-fix','Interaction-fix field reset generation is not active');
|
||||
assert(buildMeta.SAVE_SCHEMA===31&&buildMeta.STORAGE_SCHEMA===30&&buildMeta.IDB_LAYOUT_VERSION===8&&buildMeta.FIELD_STORAGE_FORMAT===2&&buildMeta.GAMEPLAY_DATA_VERSION===3&&buildMeta.WORLD_GENERATION==='linkfield-single-world-20260801','Seed-refresh field reset generation is not active');
|
||||
console.log('Full field reset test passed');
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@ const fs=require('fs');
|
|||
const {execFileSync}=require('child_process');
|
||||
const tests=[
|
||||
'shared-contracts-test.js','interaction-ownership-test.js','frame-drag-scheduler-test.js','architecture-boundaries-test.js','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','v4779-settings-pan-hud-smoke-test.js','v4780-release-persistence-cursor-smoke-test.js','v4781-hud-gate-overlay-smoke-test.js','v4782-audio-highlight-store-internal-gate-smoke-test.js','v4783-map-store-economy-persistence-smoke-test.js','v4784-pan-solve-production-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-recovery-test.js','server-smoke-test.js','shared-world-complete-smoke-test.js','security-authority-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','v4779-settings-pan-hud-smoke-test.js','v4780-release-persistence-cursor-smoke-test.js','v4781-hud-gate-overlay-smoke-test.js','v4782-audio-highlight-store-internal-gate-smoke-test.js','v4783-map-store-economy-persistence-smoke-test.js','v4784-pan-solve-production-smoke-test.js','v4785-cosmetics-shop-smoke-test.js','v4786-effects-ux-smoke-test.js','v4787-user-cosmetic-realtime-smoke-test.js','v4788-time-attack-navigation-smoke-test.js','v4784-user-request-smoke-test.js','effects-performance-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-recovery-test.js','v4791-server-startup-smoke-test.js','v4792-apache-bridge-smoke-test.js','v4793-php-poll-bridge-smoke-test.js','v4794-background-deploy-smoke-test.js','v4795-single-world-only-smoke-test.js','v4797-shared-board-input-smoke-test.js','v4798-startup-version-retry-smoke-test.js','v4800-shared-clear-economy-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');
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ const context={
|
|||
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',
|
||||
globalDirty:true,globalChangeSeq:3,cloudJournalGlobalChanged:true,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),
|
||||
|
|
@ -53,7 +53,7 @@ const context={
|
|||
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++,
|
||||
scheduleCloudPush:()=>writes.cloud++,
|
||||
interactionActive:()=>false,waitForInteractionSettle:async()=>{},
|
||||
perfStart:()=>0,perfEnd:()=>0,perfGauge:()=>{},deepClone:value=>JSON.parse(JSON.stringify(value)),resetHistory:[],invalidateStoreEffectCache:()=>{},statsDirty:false
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
'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));
|
||||
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',LINK_FIELD_TEST_DATA_ROOT: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(),route=[[0,1],[0,0],[1,0],[2,0],[3,0],[4,0],[4,1],[3,1],[2,1],[1,1],[1,2],[0,2],[0,3],[0,4],[1,4],[1,3],[2,3],[2,4],[3,4],[4,4],[4,3],[3,3],[3,2],[4,2]];p.g=[[0,1,'N'],[4,2,'S']];p.n=[[0,0,10]];p.valid=route.map(cell=>[...cell]);p.obstacles=[[2,2]];p.solution=[{startGate:0,endGate:1,cells:route.map(cell=>[...cell])}];p.specialCells={crossings:[],warps:[],locks:[],internalGates:[]};const b0=meta('B0',0,15,p);
|
||||
(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(),route=[[0,1],[0,0],[1,0],[2,0],[3,0],[4,0],[4,1],[3,1],[2,1],[1,1],[1,2],[0,2],[0,3],[0,4],[1,4],[1,3],[2,3],[2,4],[3,4],[4,4],[4,3],[3,3],[3,2],[4,2]];p.g=[[0,1,'N'],[4,2,'S']];p.n=[[0,0,16]];p.valid=route.map(cell=>[...cell]);p.obstacles=[[2,2]];p.solution=[{startGate:0,endGate:1,cells:route.map(cell=>[...cell])}];p.specialCells={crossings:[],warps:[],locks:[],internalGates:[]};const 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);
|
||||
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,12);
|
||||
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');
|
||||
const forgedDifficulty=meta('B1',1,123,p);forgedDifficulty.level=10;r=await req('/api/cloud/push',{method:'POST',headers:auth(alice),body:JSON.stringify({baseRevision:2,global:{nextId:2},metas:[forgedDifficulty],states:[{id:'B1',value:{paths:[],solved:false}}]})});assert.equal(r.response.status,400,'client-authored difficulty must be rejected in favor of server-derived puzzle facts');
|
||||
|
|
|
|||
|
|
@ -4,13 +4,14 @@ const fs=require('fs');
|
|||
const os=require('os');
|
||||
const path=require('path');
|
||||
|
||||
const dataDir=fs.mkdtempSync(path.join(os.tmpdir(),'bend-field-recovery-'));
|
||||
process.env.BEND_FIELD_DATA_DIR=dataDir;
|
||||
const dataRoot=fs.mkdtempSync(path.join(os.tmpdir(),'link-field-recovery-')),dataDir=path.join(dataRoot,'world');
|
||||
process.env.LINK_FIELD_TEST_DATA_ROOT=dataRoot;
|
||||
const BuildMeta=require('../build-meta');
|
||||
const server=require('../server');
|
||||
const worldFile=path.join(dataDir,'shared-world.json'),commitFile=path.join(dataDir,'shared-world.commit.json'),boardsDir=path.join(dataDir,'shared-world.boards');
|
||||
const playerId='aaaaaaaaaaaaaaaaaaaaaaaa',playerFile=path.join(dataDir,`${playerId}.json`);
|
||||
const player=earnedScore=>({playerId,name:'Player',tokenHash:'0'.repeat(64),purchases:[],generationBonuses:[],earnedScore,economyRevision:earnedScore,createdAt:1,updatedAt:1});
|
||||
const world=revision=>({revision,rowRevision:revision,boardVersions:{},changes:[],clearEvents:[],expansionGrants:{},global:{nextId:1},createdAt:1,updatedAt:1});
|
||||
const world=revision=>({revision,rowRevision:revision,boardVersions:{},changes:[],clearEvents:[],expansionGrants:{},global:{nextId:1,worldGeneration:BuildMeta.WORLD_GENERATION},createdAt:1,updatedAt:1});
|
||||
|
||||
(async()=>{
|
||||
fs.mkdirSync(boardsDir,{recursive:true});
|
||||
|
|
@ -37,4 +38,4 @@ const world=revision=>({revision,rowRevision:revision,boardVersions:{},changes:[
|
|||
assert.equal(JSON.parse(fs.readFileSync(playerFile,'utf8')).earnedScore,100);
|
||||
assert.equal(fs.existsSync(commitFile),false);
|
||||
console.log('Shared-world commit recovery and retired-version collection passed');
|
||||
})().finally(()=>fs.rmSync(dataDir,{recursive:true,force:true})).catch(error=>{console.error(error);process.exitCode=1});
|
||||
})().finally(()=>fs.rmSync(dataRoot,{recursive:true,force:true})).catch(error=>{console.error(error);process.exitCode=1});
|
||||
|
|
|
|||
|
|
@ -8,9 +8,10 @@ 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 dataDir=fs.mkdtempSync(path.join(os.tmpdir(),'link-field-shared-world-'));
|
||||
const worldDir=path.join(dataDir,'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']
|
||||
env:{...process.env,PORT:String(port),HOST:'127.0.0.1',LINK_FIELD_TEST_DATA_ROOT:dataDir},stdio:['ignore','pipe','pipe']
|
||||
});
|
||||
let stderr='';child.stderr.on('data',chunk=>stderr+=chunk);
|
||||
const base=`http://127.0.0.1:${port}`;
|
||||
|
|
@ -27,8 +28,11 @@ function solvedState(puzzle){return{paths:puzzle.solution.map(route=>({startGate
|
|||
(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 mountedStatus=await request('/~333/link-field/api/cloud/status');assert.equal(mountedStatus.response.status,200);assert.equal(mountedStatus.body.sharedWorld,true);
|
||||
const page=await requestText('/');assert.equal(page.response.status,200);assert(page.body.indexOf('build-meta.js')<page.body.indexOf('puzzle-core.js')&&page.body.indexOf('app-logic.js')>page.body.indexOf('puzzle-core.js')&&page.body.indexOf('app-logic.js')<page.body.indexOf('app.js'));
|
||||
const mountedPage=await requestText('/~333/link-field/');assert.equal(mountedPage.response.status,200);assert.match(mountedPage.body,/LinkField/);
|
||||
const runtimeConfig=await requestText('/runtime-config.js');assert.equal(runtimeConfig.response.status,200);assert.match(runtimeConfig.body,/cloudApi:true/);
|
||||
const mountedRuntimeConfig=await requestText('/~333/link-field/runtime-config.js');assert.equal(mountedRuntimeConfig.response.status,200);assert.match(mountedRuntimeConfig.body,/cloudApi:true/);
|
||||
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;
|
||||
|
|
@ -37,12 +41,12 @@ function solvedState(puzzle){return{paths:puzzle.solution.map(route=>({startGate
|
|||
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 bootstrap={baseRevision:0,global:{schema:31,appVersion:'47.70',generatorVersion:5,worldGeneration:'linkfield-single-world-20260801',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);
|
||||
aliceRealtime=await connectRealtime(base+'/~333/link-field',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);
|
||||
|
||||
|
|
@ -63,8 +67,8 @@ function solvedState(puzzle){return{paths:puzzle.solution.map(route=>({startGate
|
|||
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);
|
||||
const aliceRecord=JSON.parse(fs.readFileSync(path.join(worldDir,`${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(worldDir,'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(worldDir,'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})});
|
||||
|
|
|
|||
|
|
@ -48,5 +48,16 @@ assert.equal(client[1].buyer.length,24);
|
|||
assert.equal(client[1].paidCost,Number.MAX_SAFE_INTEGER);
|
||||
|
||||
const runtimeContext={globalThis:null};runtimeContext.globalThis=runtimeContext;vm.createContext(runtimeContext);vm.runInContext(read('runtime-config.js'),runtimeContext);
|
||||
assert.equal(runtimeContext.BendRuntimeConfig.cloudApi,false,'Static/file mode must not probe the cloud API');
|
||||
assert.equal(runtimeContext.BendRuntimeConfig.cloudApi,true,'LinkField no longer supports a local-only runtime');
|
||||
assert.equal(runtimeContext.BendRuntimeConfig.singleSharedWorld,true,'Runtime must require the single shared world');
|
||||
const hostedRuntimeContext={globalThis:null,URL,location:{protocol:'https:',href:'https://host.example/~333/link-field/'},document:{currentScript:{src:'https://host.example/~333/link-field/runtime-config.js'}}};hostedRuntimeContext.globalThis=hostedRuntimeContext;vm.createContext(hostedRuntimeContext);vm.runInContext(read('runtime-config.js'),hostedRuntimeContext);
|
||||
assert.equal(hostedRuntimeContext.BendRuntimeConfig.cloudApi,true,'HTTP hosting must enable the shared-world API');
|
||||
assert.equal(hostedRuntimeContext.BendRuntimeConfig.appBaseUrl,'https://host.example/~333/link-field/','Hosted runtime did not preserve the application mount path');
|
||||
assert.equal(hostedRuntimeContext.BendRuntimeConfig.apiBridgeUrl,'https://host.example/~333/link-field/api-bridge.php','Hosted runtime did not configure the PHP API bridge');
|
||||
assert.equal(hostedRuntimeContext.BendRuntimeConfig.realtimeTransport,'http-poll','Static hosting must use HTTP realtime polling');
|
||||
const endpointContext={URL,cloudApiBaseUrl:'https://host.example/~333/link-field/api/'};vm.createContext(endpointContext);vm.runInContext(`${functionSource('cloudEndpointUrl')}
|
||||
this.cloudEndpointUrl=cloudEndpointUrl;`,endpointContext);
|
||||
assert.equal(endpointContext.cloudEndpointUrl('/api/cloud/status'),'https://host.example/~333/link-field/api/cloud/status','Cloud API URL lost the mounted application path');
|
||||
const bridgeEndpointContext={URL,cloudApiBaseUrl:'https://host.example/~333/link-field/api-bridge.php',cloudApiBridgeUrl:'https://host.example/~333/link-field/api-bridge.php'};vm.createContext(bridgeEndpointContext);vm.runInContext(`${functionSource('cloudEndpointUrl')}\nthis.cloudEndpointUrl=cloudEndpointUrl;`,bridgeEndpointContext);
|
||||
assert.equal(bridgeEndpointContext.cloudEndpointUrl('/api/cloud/pull?since=7'),'https://host.example/~333/link-field/api-bridge.php?path=%2Fapi%2Fcloud%2Fpull&since=7','PHP bridge URL did not preserve the API path and query');
|
||||
console.log('Canonical build, store, mechanics, runtime mode, and purchase contracts passed');
|
||||
|
|
|
|||
|
|
@ -11,11 +11,11 @@ const outboxContext={
|
|||
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');
|
||||
assert.equal(outboxContext.cloudJournalStateIds.has('B0'),true,'Unfinished shared path was not added to the shared journal');
|
||||
assert.equal(outboxContext.cloudOutboxDeleteKeys.has('state:B0'),false,'Unfinished shared path was incorrectly deleted from the outbox');
|
||||
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']);
|
||||
assert.deepEqual([...outboxContext.logic.currentCloudPending().stateIds],['B0','B1']);
|
||||
|
||||
const AppLogic=loadAppLogic(),now=1_800_000_000_000;
|
||||
const leaseContext={
|
||||
|
|
@ -26,10 +26,8 @@ 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');
|
||||
assert.equal(wait,Infinity,'A non-solving client can generate transient boards that the server will reject');
|
||||
assert.equal(leaseContext.sharedExpansionRepairDelay({solved:true,solvedById:'aaaaaaaaaaaaaaaa',solvedAt:now-100_000}),Infinity,'Expansion authority silently transfers away from the solver');
|
||||
|
||||
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};
|
||||
|
|
@ -51,7 +49,7 @@ const authoritativeContext={
|
|||
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:()=>{},
|
||||
markStateDirty:id=>authoritativeContext.dirtyStateIds.add(id),dirtyStateIds:new Set(),sanitizeStateForPuzzle:()=>{},boardClaimOwnedByMe:()=>false,
|
||||
mergeGlobalFields:()=>{throw new Error('Authoritative shared global unexpectedly used generic merge')},resolveMergedOverlaps:()=>[],statsDirty:false
|
||||
};
|
||||
vm.createContext(authoritativeContext);
|
||||
|
|
@ -76,9 +74,15 @@ assert.equal(authoritativeContext.data.states.B0.solved,true,'A durable clear wa
|
|||
assert.deepEqual([...authoritativeContext.cloudJournalStateIds],['B0'],'The retained clear was not queued for shared upload');
|
||||
assert.equal(authoritativeContext.cloudOutboxDeleteKeys.has('state:B0'),false,'The retained clear was incorrectly scheduled for deletion');
|
||||
|
||||
// Matching unsolved boards keep the player's unfinished line locally while the board definition stays shared.
|
||||
// Matching unsolved boards adopt the authoritative shared progress when this player has no pending claim.
|
||||
authoritativeContext.data.metas.B0=remoteMeta;
|
||||
authoritativeContext.data.states.B0={solved:false,paths:[{startGate:0,cells:[[0,0],[0,1]]}],rev:3_000};
|
||||
authoritativeContext.cloudJournalStateIds.clear();
|
||||
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');
|
||||
assert.equal(authoritativeContext.data.states.B0.paths.length,0,'Authoritative shared progress did not replace stale local unfinished progress');
|
||||
// The active claimant keeps an unsent local update during a revision-conflict pull, then retries it.
|
||||
authoritativeContext.data.states.B0={solved:false,paths:[{startGate:0,cells:[[0,0],[0,1]]}],rev:5_000};
|
||||
authoritativeContext.cloudJournalStateIds.add('B0');authoritativeContext.boardClaimOwnedByMe=()=>true;
|
||||
authoritativeContext.mergeSnapshotIntoData({metas:{B0:{...remoteMeta,rev:6_000}},states:{B0:{solved:false,paths:[],rev:6_000}},nextId:2},{finalize:false,authoritativeWorld:true});
|
||||
assert.equal(authoritativeContext.data.states.B0.paths.length,1,'The active claimant lost an unsent path during conflict recovery');
|
||||
console.log('Shared-world phase 1 client synchronization smoke test passed');
|
||||
|
|
|
|||
|
|
@ -15,8 +15,8 @@ assert(functionSource('inventoryEntries').includes('personalEconomyMode()')&&fun
|
|||
assert(serverSource.includes(".add('POST','/api/player/purchase',handlePurchase)")&&serverSource.includes('assertPlayerCanAfford')&&!serverSource.includes("'/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']});
|
||||
const port=24000+Math.floor(Math.random()*8000),dataDir=fs.mkdtempSync(path.join(os.tmpdir(),'link-field-complete-')),worldDir=path.join(dataDir,'world');
|
||||
const child=spawn(process.execPath,[path.join(root,'server.js')],{env:{...process.env,PORT:String(port),HOST:'127.0.0.1',LINK_FIELD_TEST_DATA_ROOT: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}}
|
||||
|
|
@ -30,14 +30,14 @@ function solvedState(puzzle){return{paths:puzzle.solution.map(route=>({startGate
|
|||
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(),route=[[0,1],[0,0],[1,0],[2,0],[3,0],[4,0],[4,1],[3,1],[2,1],[1,1],[1,2],[0,2],[0,3],[0,4],[1,4],[1,3],[2,3],[2,4],[3,4],[4,4],[4,3],[3,3],[3,2],[4,2]];
|
||||
puzzle.g=[[0,1,'N'],[4,2,'S']];puzzle.n=[[0,0,10]];puzzle.valid=route.map(cell=>[...cell]);puzzle.obstacles=[[2,2]];puzzle.solution=[{startGate:0,endGate:1,cells:route.map(cell=>[...cell])}];puzzle.specialCells={crossings:[],warps:[],locks:[],internalGates:[]};puzzle.maxTurns=10;puzzle.totalTurns=10;
|
||||
puzzle.g=[[0,1,'N'],[4,2,'S']];puzzle.n=[[0,0,16]];puzzle.valid=route.map(cell=>[...cell]);puzzle.obstacles=[[2,2]];puzzle.solution=[{startGate:0,endGate:1,cells:route.map(cell=>[...cell])}];puzzle.specialCells={crossings:[],warps:[],locks:[],internalGates:[]};puzzle.maxTurns=16;puzzle.totalTurns=16;
|
||||
const 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);
|
||||
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:'linkfield-single-world-20260801',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:'reaction',id:'reaction-one',emoji:'🤩',style:'laser',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.style,'laser');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 alicePath=path.join(worldDir,`${alice.playerId}.json`),aliceRecord=JSON.parse(fs.readFileSync(alicePath,'utf8'));aliceRecord.earnedScore=250000;fs.writeFileSync(alicePath,JSON.stringify(aliceRecord));
|
||||
const [renamed,purchase]=await Promise.all([
|
||||
request('/api/cloud/profile',{method:'POST',headers:auth(alice),body:JSON.stringify({name:'Alice Concurrent'})}),
|
||||
request('/api/player/purchase',{method:'POST',headers:auth(alice),body:JSON.stringify({boardId:'B0',itemId:'score-lens'})})
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ const serverSource=read('server.js');
|
|||
const packageVersion=JSON.parse(read('package.json')).version,appVersion=packageVersion.split('.').slice(0,2).join('.');
|
||||
const storeCatalog=JSON.parse(read('store-catalog.json'));
|
||||
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'});
|
||||
assert(buildMeta.APP_VERSION===appVersion&&buildMeta.SAVE_SCHEMA===31&&buildMeta.STORAGE_SCHEMA===30&&buildMeta.IDB_LAYOUT_VERSION===8&&buildMeta.FIELD_STORAGE_FORMAT===2&&buildMeta.GAMEPLAY_DATA_VERSION===3&&buildMeta.WORLD_GENERATION==='v47-field-reset-20260728-interaction-fix','Canonical build metadata does not match the package or persistence contracts');
|
||||
assert(buildMeta.APP_VERSION===appVersion&&buildMeta.SAVE_SCHEMA===31&&buildMeta.STORAGE_SCHEMA===30&&buildMeta.IDB_LAYOUT_VERSION===8&&buildMeta.FIELD_STORAGE_FORMAT===2&&buildMeta.GAMEPLAY_DATA_VERSION===3&&buildMeta.WORLD_GENERATION==='linkfield-single-world-20260801','Canonical build metadata does not match the package or persistence contracts');
|
||||
for(const marker of [
|
||||
'SPECIAL_CELL_MIN_LEVEL=5',
|
||||
'shapeCandidatesForLevel','addSpecialCellPattern','addWarpSpecial','addLockSpecial','addCrossingSpecial',
|
||||
|
|
@ -28,8 +28,8 @@ assert(html.includes('id="clearFeed"')&&css.includes('.clear-feed-item'),'Shared
|
|||
assert(serverSource.includes("const WORLD_FILE = path.join(DATA_DIR, 'shared-world.json')")&&serverSource.includes(".add('POST','/api/cloud/profile',handleCloudProfile)")&&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(!functionSource('noteCloudRow').includes("solved!==true")&&functionSource('currentCloudPending').includes('stateIds:[...cloudJournalStateIds]'),'Unfinished shared paths are still excluded from the durable outbox');
|
||||
assert(functionSource('canExpandSharedBoard').includes('state.solvedById===currentPlayerId()')&&functionSource('repairExpansions').includes('canExpandSharedBoard(meta,st)'),'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');
|
||||
|
|
@ -47,7 +47,7 @@ assert(!css.includes('.board-card:not(.active)'),'Unselected-board styling remai
|
|||
assert(html.includes('id="boardHudLayer"')&&css.includes('.board-label.hud-visible:not([hidden])')&&css.includes('.board-label[hidden]{display:none!important}')&&functionSource('boardPlayHudVisible').includes('hudBoardId===b?.id')&&functionSource('activateBoardPointerDrag').includes('activateBoardHud(b)')&&functionSource('refreshInteractionState').includes('setBoardHudVisibility(board,boardPlayHudVisible(board))')&&functionSource('renderBoardNow').includes('setBoardHudVisibility(b,hudVisible)'),'Board HUD is not retained for the last manipulated board or is still clipped inside the 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('updateSelectedProgress')==='function updateSelectedProgress(){}'&&!html.includes('id="selectedInfo"'),'Top HUD still exposes the current board 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');
|
||||
|
|
@ -56,7 +56,7 @@ assert(!app.includes('level-min-10')&&!app.includes('level-max-10')&&!app.includ
|
|||
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(!app.includes('shouldTeleportToUnsolvedBoard')&&!app.includes('makeStaticBoard')&&!app.includes('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(html.includes('id="fpsCounter"')&&functionSource('refreshFpsCounter').includes('FPS ${fpsLastValue}')&&app.includes('MAX_RENDER_FPS=30')&&app.includes('AUXILIARY_FPS=30')&&app.includes('DRAG_TARGET_FPS=30'),'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')&&css.includes('.board-input-surface')&&css.includes('body.is-drawing #viewport')&&functionSource('updateCustomCursorFromPointer').includes("classList.contains('is-drawing')"),'Custom cursor coverage or drag-time cursor hiding is incomplete');
|
||||
|
||||
|
|
@ -94,11 +94,11 @@ assert(functionSource('rebuildWorldOverviewCache').includes('drawMapBoardCells')
|
|||
assert(!app.includes('makeStaticBoard')&&!css.includes('.static-summary'),'Inactive static-board LOD code remains');
|
||||
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(yellowFaces.length===97&&!yellowFaces.some(row=>/^(?:1FAE9|1FAEA|1F642 200D 219[45] FE0F)\|/.test(row)),'Glitchy or undisplayed yellow-face cursors remain');
|
||||
const faceContracts=storeCatalog.filter(item=>item.id.startsWith('cursor-face-'));
|
||||
assert(faceContracts.length===101&&Math.min(...faceContracts.map(item=>item.cost))===500&&Math.max(...faceContracts.map(item=>item.cost))===50000&&app.includes('MAX_FACE_CURSOR_PRICE=50000')&&functionSource('storeItemPrice').includes('Math.min(MAX_FACE_CURSOR_PRICE,adjusted)'),'Canonical yellow-face cursor prices do not span 500-50000 gems');
|
||||
assert(faceContracts.length===97&&Math.min(...faceContracts.map(item=>item.cost))===500&&Math.max(...faceContracts.map(item=>item.cost))===50000&&app.includes('MAX_FACE_CURSOR_PRICE=50000')&&functionSource('storeItemPrice').includes('Math.min(MAX_FACE_CURSOR_PRICE,adjusted)'),'Canonical supported yellow-face cursor prices do not span 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');
|
||||
assert(flagCodes.length===259&&new Set(flagCodes).size===259&&app.includes("['gbeng','イングランド'],['gbsct','スコットランド'],['gbwls','ウェールズ']"),'Complete Unicode Emoji 17.0 flag cursor catalog is missing');
|
||||
const oecdCodes='AU AT BE CA CL CO CR CZ DK EE FI FR DE GR HU IS IE IL IT JP KR LV LT LU MX NL NZ NO PL PT SK SI ES SE CH TR GB US'.split(' ');
|
||||
assert(oecdCodes.length===38&&oecdCodes.every(code=>storeCatalog.find(item=>item.id===`cursor-flag-${code.toLowerCase()}`)?.cost===20000)&&storeCatalog.filter(item=>item.id.startsWith('cursor-flag-')).every(item=>item.cost===10000||item.cost===20000),'Canonical flag 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'));
|
||||
|
|
@ -107,10 +107,10 @@ assert(functionSource('syncCursorAppearance').includes("image.src=selected.flagA
|
|||
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 drag-time cursor hiding 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('renderStorePanel').includes('storeInventoryItems(meta,store)')&&functionSource('purchaseStoreItem').includes('storeInventoryItems(meta,store).some'),'Store UI or purchase validation bypasses its seeded eighteen-item inventory');
|
||||
assert(functionSource('seededStoreItemIds').includes('.slice(0,6)')&&functionSource('seededStoreItemIds').includes('6-fixedTools.length')&&functionSource('seededStoreItemIds').includes('item.scoreLens')&&functionSource('maybeOpenStore').includes('itemIds:seededStoreItemIds(meta.seed)'),'Stores do not persist six seeded cursors and six non-cursor items');
|
||||
assert(['カーソル','その他のアイテム'].every(title=>functionSource('renderStorePanel').includes(`title:'${title}'`))&&functionSource('renderStorePanel').includes('.slice(0,6)')&&functionSource('renderStorePanel').includes('store-section-brief')&&css.includes('.store-compact-list{grid-template-columns:repeat(6'),'Shop is not arranged as six cursors above six described non-cursor items');
|
||||
assert(functionSource('createInventoryCategoryView').includes("'inventory-cursor-grid'")&&functionSource('updateInventoryItemView').includes("option.classList.toggle('selected'")&&functionSource('useInventoryItemLoaded').includes("previousCursor===item.cursorStyle?'default':item.cursorStyle"),'Persistent click-to-toggle cursor inventory is missing');
|
||||
assert(!app.includes('static-shop-icon')&&functionSource('beginPan').includes('nearestStoreMetaAtWorldPoint')&&!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');
|
||||
|
|
@ -151,7 +151,7 @@ for(let level=1;level<=10;level++){
|
|||
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))};
|
||||
const normalizeContext={AppLogic,LINE_COLORS:Array(10).fill('#000'),LINE_EFFECT_IDS:new Set(['glow','neon']),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');
|
||||
|
|
@ -160,7 +160,7 @@ const stored=normalizeContext.logic.puzzleForStorage(normalized);assert(stored.s
|
|||
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('closedVoidRepairCandidates').includes('closedVoidRepair:true')&&functionSource('repairExpansions').includes('closedVoidRepairCandidates().filter')&&functionSource('repairExpansions').includes('.slice(0,2)')&&functionSource('pendingExpansionCount').includes('closedVoidRepairCandidates().filter'),'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`);
|
||||
|
|
|
|||
|
|
@ -39,8 +39,9 @@ async function waitForServer(){
|
|||
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'),
|
||||
const itemSections=[...document.querySelectorAll('.store-items-section')],cursorSection=document.querySelector('.store-cursors-section'),
|
||||
cursorCards=[...cursorSection.querySelectorAll('.store-item')],itemCards=[...document.querySelectorAll('.store-other')],
|
||||
itemList=itemSections.map(section=>section.querySelector('.store-section-list')).find(list=>list.querySelector('.store-item')),
|
||||
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);
|
||||
|
|
@ -49,7 +50,7 @@ async function waitForServer(){
|
|||
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,
|
||||
items:itemCards.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,
|
||||
|
|
@ -95,10 +96,10 @@ async function waitForServer(){
|
|||
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/10)<1e-12,'Cursor prices or the 1/10 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.headings.includes('カーソル')&&result.headings.includes('その他のアイテム')&&result.headings.length===2,'Shop cosmetic, tool, and cursor sections are not separated');
|
||||
assert(result.items===12&&result.cursors===6,'Shop does not render its 12+6 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.itemColumns===6&&result.cursorColumns===6&&result.cursorVertical,'Shop cosmetics and cursor designs are not compact horizontal grids 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');
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
'use strict';
|
||||
const {assert,functionSource,app,css,html,read,loadBendPuzzle,root,fs,path,buildMeta}=require('./helpers/app-source');
|
||||
assert(buildMeta.APP_VERSION==='47.83'&&buildMeta.PACKAGE_VERSION==='47.83.0','Version was not advanced to v47.83');
|
||||
assert(buildMeta.APP_VERSION==='48.0'&&buildMeta.PACKAGE_VERSION==='48.0.0','Version was not advanced to v47.84');
|
||||
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');
|
||||
|
|
@ -26,4 +26,4 @@ assert(hasLevel6Region&&hasLevel10Region,'World difficulty map still lacks level
|
|||
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.83 settings, map rendering, generation bonus, docs policy, and high-level generation regression test passed');
|
||||
console.log('v47.84 settings, map rendering, generation bonus, docs policy, and high-level generation regression test passed');
|
||||
|
|
|
|||
|
|
@ -8,4 +8,4 @@ assert(functionSource('renderDragFrame').includes("setSvgAttr(cache.liveTail,'x2
|
|||
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.83 active HUD, cached overview, diagonal drag, and drag performance regression test passed');
|
||||
console.log('v47.84 active HUD, cached overview, diagonal drag, and drag performance regression test passed');
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ for(const name of ['scheduleBoardDragFrame','queueCameraInteraction','scheduleWo
|
|||
const source=functionSource(name);
|
||||
if(name==='queueCameraInteraction')assert(source.includes('cameraInteractionScheduler.push')&&frameScheduler.includes('clearArmed()'),'Camera missed-vsync fallback does not cancel its paired scheduler');
|
||||
else if(name==='scheduleBoardDragFrame')assert(source.includes('requestFrame')&&frameScheduler.includes('clearArmed()'),'Pickup missed-vsync fallback does not cancel its paired scheduler');
|
||||
else if(name==='scheduleReactionRender')assert(source.includes('reactionDelayTimer=setTimeout')&&source.includes('reactionNextDrawAt'),'Reaction rendering does not use its deadline timer to avoid high-refresh RAF polling');
|
||||
else if(name!=='scheduleWorldOverview')assert(!source.includes('setTimeout('),`${name} still double-throttles through setTimeout plus requestAnimationFrame`);
|
||||
if(name==='scheduleBoardDragFrame'||name==='queueCameraInteraction')assert(frameScheduler.includes('requestFrame(step)'),`${name} must use the shared frame-synchronized lane`);
|
||||
else assert(source.includes('requestAnimationFrame'),`${name} must remain frame-synchronized`);
|
||||
|
|
@ -23,5 +24,5 @@ assert(camera.includes('minimapDirty=true'),'Camera movement must mark, not imme
|
|||
assert(functionSource('boardPlayHudVisible').includes('hudBoardId===b?.id')&&functionSource('activateBoardPointerDrag').includes('activateBoardHud(b)'),'Persistent manipulated-board HUD gating changed unexpectedly');
|
||||
assert(!css.includes('#viewport.panning::after{display:none}')&&!css.includes('#world.camera-interacting'),'Panning still activates an automatic lightweight visual mode');
|
||||
assert(css.includes('#presenceCanvas,#reactionCanvas{transform-origin:0 0'),'Online canvases are not compositor-ready');
|
||||
assert(buildMeta.APP_VERSION==='47.83','Application version was not advanced');
|
||||
console.log('v47.83 compositor frame-pipeline regression test passed');
|
||||
assert(buildMeta.APP_VERSION==='48.0','Application version was not advanced');
|
||||
console.log('v47.84 compositor frame-pipeline regression test passed');
|
||||
|
|
|
|||
|
|
@ -16,5 +16,5 @@ assert(functionSource('syncCursorAppearance').includes('dataset.cursorMode=prese
|
|||
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(buildMeta.APP_VERSION==='47.83','Application version was not advanced');
|
||||
console.log('v47.83 active HUD, cursor coverage, and capped knob tracking regression test passed');
|
||||
assert(buildMeta.APP_VERSION==='48.0','Application version was not advanced');
|
||||
console.log('v47.84 active HUD, cursor coverage, and capped knob tracking regression test passed');
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ const fs=require('fs'),path=require('path'),browserBenchmark=fs.readFileSync(pat
|
|||
frameScheduler=fs.readFileSync(path.join(__dirname,'../client/input/frame-scheduler.js'),'utf8'),
|
||||
dragModule=fs.readFileSync(path.join(__dirname,'../client/input/drag.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=34'),'Interaction and auxiliary frame budgets are not separated');
|
||||
assert(app.includes('MAX_RENDER_FPS=30')&&app.includes('AUXILIARY_FPS=30')&&app.includes('DRAG_TARGET_FPS=30')&&app.includes('DRAG_FRAME_INTERVAL=1000/DRAG_TARGET_FPS')&&app.includes('CAMERA_DISPLAY_WATCHDOG_MS=34'),'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_LIVE_CATCHUP_CELLS=6')&&app.includes('DRAG_MAX_RELEASE_CATCHUP_CELLS=24')&&functionSource('extendPointerTo').includes('catchupPending')&&functionSource('processBoardDragFrame').includes('catchupPending'),'Large pointer jumps are not bounded and resumed across drag frames');
|
||||
|
||||
|
|
@ -52,7 +52,7 @@ assert(functionSource('finalizeAtGate').includes('usesLightweightDragOverlay(b)'
|
|||
assert(bind.includes("flushBoardPointerMove(b,e,()=>finishPointer(e,'settled'))")&&bind.includes('b.releaseDrain?.pointerId===e.pointerId'),'Pointer-up settlement does not wait for the bounded drain or lost capture can cancel an active drain');
|
||||
|
||||
assert(functionSource('runDeferredSave').includes("interactionActive('persistence')"),'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('pullCloudWorld').includes('await waitForInteractionSettle()')&&!app.includes('applyWorldSignal'),'Server reconciliation can run broad refresh work during a gesture or retired cross-tab reconciliation remains');
|
||||
assert(functionSource('createPuzzleWorker').includes("perfCount('workerResultsDeferredDuringInteraction')")&&functionSource('createPuzzleWorker').includes("waitForInteractionSettle(null,'worker').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');
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
'use strict';
|
||||
const {assert,functionSource,app,css,html,read,vm,buildMeta}=require('./helpers/app-source');
|
||||
const packageVersion=JSON.parse(read('package.json')).version;
|
||||
assert(packageVersion===buildMeta.PACKAGE_VERSION&&buildMeta.APP_VERSION==='47.83','v47.83 canonical version marker is missing');
|
||||
assert(packageVersion===buildMeta.PACKAGE_VERSION&&buildMeta.APP_VERSION==='48.0','v47.84 canonical version marker is missing');
|
||||
assert(html.includes('id="resetSettings"')&&html.includes('id="closeSettings"')&&!html.includes('id="saveSettings"'),'Settings actions must expose Initial reset and Close without a Save button');
|
||||
assert(functionSource('closeSettings').includes('saveSettings(restoreFocus)')&&functionSource('saveSettings').includes('closeSettingsDialog(restoreFocus)')&&functionSource('resetSettingsForm').includes('lightweightRenderingToggle.checked=false'),'Close does not apply settings or Initial reset is incomplete');
|
||||
assert(functionSource('applyUiSettings').includes("classList.toggle('lightweight-rendering',uiSettings.lightweightRendering)")&&functionSource('applyUiSettings').includes("classList.toggle('reduced-effects',uiSettings.lightweightRendering)")&&!app.includes('autoReducedEffects')&&!app.includes('interactionQualityDowngradePending'),'Panning/interaction still enables an automatic lightweight mode');
|
||||
|
|
@ -20,4 +20,4 @@ const hudStyle={removeProperty(name){delete this[name]}},hudLabel={hidden:true,c
|
|||
vm.createContext(hudContext);vm.runInContext(`${functionSource('positionBoardLabel')}this.positionBoardLabel=positionBoardLabel;`,hudContext);const hudBoard={label:hudLabel,meta:{},p:{bounds:{w:5,h:5}}};
|
||||
assert(hudContext.positionBoardLabel(hudBoard)&&parseFloat(hudLabel.style.top)>=8&&parseFloat(hudLabel.style.top)<=308,'Detached HUD is not clamped inside the visible viewport');
|
||||
|
||||
console.log('v47.83 settings, pan rendering, detached HUD, cursor, and knob regression test passed');
|
||||
console.log('v47.84 settings, pan rendering, detached HUD, cursor, and knob regression test passed');
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
'use strict';
|
||||
const {assert,functionSource,app,css,html,read,vm,buildMeta}=require('./helpers/app-source');
|
||||
const packageVersion=JSON.parse(read('package.json')).version;
|
||||
assert(packageVersion===buildMeta.PACKAGE_VERSION&&buildMeta.APP_VERSION==='47.83','v47.83 canonical version marker is missing');
|
||||
assert(packageVersion===buildMeta.PACKAGE_VERSION&&buildMeta.APP_VERSION==='48.0','v47.84 canonical version marker is missing');
|
||||
|
||||
const bindBoard=functionSource('bindBoard');
|
||||
assert(bindBoard.includes('const release=(paintCurrent=false)=>{')&&bindBoard.includes('cancelBoardDragFrame(b);clearDragRender(b);hidePickupHandleOverlay()'),'Pointer release does not synchronously stop the drag scheduler and remove the pickup overlay');
|
||||
|
|
@ -13,10 +13,10 @@ assert(css.includes('#pickupHandleOverlay.custom-cursor.visible{display:grid}')&
|
|||
assert(functionSource('syncCursorAppearance').includes('syncPickupHandleDesign()'),'Changing cursor style does not refresh the pickup appearance');
|
||||
|
||||
const merge=functionSource('mergeSnapshotIntoData');
|
||||
assert(merge.includes('authoritativeCompatibleStateIds=authoritativeWorld?new Set():null')&&merge.includes('retainLocalSolve=compatible&¤t?.solved===true&&incoming?.solved!==true')&&merge.includes("if(retainLocalSolve)noteCloudRow('state',id)")&&merge.includes('merged=compatible?mergeBoardStates(current,incoming):deepClone(incoming)')&&!merge.includes('current?.solved&&!incoming.solved?deepClone(incoming)'),'Authoritative pull can still downgrade a compatible local clear or reuse a clear across replaced board geometry');
|
||||
assert(merge.includes('authoritativeCompatibleStateIds=authoritativeWorld?new Set():null')&&merge.includes('retainLocalSolve=compatible&¤t?.solved===true&&incoming?.solved!==true')&&merge.includes('retainClaimedPending=compatible')&&merge.includes("if(retainLocalSolve){noteCloudRow('state',id);merged=deepClone(current)}")&&merge.includes('else{clearSharedWorldJournalRow')&&!merge.includes('merged=compatible?mergeBoardStates(current,incoming):deepClone(incoming)'),'Authoritative pull can still downgrade a compatible local clear, erase a claimant retry, or merge stale unfinished paths');
|
||||
const completion=functionSource('checkSolvedAndExpand');
|
||||
const cloudFailure=completion.slice(completion.indexOf('const published=await pushCloudPending()'),completion.indexOf('let durableMeta='));
|
||||
assert(completion.includes('writeDirtyRecoveryJournal();')&&cloudFailure.includes("toast('クリアは端末に保存しました。共有反映は自動で再試行します。')")&&cloudFailure.includes('armCloudPush(1000)')&&!cloudFailure.includes('data.states[b.id]=previous.state'),'Completion is not checkpointed immediately or is still rolled back on a transient cloud failure');
|
||||
const cloudFailure=completion.slice(completion.indexOf('let published=false'),completion.indexOf('const immediateBoard='));
|
||||
assert(completion.includes('writeDirtyRecoveryJournal();')&&cloudFailure.includes('for(let attempt=0;attempt<3&&!published;attempt++)')&&cloudFailure.includes('data.states[b.id]=previous.state')&&cloudFailure.includes('await persistNow({skipCloud:true})'),'Completion is not verified by the shared server or is left locally solved after publication failure');
|
||||
|
||||
const overlay={dataset:{},classList:{values:new Set(),toggle(name,on){on?this.values.add(name):this.values.delete(name)}},replaceChildren(node){this.child=node;this.textContent=''},textContent:'',style:{}};
|
||||
const cursorItems=new Map([['emoji-test',{cursorStyle:'emoji-test',cursorEmoji:'🙂'}]]);
|
||||
|
|
@ -26,4 +26,4 @@ assert(context.syncPickupHandleDesign()===true&&overlay.textContent==='🙂'&&ov
|
|||
context.data.cursorStyle='flag-test';cursorItems.set('flag-test',{cursorStyle:'flag-test',flagAsset:'flag.svg'});context.syncPickupHandleDesign();
|
||||
assert(overlay.child?.src==='flag.svg'&&overlay.classList.values.has('flag-cursor'),'Flag cursor was not applied to the pickup overlay');
|
||||
|
||||
console.log('v47.83 pickup release, durable completion, and cursor-design regression test passed');
|
||||
console.log('v47.84 pickup release, durable completion, and cursor-design regression test passed');
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
'use strict';
|
||||
const {assert,functionSource,app,html,read,buildMeta}=require('./helpers/app-source');
|
||||
const packageVersion=JSON.parse(read('package.json')).version;
|
||||
assert(packageVersion===buildMeta.PACKAGE_VERSION&&buildMeta.APP_VERSION==='47.83','v47.83 canonical version marker is missing');
|
||||
assert(packageVersion===buildMeta.PACKAGE_VERSION&&buildMeta.APP_VERSION==='48.0','v47.84 canonical version marker is missing');
|
||||
|
||||
const hud=functionSource('boardPlayHudVisible'),activateHud=functionSource('activateBoardHud'),select=functionSource('selectBoard'),activateDrag=functionSource('activateBoardPointerDrag'),makeBoard=functionSource('makeBoard');
|
||||
assert(hud.includes('hudBoardId===b?.id')&&!hud.includes('b?.drawing'),'HUD still disappears when the pointer draw ends');
|
||||
|
|
@ -16,9 +16,10 @@ assert(overlay.includes('hidePickupHandleOverlay();return false'),'An invalidate
|
|||
assert(finish.includes('if(!b.drawing){cancelBoardDragFrame(b);clearDragRender(b);hidePickupHandleOverlay();safeRelease'),'Pointer-up fallback does not clear a stale pickup overlay');
|
||||
|
||||
const inventoryRender=functionSource('renderInventoryPanel'),inventorySync=functionSource('syncInventoryCursorSelection'),inventoryUse=functionSource('useInventoryItemLoaded');
|
||||
assert(inventoryRender.includes('option.dataset.itemId=item.id')&&inventoryRender.includes('event.preventDefault()'),'Cursor inventory options lack stable item identity or click-default suppression');
|
||||
assert(inventorySync.includes("querySelectorAll('.inventory-cursor-option[data-item-id]')")&&inventorySync.includes("classList.toggle('selected',selected)")&&inventorySync.includes("setAttribute('aria-pressed',String(selected))"),'Cursor selection cannot update in place');
|
||||
const cursorBranch=inventoryUse.slice(inventoryUse.indexOf('if(item.cursorStyle)'),inventoryUse.indexOf('if(item.scoreLens)'));
|
||||
assert(functionSource('createInventoryItemView').includes('option.dataset.itemId=item.id')&&functionSource('createInventoryItemView').includes('event.preventDefault()'),'Cursor inventory options lack stable item identity or click-default suppression');
|
||||
assert(inventorySync.includes('inventorySelectedCursorItemId')&&inventorySync.includes('patchInventoryItems([inventorySelectedCursorItemId,next])'),'Cursor selection cannot update in place');
|
||||
const cursorBranch=inventoryUse.slice(inventoryUse.indexOf('if(item.cursorStyle)'),inventoryUse.indexOf('if(item.lineColor)'));
|
||||
assert(cursorBranch.includes('syncInventoryCursorSelection()')&&!cursorBranch.includes('renderInventoryPanel()')&&!cursorBranch.includes('updateHud()'),'Cursor switching still rebuilds the inventory panel and can move its scroll position');
|
||||
assert(functionSource('renderInventoryPanel').includes('restorePanelScroll(inventoryPanel,scrollPosition)')&&functionSource('renderStorePanel').includes('restorePanelScroll(storePanel,scrollPosition)'),'Item-list rerenders can reset the inventory or shop scroll position');
|
||||
|
||||
console.log('v47.83 persistent board HUD, gate-overlay cleanup, and inventory-scroll regression test passed');
|
||||
console.log('v47.84 persistent board HUD, gate-overlay cleanup, and inventory-scroll regression test passed');
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
'use strict';
|
||||
const {assert,functionSource,app,css,html,read,buildMeta}=require('./helpers/app-source');
|
||||
const packageVersion=JSON.parse(read('package.json')).version;
|
||||
assert(packageVersion===buildMeta.PACKAGE_VERSION&&buildMeta.APP_VERSION==='47.83','v47.83 canonical version marker is missing');
|
||||
assert(packageVersion===buildMeta.PACKAGE_VERSION&&buildMeta.APP_VERSION==='48.0','v47.84 canonical version marker is missing');
|
||||
|
||||
const sound=functionSource('playSound');
|
||||
assert(app.includes('SOUND_GAIN_MULTIPLIER=5.2')&&functionSource('soundTone').includes('Math.min(.42')&&functionSource('soundNoise').includes('Math.min(.24'),'Requested sound level increase is missing');
|
||||
|
|
@ -9,9 +9,7 @@ for(const kind of['grab','stretch','gate','clear','buy','reset','remove','shop',
|
|||
assert(functionSource('finalizeAtGate').includes("playSound('gate')")&&functionSource('removeDetachedPathAtOwnEndpoint').includes("playSound('remove')")&&functionSource('beginMinimapPointer').includes("playSound('shop')"),'New sound variants are not wired to gameplay events');
|
||||
|
||||
const makeBoard=functionSource('makeBoard');
|
||||
assert(makeBoard.includes("class:'active-board-boundary-layer'")&&makeBoard.includes("class:'active-board-boundary-shadow'")&&makeBoard.includes("class:'active-board-boundary-dash'"),'Active-board boundary layers are missing');
|
||||
assert(css.includes('.board-card.hud-current:not(.solved) .active-board-boundary-layer{display:block}')&&css.includes('@keyframes activeBoardOrbit')&&css.includes('@keyframes activeBoardBreathe'),'Active-board orbit/blink styling is missing');
|
||||
assert(css.includes('body.lightweight-rendering .active-board-boundary-layer{display:none!important}'),'Active-board boundary remains visible in lightweight rendering');
|
||||
assert(!makeBoard.includes('active-board-boundary')&&!css.includes('active-board-boundary')&&!css.includes('@keyframes activeBoardOrbit'),'Retired active-board orbit boundary remains');
|
||||
|
||||
assert(html.includes('<div class="modal-actions settings-actions"><button class="pill quiet" id="resetSettings"')&&html.includes('</button><button class="pill close" id="closeSettings"'),'Settings reset and close buttons are not adjacent in document order');
|
||||
assert(css.includes('.settings-actions{display:flex;align-items:center;justify-content:flex-end;gap:8px}')&&css.includes('.settings-actions .pill{flex:1 1 0;width:auto;margin:0!important}'),'Settings actions are not horizontally aligned');
|
||||
|
|
@ -35,4 +33,4 @@ assert(gatePoint.includes('if(g.internal)return[x,y]')&&outside.includes('if(g?.
|
|||
assert(makeBoard.includes("'aria-label':g.internal?`盤面内ゲート ${i+1}`")&&makeBoard.includes('gateHitBox(gp,g.side,g.internal)'),'Internal gates do not share normal gate input affordances');
|
||||
for(const name of['matchingNeighborGate','gateFrontierCandidates','missingGateConnections','placementConnectionRequirements'])assert(functionSource(name).includes('internalGates'),`${name} can misclassify an internal gate as a world-expansion gate`);
|
||||
|
||||
console.log('v47.83 audio, active-board emphasis, shop navigation, removal repaint, and internal-gate regression test passed');
|
||||
console.log('v47.84 audio, active-board emphasis, shop navigation, removal repaint, and internal-gate regression test passed');
|
||||
|
|
|
|||
|
|
@ -3,12 +3,12 @@ const vm=require('vm');
|
|||
const {assert,functionSource,app,css,html,read,buildMeta}=require('./helpers/app-source');
|
||||
const server=read('server.js');
|
||||
const packageVersion=JSON.parse(read('package.json')).version;
|
||||
assert(packageVersion===buildMeta.PACKAGE_VERSION&&buildMeta.APP_VERSION==='47.83','v47.83 canonical version marker is missing');
|
||||
assert(packageVersion===buildMeta.PACKAGE_VERSION&&buildMeta.APP_VERSION==='48.0','v47.84 canonical version marker is missing');
|
||||
|
||||
assert(html.includes('<div class="minimap-head"><b>マップ</b></div>')&&!html.includes('minimapStatus')&&!html.includes('周辺マップ'),'Map title or count removal is incomplete');
|
||||
assert(html.includes('<span class="shop">ショップ</span>')&&functionSource('makeBoard').includes('<small>ショップ</small>'),'Shop labels were not localized');
|
||||
assert(!functionSource('drawMinimap').includes('nearbyPlayers')&&!functionSource('drawMinimap').includes('visibleMetas.length')&&!functionSource('drawMinimap').includes('storeCount||0'),'Map still exposes player, board, or shop counts');
|
||||
assert(css.includes('.active-board-boundary-dash{stroke:#f5feff;stroke-width:9;stroke-dasharray:1 18'),'Active-board orbit dots were not thickened to the shadow scale');
|
||||
assert(!css.includes('.active-board-boundary-dash'),'Retired active-board orbit dots remain');
|
||||
|
||||
const renderDrag=functionSource('renderDragFrame'),overlay=functionSource('updatePickupHandleOverlay');
|
||||
assert(functionSource('activeDrawingLineColorIndex').includes('startColorIndex')&&overlay.includes('activeDrawingLineColorIndex(path)'),'Pickup overlay color is not derived from the actively drawn line');
|
||||
|
|
@ -23,7 +23,7 @@ assert(server.includes('meta?.puzzle?.obstacles||[]')&&server.includes('pathInde
|
|||
assert(functionSource('personalEconomyMode').includes('cloudProfile'),'Personal economy mode is not stable across transient connectivity changes');
|
||||
for(const name of['inventoryEntries','spentScoreTotal','pruneAndCount'])assert(functionSource(name).includes('personalEconomyMode()'),`${name} can fall back to shared-board purchases during an outage`);
|
||||
assert(functionSource('purchaseStoreItem').includes("personalEconomyMode()&&!onlinePlayerEconomy()")&&functionSource('purchaseStoreItem').includes('共有の所持数を確認できないため購入できません。'),'Offline personal purchases are not blocked safely');
|
||||
assert(functionSource('mergeGlobalRecords').includes('playerEarnedScore=Math.max')&&functionSource('applyPlayerEconomyEnvelope').includes('playerEarnedScore=Math.max'),'A stale global/player response can still reduce earned gems');
|
||||
assert(functionSource('applyPlayerEconomyEnvelope').includes('data.playerEarnedScore=Number.isSafeInteger(player.earnedScore)')&&!functionSource('applyPlayerEconomyEnvelope').includes('playerEarnedScore=Math.max'),'Personal gem balance is not server-authoritative');
|
||||
assert(functionSource('rememberStateSignatures').includes('!personalEconomyMode()'),'World-store purchase deltas can still reduce personal gems');
|
||||
|
||||
assert(functionSource('finalizeAtGate').includes('commitConnectedLineVisuals(b,pi)')&&functionSource('joinTips').includes('commitConnectedLineVisuals'),'Connected-line visuals are not scheduled after connection');
|
||||
|
|
@ -31,7 +31,7 @@ assert(functionSource('commitConnectedLineVisuals').includes('invalidateLineGrap
|
|||
|
||||
const preserveSource=functionSource('preserveSolvedBoardState');
|
||||
assert(preserveSource.includes('solvedSource?._summaryOnly?baseState:solvedSource')&&preserveSource.includes('preserved.solved=true')&&preserveSource.includes('delete preserved._summaryOnly'),'Solved summary recovery can still erase full route data');
|
||||
assert(functionSource('applyWorldSignal').includes('current?.solved&&!incoming.solved&&compatible')&&functionSource('applyWorldSignal').includes('preserveSolvedBoardState(current,incoming)'),'Cross-tab stale unsolved records can still overwrite a clear');
|
||||
assert(!app.includes('applyWorldSignal')&&!app.includes('BroadcastChannel'),'Retired cross-tab board reconciliation remains');
|
||||
assert(functionSource('hydrateMeta').includes('currentState?.solved&&!loadedState.solved')&&functionSource('hydrateMeta').includes('preserveSolvedBoardState(currentState,loadedState)'),'Rehydration can still restore an older unsolved state');
|
||||
assert(functionSource('evictHydratedBoardDetails').includes('summarizeBoardV2(target,currentState'),'Eviction can still cache a stale pre-clear summary');
|
||||
|
||||
|
|
@ -41,4 +41,4 @@ const summary={_summaryOnly:true,solved:true,expanded:true,solvedBy:'A',scoreAwa
|
|||
const preserved=context.preserveSolvedBoardState(summary,incoming);
|
||||
assert(preserved.solved===true&&preserved.paths.length===1&&preserved.paths[0].cells.length===2&&!('_summaryOnly' in preserved),'Solved-summary repair did not retain incoming full route data');
|
||||
|
||||
console.log('v47.83 map, store, economy, line inheritance, and clear persistence regression test passed');
|
||||
console.log('v47.84 map, store, economy, line inheritance, and clear persistence regression test passed');
|
||||
|
|
|
|||
20
test/v4784-user-request-smoke-test.js
Normal file
20
test/v4784-user-request-smoke-test.js
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
'use strict';
|
||||
const {assert,app,css,html,read,functionSource,buildMeta}=require('./helpers/app-source');
|
||||
const catalog=JSON.parse(read('store-catalog.json')),server=read('server.js');
|
||||
const retired=['glow','pulse','dash','starlight','neon','prism','comet'];
|
||||
assert(retired.every(effect=>!catalog.some(item=>item.lineEffect===effect)&&!css.includes(`line-effect-${effect}`)),`A retired/light-emitting line effect remains: ${retired.find(effect=>catalog.some(item=>item.lineEffect===effect)||css.includes(`line-effect-${effect}`))}`);
|
||||
const aurora=catalog.find(item=>item.id==='line-effect-aurora');
|
||||
assert(aurora?.lineColor&&aurora.aurora===true&&!aurora.lineEffect&&catalog.filter(item=>item.lineEffect).length===0,'Aurora is not classified as a shop-only line color');
|
||||
assert(app.includes('AURORA_COLOR_INTERVAL=2000')&&app.includes('AURORA_RGB_PALETTE=Object.freeze')&&functionSource('nextAuroraRgb').includes('auroraPaletteIndex')&&css.includes('transition:stroke 2s linear')&&css.includes('.gate-dot.line-effect-aurora'),'Aurora does not use the curated two-second line/gate palette');
|
||||
assert(app.includes('MAX_RENDER_FPS=30')&&app.includes('AUXILIARY_FPS=30')&&app.includes('REACTION_TARGET_FPS=30')&&app.includes('DRAG_TARGET_FPS=30')&&functionSource('applyCamera').includes('GLOBAL_FRAME_INTERVAL'),'Not all manual visual pipelines are capped at 30 FPS');
|
||||
assert(app.includes("location.pathname.endsWith('/debug-items')")&&server.includes("pathname.endsWith('/debug-items')")&&!html.includes('debugAllItemsToggle')&&!app.includes('デバッグ使用可'),'Debug access is not URL-only or still leaks into the item UI');
|
||||
assert(functionSource('purchaseStoreItem').includes('paidCost:0')&&functionSource('purchaseStoreItem').includes('if(!debug&&data.score<price)'),'Debug URL does not retain the normal purchase flow with free debug settlement');
|
||||
const seeded=functionSource('seededStoreItemIds');
|
||||
assert(seeded.includes('cursorPool.slice(0,6)')&&seeded.includes('6-fixedTools.length')&&seeded.includes('return[...cursorPool.slice(0,6),...selectedOthers]'),'Store stock is not six cursors followed by six seeded non-cursor items');
|
||||
const store=functionSource('renderStorePanel');
|
||||
assert(store.includes("title:'カーソル'")&&store.includes("title:'その他のアイテム'")&&store.includes('.slice(0,6)')&&!app.includes('陳列は店ごとに異なります')&&!app.includes('便利な機能から6点を陳列しています。'),'Shop layout or removed copy is incorrect');
|
||||
const inventoryCategory=functionSource('createInventoryCategoryView');
|
||||
assert(inventoryCategory.includes("document.createElement('details')")&&inventoryCategory.includes("document.createElement('summary')")&&inventoryCategory.includes('inventoryCollapsedCategories'),'Inventory categories cannot be collapsed');
|
||||
assert(!app.includes("title:'Line Colors'")&&!app.includes("title:'Line Effects'")&&!app.includes("title:'Emoji Effects'")&&!app.includes("title:'Tools'")&&!app.includes("title:'Cursors'")&&!html.includes('>SETTINGS<')&&!html.includes('>DEBUG<'),'Visible English category or panel labels remain');
|
||||
assert(buildMeta.WORLD_GENERATION==='linkfield-single-world-20260801'&&app.includes('STARTER_SEED=0x9c37a5e1')&&functionSource('readWorld',server).includes('value.global?.worldGeneration!==BuildMeta.WORLD_GENERATION'),'Board generation was not fully reset with a new seed on client and server');
|
||||
console.log('v47.87 user-requested Aurora, debug URL, FPS, shop, and inventory guards passed');
|
||||
35
test/v4785-cosmetics-shop-smoke-test.js
Normal file
35
test/v4785-cosmetics-shop-smoke-test.js
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
'use strict';
|
||||
const {assert,app,css,read,functionSource,vm}=require('./helpers/app-source');
|
||||
const catalog=JSON.parse(read('store-catalog.json')),generatedContext={};
|
||||
vm.createContext(generatedContext);vm.runInContext(read('store-catalog.generated.js'),generatedContext);const generated=generatedContext.BendStoreCatalog;
|
||||
assert(JSON.stringify(generated)===JSON.stringify(catalog),'Generated store catalog is not synchronized with store-catalog.json');
|
||||
assert(catalog.length===380&&new Set(catalog.map(item=>item.id)).size===catalog.length,'Expanded store catalog count or IDs are invalid');
|
||||
const colors=catalog.filter(item=>item.lineColor),lineEffects=catalog.filter(item=>item.lineEffect),reactionEffects=catalog.filter(item=>item.reactionStyle),cursors=catalog.filter(item=>item.cursorStyle),aurora=catalog.find(item=>item.id==='line-effect-aurora');
|
||||
assert(colors.length===15&&lineEffects.length===0&&reactionEffects.length===5&&cursors.length===359&&aurora?.aurora===true,'The requested color, reaction-effect, or cursor inventory is incomplete');
|
||||
assert(catalog.every(item=>[item.cursorStyle,item.scoreLens===true,item.lineColor,item.lineEffect,item.reactionStyle].filter(Boolean).length===1),'A catalog item mixes multiple cosmetic/tool contracts');
|
||||
const retiredReactions=['fountain','shockwave','rain'];
|
||||
assert(retiredReactions.every(style=>!reactionEffects.some(item=>item.reactionStyle===style)&&!read('realtime-server.js').includes(`'${style}'`)),`A retired emoji effect remains active`);
|
||||
const retiredLineEffects=['neon','prism','comet','glow','pulse','dash','starlight'];
|
||||
assert(retiredLineEffects.every(style=>!lineEffects.some(item=>item.lineEffect===style)),`A retired line effect remains active`);
|
||||
const removed=['cursor-face-1f642-200d-2194-fe0f','cursor-face-1f642-200d-2195-fe0f','cursor-face-1fae9','cursor-face-1faea'];
|
||||
assert(removed.every(id=>!catalog.some(item=>item.id===id)&&!app.includes(`'${id}'`)),`A removed glitchy/undisplayed cursor remains`);
|
||||
const defaultData=functionSource('defaultData'),normalize=functionSource('normalizeSnapshot'),newPath=functionSource('startGate'),pathNormalization=functionSource('normalizePath'),appearance=functionSource('syncCosmeticAppearance'),migration=functionSource('migratedLineColorStyle');
|
||||
assert(defaultData.includes('starterLineColor=randomStarterLineColorId()')&&defaultData.includes('lineColorStyle:starterLineColor'),'A first-access player is not granted and equipped a random starter line color');
|
||||
assert(functionSource('validStarterLineColorId').includes('STARTER_LINE_COLOR_IDS.includes(value)')&&normalize.includes('validStarterLineColorId(raw.starterLineColor)'),'A premium shop color can be forged as a free starter color');
|
||||
assert(normalize.includes('clean.lineColorStyle=migratedLineColorStyle(raw)||clean.starterLineColor')&&normalize.includes("clean.reactionStyle=REACTION_STYLE_IDS.has(raw.reactionStyle)")&&!normalize.includes('ownsSavedItem'),'Saved equipped cosmetics are incorrectly reset before purchase data hydrates');
|
||||
assert(migration.includes("source?.lineEffectStyle==='aurora'")&&migration.includes('AURORA_LINE_COLOR_ITEM_ID'),'Legacy Aurora equipment is not migrated to the line-color contract');
|
||||
assert(functionSource('starterColorGrantCount').includes('data.starterLineColor')&&functionSource('inventoryCount').includes('starterColorGrantCount')&&functionSource('ownsStoreItem').includes('inventoryCount(itemId)>0'),'The starter color is not represented as owned inventory');
|
||||
assert(newPath.includes("activeLineColorItem()?.aurora?'aurora':null")&&newPath.includes('ownerId:currentPlayerId()')&&pathNormalization.includes('LINE_EFFECT_IDS.has(raw.lineEffect)'),'Aurora line presentation is not persisted on newly drawn paths or legacy paths');
|
||||
assert(appearance.includes("activeColor?.aurora?'aurora':'none'")&&appearance.includes('--player-line-color'),'Equipped color and Aurora are not synchronized to the presentation layer');
|
||||
assert(css.includes('.path.line-effect-aurora')&&css.includes('.gate-dot.line-effect-aurora'),'Aurora line and gate CSS presentation is missing');
|
||||
assert(css.includes('@media (prefers-reduced-motion:reduce)')&&css.includes('body.lightweight-rendering'),'Cosmetic effects do not provide reduced/lightweight rendering fallbacks');
|
||||
const reactionSource=functionSource('drawStyledReaction'),publishSource=functionSource('publishReactionAt'),realtime=read('realtime-server.js');
|
||||
for(const style of reactionEffects.map(item=>item.reactionStyle)){assert(reactionSource.includes(`style==='${style}'`),`Missing canvas presentation for emoji effect ${style}`);assert(realtime.includes(`'${style}'`),`Realtime server does not accept emoji effect ${style}`)}
|
||||
assert(publishSource.includes('style,x,y')&&publishSource.includes('sendOrQueueRealtimeReaction(reaction)')&&realtime.includes("REACTION_STYLES.has(message.style)?message.style:'classic'"),'Purchased emoji styles are not published and safely normalized');
|
||||
assert(functionSource('publishReaction',realtime).includes("style!=='classic'")&&functionSource('publishReaction',realtime).includes('active.playerId===client.playerId'),'Realtime authority does not prevent overlapping special emoji effects');
|
||||
const seeded=functionSource('seededStoreItemIds'),authoritative=functionSource('authoritativeStoreItemIds',read('server.js'));
|
||||
assert(seeded.includes('.slice(0,6)')&&seeded.includes('6-fixedTools.length')&&seeded.includes('item.scoreLens'),'Client shops do not offer six cursors and six non-cursor items including the Score Lens');
|
||||
assert(authoritative.includes('.slice(0,6)')&&authoritative.includes('6-fixedTools.length')&&authoritative.includes('item.scoreLens'),'Server shop authority does not match the twelve-item client inventory');
|
||||
assert(functionSource('renderStorePanel').includes("compact:true")&&functionSource('renderStorePanel').includes('if(category.compact)card.append(icon,buy)')&&css.includes('.store-compact-list{grid-template-columns:repeat(6'),'New shop cosmetics are not displayed in the compact cursor-style grid');
|
||||
assert(functionSource('starterLineColorForPlayer',read('server.js')).includes("createHash('sha256')"),'Server-backed players do not receive a stable starter color');
|
||||
console.log('v47.87 starter colors, Aurora line color, emoji effects, and cursor cleanup passed');
|
||||
15
test/v4786-effects-ux-smoke-test.js
Normal file
15
test/v4786-effects-ux-smoke-test.js
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
'use strict';
|
||||
const {assert,app,css,read,functionSource}=require('./helpers/app-source');
|
||||
const catalog=JSON.parse(read('store-catalog.json')),ids=new Set(catalog.map(item=>item.id));
|
||||
for(const id of['line-effect-prism','line-effect-comet','line-effect-neon','reaction-effect-rain'])assert(!ids.has(id),`Retired effect remains in catalog: ${id}`);
|
||||
assert(app.includes('MAX_RENDER_FPS=30')&&app.includes('AUXILIARY_FPS=30')&&app.includes('REACTION_TARGET_FPS=30')&&app.includes('DRAG_TARGET_FPS=30'),'Visual rendering is not capped at 30 FPS');
|
||||
assert(css.includes('.path.line-effect-aurora,.gate-marker.line-effect-aurora')&&css.includes('.gate-dot.line-effect-aurora')&&css.includes('!important')&&functionSource('nextAuroraRgb').includes('auroraPaletteIndex')&&functionSource('startAuroraRgbAnimation').includes('AURORA_COLOR_INTERVAL')&&app.includes('AURORA_COLOR_INTERVAL=2000'),'Aurora is not driven by the curated two-second palette across lines and gates');
|
||||
const styled=functionSource('drawStyledReaction');
|
||||
assert(!styled.includes("style==='rain'")&&styled.includes('floatY=-18')&&!styled.includes('drawReactionFlash(context,50'),'Classic reaction is not reduced to a simple floating emoji or rain remains');
|
||||
assert(functionSource('drawLaserReaction').includes('spin*3.8'),'Laser center emoji is not rotating at high speed');
|
||||
const publish=functionSource('publishReactionAt');
|
||||
assert(publish.includes("style!=='classic'")&&publish.includes('activeLocalSpecialReactionUntil=expiresAt')&&publish.includes('sendOrQueueRealtimeReaction(reaction)'),'Client does not lock or share overlapping special emoji effects');
|
||||
assert(functionSource('publishReaction',read('realtime-server.js')).includes('active.playerId===client.playerId'),'Server does not reject overlapping special emoji effects');
|
||||
assert(functionSource('animatePurchasedItemToInventory').includes("fly.className='purchase-item-fly'")&&functionSource('animatePurchasedItemToInventory').includes('inventoryBtn.classList.add')&&functionSource('renderStorePanel').includes('purchaseFlyOrigin(icon)'),'Purchased-item appearance does not fly into the Item button');
|
||||
assert(!functionSource('makeBoard').includes('active-board-boundary')&&!css.includes('active-board-boundary'),'Retired selected-board orbit outline remains');
|
||||
console.log('v47.87 effect performance, reaction sharing, Aurora, and shop fly-in passed');
|
||||
20
test/v4787-user-cosmetic-realtime-smoke-test.js
Normal file
20
test/v4787-user-cosmetic-realtime-smoke-test.js
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
'use strict';
|
||||
const {assert,app,css,html,read,functionSource,buildMeta}=require('./helpers/app-source');
|
||||
const catalog=JSON.parse(read('store-catalog.json')),server=read('server.js'),realtime=read('realtime-server.js');
|
||||
assert(buildMeta.APP_VERSION==='48.0'&&buildMeta.PACKAGE_VERSION==='48.0.0','v47.87 canonical version marker is missing');
|
||||
const aurora=catalog.find(item=>item.id==='line-effect-aurora');
|
||||
assert(aurora?.lineColor==='#5fd8ff'&&aurora.aurora===true&&!('lineEffect'in aurora),'Aurora is not a shop-only line-color contract');
|
||||
assert(app.includes('AURORA_COLOR_INTERVAL=2000')&&app.includes("AURORA_RGB_PALETTE=Object.freeze(['79 235 255'")&&functionSource('nextAuroraRgb').includes('auroraPaletteIndex=(auroraPaletteIndex+1)%AURORA_RGB_PALETTE.length'),'Aurora palette timing or deterministic selection is missing');
|
||||
const boardRender=functionSource('renderBoardNow');
|
||||
assert(boardRender.includes("activeLineColorItem()?.aurora===true")&&boardRender.includes("classList.toggle('line-effect-aurora',auroraGate)")&&css.includes('.gate-dot.line-effect-aurora')&&css.includes('.gate-marker.line-effect-aurora'),'Equipped Aurora does not color usable gates');
|
||||
assert(functionSource('normalizeSnapshot').includes('clean.lineColorStyle=migratedLineColorStyle(raw)||clean.starterLineColor')&&functionSource('globalForStorage').includes('lineColorStyle:migratedLineColorStyle(source)')&&functionSource('applyGlobalRecordToData').includes('normalizeEquippedCosmeticsInPlace(data)'),'Equipped appearance is not durable across storage reload/merge');
|
||||
assert(!app.includes('陳列は店ごとに異なります')&&!app.includes('便利な機能から6点を陳列しています。'),'Removed shop copy remains');
|
||||
assert(css.includes('.store-wallet{display:inline-flex')&&css.includes('white-space:nowrap')&&css.includes('.store-wallet b{display:inline'),'Store gem balance can still wrap before its number');
|
||||
assert(functionSource('drawPresenceLayer').includes('drawRemoteCursorGlyph(context,player,x,y)')&&functionSource('drawPresenceLayer').includes('drawRemotePlayerName(context,player,x,y)')&&!app.includes("viewport.addEventListener('pointerleave',hideRealtimeCursor"),'Nearby player cursor/name visibility is incomplete');
|
||||
assert(css.includes('.inventory-use.selected{')&&functionSource('updateInventoryItemView').includes("classList.toggle('selected',pressed&&active)"),'Equipped inventory action has no distinct color');
|
||||
assert(app.includes("const REACTION_EMOJIS=Object.freeze(['👍','🤩','🙏','🧠','🎉'])")&&realtime.includes("new Set(['👍','🤩','🙏','🧠','🎉'])")&&!app.includes("['👍','👉🏻'")&&!realtime.includes("['👍','👉🏻'"),'The pointing reaction was not replaced by star-struck');
|
||||
for(const oldName of['巨大絵文字','絵文字レーザー','絵文字オービット','絵文字花火','絵文字彗星'])assert(!app.includes(oldName),`Legacy emoji-prefixed item name remains: ${oldName}`);
|
||||
assert(!html.includes('debugAllItemsToggle')&&!app.includes('デバッグ使用可')&&app.includes("location.pathname.endsWith('/debug-items')")&&server.includes("pathname.endsWith('/debug-items')")&&functionSource('purchaseStoreItem').includes('if(!debug&&data.score<price)'),'Debug mode is not URL-only with normal purchasing');
|
||||
assert(html.includes('id="fpsCounter"')&&functionSource('refreshFpsCounter').includes('FPS 待機')&&functionSource('refreshFpsCounter').includes('FPS ${fpsLastValue}'),'FPS label was not renamed');
|
||||
assert(functionSource('publishReactionAt').includes('sendOrQueueRealtimeReaction(reaction)')&&functionSource('sendOrQueueRealtimeReaction').includes('pendingRealtimeReactionMessages')&&functionSource('flushPendingRealtimeReactions').includes('realtimeSend(payload)')&&functionSource('handleRealtimeMessage').includes('flushPendingRealtimeReactions()'),'Emoji reactions are not shared reliably with nearby players');
|
||||
console.log('v47.87 requested cosmetics, persistence, debug URL, presence, and reaction sharing passed');
|
||||
25
test/v4788-time-attack-navigation-smoke-test.js
Normal file
25
test/v4788-time-attack-navigation-smoke-test.js
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
'use strict';
|
||||
const fs=require('fs');
|
||||
const path=require('path');
|
||||
const root=path.join(__dirname,'..');
|
||||
const app=fs.readFileSync(path.join(root,'app.js'),'utf8');
|
||||
const html=fs.readFileSync(path.join(root,'index.html'),'utf8');
|
||||
const css=fs.readFileSync(path.join(root,'style.css'),'utf8');
|
||||
function assert(value,message){if(!value)throw new Error(message)}
|
||||
assert(html.includes('href="https://host.nishi.boats/~333/"')&&html.includes('333の部屋に戻る'),'The return link is missing');
|
||||
assert(!html.includes('id="selectedInfo"')&&!app.includes("querySelector('#selectedInfo')"),'The current board level remains in the top UI');
|
||||
assert(!app.includes('絵文字エフェクト')&&!html.includes('絵文字エフェクト'),'The reaction category still uses the retired label');
|
||||
assert(app.includes('TIME_ATTACK_COOLDOWN_MINUTES=Object.freeze({3:10,5:15,10:20})'),'Time-attack cooldown durations are incorrect');
|
||||
assert(app.includes('for(const minutes of TIME_ATTACK_MINUTES)data.timeAttackCooldowns[minutes]=cooldownEndsAt'),'Time-attack cooldown is not shared by every course');
|
||||
assert(app.includes("for(const value of['3','2','1'])")&&app.includes("showTimeAttackCountdownOverlay('Start','start')"),'The start countdown is incomplete');
|
||||
assert(app.includes("timeAttackBtn.classList.toggle('final-countdown',remaining<=30000&&remaining>0)")&&css.includes('.pill.time-attack.active.final-countdown'),'The right-side timer is not emphasized during the final 30 seconds');
|
||||
assert(html.includes('獲得ジェムの倍率UP')&&html.includes('獲得ジェムに応じて次の通り倍率が上昇します。')&&html.includes('<small>基礎累計</small>'),'The multiplier explanation is missing');
|
||||
assert(app.includes('return timeAttackMultiplier(run.baseCollected||0)')&&app.includes('reward.preTimeAward')&&app.includes('run.baseCollected'),'The multiplier implementation no longer matches the displayed explanation');
|
||||
assert(!html.includes('id="timeAttackResultCollected"')&&!html.includes('id="timeAttackResultMultiplier"')&&!html.includes('id="timeAttackResultBonus"'),'Removed result fields remain');
|
||||
assert(app.includes("'https://host.nishi.boats/~333/link-field/'"),'The result URL is missing');
|
||||
assert(css.includes('rgba(194,108,255,.42)')&&css.includes('#timeAttackCountdownOverlay'),'The purple multiplier panel or countdown styling is missing');
|
||||
console.log('v47.88 time-attack and navigation smoke test passed');
|
||||
|
||||
assert(html.includes('<title>LinkField/リンクフィールド</title>')&&app.includes('LinkField/リンクフィールド|タイムアタック'),'The LinkField title is missing');
|
||||
assert(!app.includes('キャンディローズ')&&!app.includes('ルビービーム')&&!app.includes('line-color-rose')&&!app.includes('line-color-ruby'),'Removed line colors remain in presentation code');
|
||||
assert(app.includes('let initialMeta=await randomUnsolvedMeta()')&&!app.includes('if(!restoreSavedCamera())centerMeta(initialMeta'),'Startup camera is not randomized to an unsolved board');
|
||||
34
test/v4791-server-startup-smoke-test.js
Normal file
34
test/v4791-server-startup-smoke-test.js
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
'use strict';
|
||||
const assert=require('assert/strict');
|
||||
const http=require('http');
|
||||
const {parsePort,defaultPortCandidates,listenWithPortFallback}=require('../server');
|
||||
|
||||
function close(server){return new Promise(resolve=>server.close(()=>resolve()))}
|
||||
function listen(server,port=0){return new Promise((resolve,reject)=>{server.once('error',reject);server.listen(port,'127.0.0.1',()=>resolve(server.address().port))})}
|
||||
|
||||
(async()=>{
|
||||
assert.equal(parsePort(undefined),8080);
|
||||
assert.equal(parsePort('0'),0);
|
||||
assert.equal(parsePort('4312'),4312);
|
||||
assert.throws(()=>parsePort('invalid'),/Invalid server port/);
|
||||
assert.deepEqual(defaultPortCandidates(8080).slice(0,3),[8080,3000,3001]);
|
||||
|
||||
const blocker=http.createServer((_req,res)=>res.end('occupied'));
|
||||
const occupiedPort=await listen(blocker);
|
||||
const fallbackServer=http.createServer((_req,res)=>res.end('LinkField'));
|
||||
const listening=await listenWithPortFallback(fallbackServer,{host:'127.0.0.1',preferredPort:occupiedPort,explicitPort:false,candidates:[occupiedPort,0]});
|
||||
assert.equal(listening.usedFallback,true);
|
||||
assert.notEqual(listening.port,occupiedPort);
|
||||
const response=await fetch(`http://127.0.0.1:${listening.port}/`);
|
||||
assert.equal(await response.text(),'LinkField');
|
||||
|
||||
const explicitServer=http.createServer();
|
||||
await assert.rejects(
|
||||
listenWithPortFallback(explicitServer,{host:'127.0.0.1',preferredPort:occupiedPort,explicitPort:true}),
|
||||
error=>error?.code==='EADDRINUSE'&&/already in use/.test(error.message)
|
||||
);
|
||||
|
||||
await close(fallbackServer);
|
||||
await close(blocker);
|
||||
console.log('LinkField v48.0 server startup fallback smoke test passed');
|
||||
})().catch(error=>{console.error(error);process.exitCode=1});
|
||||
40
test/v4792-apache-bridge-smoke-test.js
Normal file
40
test/v4792-apache-bridge-smoke-test.js
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
'use strict';
|
||||
const assert=require('assert/strict');
|
||||
const fs=require('fs');
|
||||
const fsp=fs.promises;
|
||||
const os=require('os');
|
||||
const path=require('path');
|
||||
const {renderApacheBridge,replaceManagedBlock,installApacheBridge,BEGIN_MARKER,END_MARKER}=require('../server/apache-bridge');
|
||||
|
||||
(async()=>{
|
||||
const rendered=renderApacheBridge(8080);
|
||||
assert.match(rendered,/RewriteRule \^api\/\(\.\*\)\$ http:\/\/127\.0\.0\.1:8080\/api\/\$1 \[P,L\]/);
|
||||
assert.match(rendered,/ws:\/\/127\.0\.0\.1:8080\/api\/realtime/);
|
||||
assert.match(rendered,/api-bridge\.php\?path=\/api\/\$1 \[QSA,L\]/);
|
||||
assert.equal((rendered.match(new RegExp(BEGIN_MARKER,'g'))||[]).length,1);
|
||||
assert.equal((rendered.match(new RegExp(END_MARKER,'g'))||[]).length,1);
|
||||
|
||||
const custom='Options -Indexes\n\n# custom rule\n';
|
||||
const first=replaceManagedBlock(custom,rendered);
|
||||
assert.match(first,/Options -Indexes/);
|
||||
assert.match(first,/# custom rule/);
|
||||
assert.match(first,/127\.0\.0\.1:8080/);
|
||||
const replaced=replaceManagedBlock(first,renderApacheBridge(4312));
|
||||
assert.match(replaced,/127\.0\.0\.1:4312/);
|
||||
assert.doesNotMatch(replaced,/127\.0\.0\.1:8080/);
|
||||
assert.equal((replaced.match(new RegExp(BEGIN_MARKER,'g'))||[]).length,1);
|
||||
|
||||
const root=await fsp.mkdtemp(path.join(os.tmpdir(),'linkfield-apache-'));
|
||||
try{
|
||||
await fsp.writeFile(path.join(root,'.htaccess'),custom);
|
||||
const installed=await installApacheBridge({fsp,root,port:9123});
|
||||
assert.equal(installed.enabled,true);
|
||||
assert.equal(installed.written,true);
|
||||
const actual=await fsp.readFile(path.join(root,'.htaccess'),'utf8');
|
||||
assert.match(actual,/Options -Indexes/);
|
||||
assert.match(actual,/127\.0\.0\.1:9123/);
|
||||
const unchanged=await installApacheBridge({fsp,root,port:9123});
|
||||
assert.equal(unchanged.written,false);
|
||||
}finally{await fsp.rm(root,{recursive:true,force:true})}
|
||||
console.log('LinkField v48.0 Apache bridge smoke test passed');
|
||||
})().catch(error=>{console.error(error);process.exitCode=1});
|
||||
47
test/v4793-php-poll-bridge-smoke-test.js
Normal file
47
test/v4793-php-poll-bridge-smoke-test.js
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
'use strict';
|
||||
const assert=require('assert/strict');
|
||||
const fs=require('fs');
|
||||
const http=require('http');
|
||||
const {spawnSync}=require('child_process');
|
||||
const {createRealtimeHub}=require('../realtime-server');
|
||||
const {renderApacheBridge}=require('../server/apache-bridge');
|
||||
|
||||
(async()=>{
|
||||
const runtime=fs.readFileSync(require.resolve('../runtime-config.js'),'utf8');
|
||||
const app=fs.readFileSync(require.resolve('../app.js'),'utf8');
|
||||
const php=fs.readFileSync(require.resolve('../api-bridge.php'),'utf8');
|
||||
assert.match(runtime,/api-bridge\.php/);
|
||||
assert.match(runtime,/realtimeTransport:'http-poll'/);
|
||||
assert.match(app,/\/api\/realtime\/connect/);
|
||||
assert.match(app,/\/api\/realtime\/poll/);
|
||||
assert.match(app,/x-linkfield-authorization/);
|
||||
assert.match(php,/\.linkfield-port/);
|
||||
assert.match(php,/X-LinkField-Authorization/i);
|
||||
assert.match(renderApacheBridge(4312),/api-bridge\.php\?path=\/api\/\$1 \[QSA,L\]/);
|
||||
const phpCheck=spawnSync('php',['-l',require.resolve('../api-bridge.php')],{encoding:'utf8'});
|
||||
if(!phpCheck.error)assert.equal(phpCheck.status,0,phpCheck.stderr||phpCheck.stdout);
|
||||
|
||||
let now=1_000_000;
|
||||
const server=http.createServer();
|
||||
const hub=createRealtimeHub({server,authenticate:async()=>{throw new Error('unused')},getBoardInfo:async boardId=>boardId==='B0'?{solved:false,bounds:{minX:0,minY:0,maxX:1,maxY:1}}:null,now:()=>now});
|
||||
try{
|
||||
const first={playerId:'a'.repeat(24),name:'A'};
|
||||
const second={playerId:'b'.repeat(24),name:'B'};
|
||||
const a=hub.createPollingClient(first),b=hub.createPollingClient(second);
|
||||
const directClaim=await hub.claimBoard(first,'B0',a.presenceId);assert.equal(directClaim.ok,true);assert.equal(directClaim.claim.playerName,'A');
|
||||
const deniedClaim=await hub.claimBoard(second,'B0',b.presenceId);assert.equal(deniedClaim.ok,false);assert.equal(deniedClaim.reason,'occupied');
|
||||
assert.equal(a.messages[0].type,'ready');
|
||||
assert.equal(b.messages[0].type,'ready');
|
||||
await hub.handlePollingMessage(first,a.presenceId,{type:'viewport',minX:-10,minY:-10,maxX:10,maxY:10},a.sequence);
|
||||
await hub.handlePollingMessage(second,b.presenceId,{type:'viewport',minX:-10,minY:-10,maxX:10,maxY:10},b.sequence);
|
||||
now+=1000;
|
||||
await hub.handlePollingMessage(first,a.presenceId,{type:'cursor',x:1,y:2,vx:0,vy:0,cursorStyle:'default'},a.sequence);
|
||||
now+=500;
|
||||
await hub.handlePollingMessage(first,a.presenceId,{type:'reaction',id:'poll-r1',emoji:'🤩',style:'classic',x:1,y:2},a.sequence);
|
||||
const events=hub.pollPollingClient(second,b.presenceId,b.sequence);
|
||||
assert(events.messages.some(message=>message.type==='cursor'&&message.name==='A'));
|
||||
assert(events.messages.some(message=>message.type==='reaction'&&message.reaction?.emoji==='🤩'));
|
||||
assert.equal(hub.disconnectPollingClient(first,a.presenceId),true);
|
||||
}finally{hub.close()}
|
||||
console.log('LinkField v48.0 PHP bridge and HTTP realtime polling smoke test passed');
|
||||
})().catch(error=>{console.error(error);process.exitCode=1});
|
||||
59
test/v4794-background-deploy-smoke-test.js
Normal file
59
test/v4794-background-deploy-smoke-test.js
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
'use strict';
|
||||
const assert=require('assert/strict');
|
||||
const fs=require('fs');
|
||||
const fsp=fs.promises;
|
||||
const os=require('os');
|
||||
const path=require('path');
|
||||
const {spawnSync}=require('child_process');
|
||||
const BuildMeta=require('../build-meta');
|
||||
const service=require('../scripts/service-control');
|
||||
|
||||
(async()=>{
|
||||
assert.equal(BuildMeta.APP_VERSION,'48.0');
|
||||
const root=await fsp.mkdtemp(path.join(os.tmpdir(),'linkfield-v4794-'));
|
||||
const publicDir=path.join(root,'public_html','link-field');
|
||||
const dataDir=path.join(root,'data');
|
||||
const logFile=path.join(root,'server.log');
|
||||
const serviceDir=path.join(root,'service');
|
||||
const env={...process.env,LINK_FIELD_PUBLIC_DIR:publicDir,LINK_FIELD_TEST_DATA_ROOT:dataDir,LINK_FIELD_TEST_DATA_ROOT:path.join(root,'shared-root'),LINK_FIELD_SERVICE_DIR:serviceDir,LINK_FIELD_LOG_FILE:logFile,PORT:'0'};
|
||||
try{
|
||||
await service.deployPublicFiles(publicDir);
|
||||
for(const relative of ['index.html','app.js','runtime-config.js','api-bridge.php','assets','client']){
|
||||
assert.equal(fs.existsSync(path.join(publicDir,relative)),true,`${relative} was not deployed`);
|
||||
}
|
||||
const manifest=JSON.parse(await fsp.readFile(path.join(publicDir,'.linkfield-deployment.json'),'utf8'));
|
||||
assert.equal(manifest.version,'48.0');
|
||||
|
||||
const start=spawnSync(process.execPath,[path.join(service.ROOT,'scripts','service-control.js'),'start'],{cwd:service.ROOT,env,encoding:'utf8',timeout:20_000});
|
||||
assert.equal(start.status,0,start.stderr||start.stdout);
|
||||
assert.match(start.stdout,/started in the background/i);
|
||||
assert.match(start.stdout,/command prompt is available again/i);
|
||||
const pidFile=path.join(serviceDir,'server.pid');
|
||||
const pid=Number((await fsp.readFile(pidFile,'utf8')).trim());
|
||||
assert.equal(service.isProcessRunning(pid),true);
|
||||
const port=Number((await fsp.readFile(path.join(publicDir,'.linkfield-port'),'utf8')).trim());
|
||||
assert.ok(Number.isSafeInteger(port)&&port>0);
|
||||
const response=await fetch(`http://127.0.0.1:${port}/api/cloud/status`);
|
||||
assert.equal(response.ok,true);
|
||||
const status=await response.json();
|
||||
assert.equal(status.sharedWorld,true);
|
||||
assert.equal(status.appVersion,'48.0');
|
||||
assert.equal(fs.existsSync(path.join(publicDir,'.htaccess')),true);
|
||||
|
||||
const secondStart=spawnSync(process.execPath,[path.join(service.ROOT,'scripts','service-control.js'),'start'],{cwd:service.ROOT,env,encoding:'utf8',timeout:20_000});
|
||||
assert.equal(secondStart.status,0,secondStart.stderr||secondStart.stdout);
|
||||
assert.match(secondStart.stdout,/Replacing the running LinkField server/i);
|
||||
const replacementPid=Number((await fsp.readFile(pidFile,'utf8')).trim());
|
||||
assert.notEqual(replacementPid,pid);
|
||||
assert.equal(service.isProcessRunning(pid),false);
|
||||
assert.equal(service.isProcessRunning(replacementPid),true);
|
||||
|
||||
const stop=spawnSync(process.execPath,[path.join(service.ROOT,'scripts','service-control.js'),'stop'],{cwd:service.ROOT,env,encoding:'utf8',timeout:10_000});
|
||||
assert.equal(stop.status,0,stop.stderr||stop.stdout);
|
||||
assert.equal(service.isProcessRunning(replacementPid),false);
|
||||
}finally{
|
||||
spawnSync(process.execPath,[path.join(service.ROOT,'scripts','service-control.js'),'stop'],{cwd:service.ROOT,env,encoding:'utf8',timeout:10_000});
|
||||
await fsp.rm(root,{recursive:true,force:true});
|
||||
}
|
||||
console.log('LinkField v48.0 background deployment and service smoke test passed');
|
||||
})().catch(error=>{console.error(error);process.exitCode=1});
|
||||
54
test/v4795-single-world-only-smoke-test.js
Normal file
54
test/v4795-single-world-only-smoke-test.js
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
'use strict';
|
||||
const assert=require('assert/strict');
|
||||
const fs=require('fs');
|
||||
const fsp=fs.promises;
|
||||
const os=require('os');
|
||||
const path=require('path');
|
||||
const {spawn,spawnSync}=require('child_process');
|
||||
const {read,functionSource,root}=require('./helpers/app-source');
|
||||
|
||||
const app=read('app.js'),runtime=read('runtime-config.js'),html=read('index.html'),serverSource=read('server.js'),serviceSource=read('scripts/service-control.js');
|
||||
assert.match(runtime,/cloudApi:true/);
|
||||
assert.match(runtime,/singleSharedWorld:true/);
|
||||
assert(!app.includes('同期コード')&&!app.includes('端末のみ'),'Retired local/sync-code UI remains');
|
||||
assert(html.includes('class="shared-indicator"')&&html.includes('<span>共有</span>')&&!html.includes('共有 · 接続中')&&!html.includes('端末のみ'),'Shared state is not a fixed-size indicator');
|
||||
assert(!functionSource('setCloudStatus').includes('textContent')&&functionSource('setCloudStatus').includes('cloudBtn.dataset.state'),'Shared status still changes visible text or player names');
|
||||
assert(functionSource('init').indexOf('await initCloudSync({startup:true})')<functionSource('init').indexOf('refreshWorldView({rebuild:true'),'The local field is rendered before the server-authoritative world is adopted');
|
||||
assert(functionSource('fetchCurrentSharedWorldStatus').includes("status.singleWorld===true")&&functionSource('fetchCurrentSharedWorldStatus').includes('status.appVersion===APP_VERSION')&&functionSource('initCloudSync').includes('resetClientToSingleSharedWorld()'),'Startup does not require the current single shared world');
|
||||
assert(functionSource('requestBoardClaim').indexOf("fetchJson('/api/realtime/claim'")<functionSource('requestBoardClaim').indexOf('requestBoardClaimThroughRealtime')&&functionSource('requestBoardClaimThroughRealtime').includes('realtimeSend')&&!functionSource('requestBoardClaim').includes('await waitForRealtimeReady()'),'Board input is not using direct claim with realtime fallback');
|
||||
assert(!app.includes('BroadcastChannel')&&!app.includes('syncStorageKey')&&!app.includes('queueWorldSignal'),'Retired local cross-tab synchronization remains');
|
||||
assert(serverSource.includes('INSTANCE_LOCK_FILE')&&serverSource.includes('Another LinkField server is already running'),'Server process lock is missing');
|
||||
assert(serverSource.includes("const PRODUCTION_DATA_DIR = path.resolve('/link-field/world')"),'Shared data is not fixed to /link-field/world');
|
||||
assert(!serverSource.includes('LINK_FIELD_WORLD_DIR')&&!serverSource.includes('LINK_FIELD_DATA_ROOT')&&!serverSource.includes("'.local', 'share', 'LinkField'"),'Production can still select a second shared-world directory');
|
||||
assert(serviceSource.includes('stopLegacyLinkFieldServers')&&serviceSource.includes('Replacing the running LinkField server')&&serviceSource.includes("fsp.unlink(path.join(publicDir,'.linkfield-port'))"),'Old server processes or stale bridge ports can survive deployment');
|
||||
|
||||
const sleep=ms=>new Promise(resolve=>setTimeout(resolve,ms));
|
||||
(async()=>{
|
||||
const temp=await fsp.mkdtemp(path.join(os.tmpdir(),'linkfield-single-world-'));
|
||||
const publicDir=path.join(temp,'public');
|
||||
const dataRoot=path.join(temp,'data-root');
|
||||
const env={...process.env,HOST:'127.0.0.1',PORT:'0',LINK_FIELD_PUBLIC_DIR:publicDir,LINK_FIELD_TEST_DATA_ROOT:dataRoot,LINK_FIELD_APACHE_BRIDGE:'0'};
|
||||
const first=spawn(process.execPath,[path.join(root,'server.js')],{env,stdio:['ignore','pipe','pipe']});
|
||||
let firstErr='';first.stderr.on('data',chunk=>firstErr+=chunk);
|
||||
try{
|
||||
let port=0;
|
||||
for(let i=0;i<100;i++){
|
||||
try{port=Number((await fsp.readFile(path.join(publicDir,'.linkfield-port'),'utf8')).trim());if(port)break}catch(_){ }
|
||||
if(first.exitCode!=null)throw new Error(firstErr||`First server exited with ${first.exitCode}`);
|
||||
await sleep(40);
|
||||
}
|
||||
assert(port>0,'First single-world server did not start');
|
||||
const status=await (await fetch(`http://127.0.0.1:${port}/api/cloud/status`)).json();
|
||||
assert.equal(status.singleWorld,true);
|
||||
assert.equal(status.revision,0);
|
||||
const second=spawnSync(process.execPath,[path.join(root,'server.js')],{env,encoding:'utf8',timeout:10000});
|
||||
assert.notEqual(second.status,0,'A second shared-world server started against the same data root');
|
||||
assert.match(`${second.stdout}\n${second.stderr}`,/already running/i);
|
||||
}finally{
|
||||
first.kill('SIGTERM');
|
||||
for(let i=0;i<50&&first.exitCode==null;i++)await sleep(20);
|
||||
if(first.exitCode==null)first.kill('SIGKILL');
|
||||
await fsp.rm(temp,{recursive:true,force:true});
|
||||
}
|
||||
console.log('LinkField v48.0 /link-field/world single shared world guards passed');
|
||||
})().catch(error=>{console.error(error);process.exitCode=1});
|
||||
62
test/v4797-shared-board-input-smoke-test.js
Normal file
62
test/v4797-shared-board-input-smoke-test.js
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
'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 app=read('app.js'),serverSource=read('server.js'),html=read('index.html'),css=read('style.css');
|
||||
assert(html.includes('class="shared-indicator"')&&html.includes('<span>共有</span>'),'Compact shared indicator is missing');
|
||||
assert(!html.includes('共有 · 接続中')&&!html.includes('共有成功')&&!html.includes('共有失敗'),'Variable shared-status labels remain in the visible UI');
|
||||
assert(css.includes('.shared-indicator')&&css.includes('bottom:calc(16px + var(--safe-bottom))'),'Shared indicator is not fixed above FPS');
|
||||
assert(!functionSource('setCloudStatus').includes('textContent'),'Shared indicator still changes visible text');
|
||||
assert(functionSource('currentCloudPending').includes('stateIds:[...cloudJournalStateIds]'),'Unfinished states are omitted from cloud pending data');
|
||||
assert(!functionSource('noteCloudRow').includes("solved!==true"),'Unfinished states are still removed from the shared outbox');
|
||||
assert(functionSource('requestBoardClaim').indexOf("fetchJson('/api/realtime/claim'")<functionSource('requestBoardClaim').indexOf('requestBoardClaimThroughRealtime')&&functionSource('requestBoardClaimThroughRealtime').includes("type:'claim'"),'Board claim does not use the direct shared API with realtime fallback');
|
||||
assert(functionSource('markPendingClaimPointerReleased').includes('pending.released=true')&&functionSource('bindBoard').includes('finishReleased'),'A drag released while claim approval is pending is discarded');
|
||||
assert(!app.includes('BroadcastChannel')&&!app.includes('syncStorageKey')&&!app.includes('queueWorldSignal'),'Retired cross-tab board synchronization remains');
|
||||
assert(serverSource.includes('const state=JSON.parse(JSON.stringify(incoming));state.solved=false'),'Server still discards unfinished board paths');
|
||||
assert(serverSource.includes('unfinishedBoard')&&serverSource.includes('Board claim is required'),'Unfinished state writes are not protected by the active claim');
|
||||
|
||||
const port=25000+Math.floor(Math.random()*5000);
|
||||
const dataDir=fs.mkdtempSync(path.join(os.tmpdir(),'linkfield-v4797-'));
|
||||
const child=spawn(process.execPath,[path.join(root,'server.js')],{env:{...process.env,PORT:String(port),HOST:'127.0.0.1',LINK_FIELD_TEST_DATA_ROOT: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));
|
||||
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(puzzle){return{id:'B0',x:0,y:0,chunks:[[0,0]],level:1,targetLevel:1,seed:1717,axis:'MIX',sealedSides:[],puzzle,rev:1,revAuthor:'client'}}
|
||||
|
||||
(async()=>{
|
||||
for(let index=0;index<100;index++){try{if((await request('/api/cloud/status')).response.ok)break}catch(_){}await sleep(30)}
|
||||
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(),meta=boardMeta(puzzle);
|
||||
let result=await request('/api/cloud/push',{method:'POST',headers:auth(alice),body:JSON.stringify({baseRevision:0,global:{schema:31,worldGeneration:'linkfield-single-world-20260801',nextId:1},metas:[meta],states:[{id:'B0',value:{paths:[],specialProgress:{crossings:[]},solved:false}}],deleted:[]})});
|
||||
assert.equal(result.response.status,200);assert.equal(result.body.revision,1);
|
||||
|
||||
const route=puzzle.solution[0],partial={paths:[{startGate:route.startGate,endGate:null,openGate:null,cells:route.cells.slice(0,3).map(cell=>[...cell])}],specialProgress:{crossings:[]},solved:false};
|
||||
result=await request('/api/cloud/push',{method:'POST',headers:auth(bob),body:JSON.stringify({baseRevision:1,global:{nextId:1},metas:[],states:[{id:'B0',value:partial}],deleted:[]})});
|
||||
assert.equal(result.response.status,423,'A player without the board claim changed unfinished progress');
|
||||
|
||||
const connected=await request('/api/realtime/connect',{method:'POST',headers:auth(alice),body:'{}'});
|
||||
assert.equal(connected.response.status,200);
|
||||
const ready=connected.body.messages.find(message=>message.type==='ready');assert(ready?.presenceId,'Polling realtime connection did not return a presence id');
|
||||
const directClaim=await request('/api/realtime/claim',{method:'POST',headers:auth(alice),body:JSON.stringify({presenceId:ready.presenceId,boardId:'B0'})});
|
||||
assert.equal(directClaim.response.status,200);assert.equal(directClaim.body.ok,true,'Direct claim API failed');
|
||||
const requestId='v4797-claim';
|
||||
const claimEnvelope=await request('/api/realtime/send',{method:'POST',headers:auth(alice),body:JSON.stringify({presenceId:ready.presenceId,message:{type:'claim',requestId,boardId:'B0'},afterSequence:connected.body.sequence||0})});
|
||||
const claimResult=claimEnvelope.body.messages.find(message=>message.type==='claim-result'&&message.requestId===requestId);
|
||||
assert.equal(claimResult?.ok,true,'Claim through the cursor/reaction polling transport failed');
|
||||
|
||||
result=await request('/api/cloud/push',{method:'POST',headers:auth(alice),body:JSON.stringify({baseRevision:1,global:{nextId:1},metas:[],states:[{id:'B0',value:partial}],deleted:[]})});
|
||||
assert.equal(result.response.status,200);assert.equal(result.body.revision,2);
|
||||
const pulled=await request('/api/cloud/pull?since=0',{headers:auth(bob)});
|
||||
assert.equal(pulled.response.status,200);
|
||||
assert.deepEqual(pulled.body.page.states.B0.paths[0].cells,partial.paths[0].cells,'Another player did not receive unfinished board progress');
|
||||
assert.equal(pulled.body.page.states.B0.solved,false);
|
||||
|
||||
console.log('LinkField v48.0 shared board input and progress smoke test passed');
|
||||
})().catch(error=>{console.error(error);if(stderr)console.error(stderr);process.exitCode=1}).finally(()=>{child.kill('SIGTERM');fs.rmSync(dataDir,{recursive:true,force:true})});
|
||||
19
test/v4798-startup-version-retry-smoke-test.js
Normal file
19
test/v4798-startup-version-retry-smoke-test.js
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
'use strict';
|
||||
const assert=require('assert/strict');
|
||||
const {read,functionSource}=require('./helpers/app-source');
|
||||
const app=read('app.js'),server=read('server.js'),service=read('scripts/service-control.js');
|
||||
assert(functionSource('fetchCurrentSharedWorldStatus').includes('status.appVersion===APP_VERSION'),'Startup does not verify the running server release');
|
||||
assert(functionSource('fetchCurrentSharedWorldStatus').includes('npm start'),'Version mismatch does not provide the correct server restart action');
|
||||
assert(functionSource('runStatusRetry').includes('statusRetryAction')&&functionSource('runStatusRetry').includes('await action()'),'Retry does not rerun the selected connection action');
|
||||
assert(!app.includes('復元用バックアップがありません。')&&!app.includes("onclick=retryRecovery"),'Startup retry can still enter obsolete backup recovery');
|
||||
assert(server.includes('appVersion:BuildMeta.APP_VERSION'),'Cloud status does not identify the server release');
|
||||
assert(functionSource('initCloudSync').includes('await activateSingleSharedClientCache()'),'Startup does not activate the new server-authoritative client cache epoch');
|
||||
assert(functionSource('initCloudSync').includes('stateIds:Object.keys(data.states)'),'Initial board state is omitted from the first shared-world commit');
|
||||
assert(functionSource('initCloudSync').includes("persistNow({skipCloud:true})"),'Initial shared board is read from IndexedDB before the new epoch is committed');
|
||||
assert(functionSource('mergeGlobalFields').includes('Math.max'),'Incremental shared-world pulls do not have a defined global merge path');
|
||||
assert(functionSource('pullCloudWorld').includes('cloudSyncing=false;setCloudStatus()')&&functionSource('pushCloudPending').includes('cloudSyncing=false;setCloudStatus()'),'Shared indicator can remain stuck in the syncing state');
|
||||
assert(functionSource('disconnectRealtimeForLifecycle').includes('keepalive:true'),'HTTP polling presence is not disconnected when the page closes');
|
||||
assert(read('realtime-server.js').includes("claim.ownerPresenceId === client.id")&&read('realtime-server.js').includes("releaseBoardClaim(boardId, 'disconnected')"),'Disconnected clients can retain board claims');
|
||||
|
||||
assert(service.includes('Replacing the running LinkField server')&&functionSource('start',service).indexOf('await stop({quiet:true})')<functionSource('start',service).indexOf('deployPublicFiles()'),'npm start does not replace a stale server before deployment');
|
||||
console.log('LinkField v48.0 startup version and retry regression test passed');
|
||||
64
test/v4800-shared-clear-economy-smoke-test.js
Normal file
64
test/v4800-shared-clear-economy-smoke-test.js
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
'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,loadBendPuzzle}=require('./helpers/app-source');
|
||||
|
||||
const app=read('app.js'),catalog=require('../store-catalog.json');
|
||||
const bindBoard=functionSource('bindBoard'),claimRequest=functionSource('requestBoardClaim'),removeClaim=functionSource('removeClaim'),claimPresentation=functionSource('applyClaimPresentationToBoard');
|
||||
assert(!bindBoard.includes('pointerover'),'Hover still starts board ownership');
|
||||
assert(bindBoard.includes("const endpointTarget=e.target.closest?.('.endpoint-hit'),gateTarget=e.target.closest?.('.gate-hit')")&&bindBoard.indexOf('!endpointTarget&&!gateTarget')<bindBoard.indexOf('ensureBoardClaimForInput(b)'),'Ownership is requested before a knob/endpoint operation starts');
|
||||
assert(claimPresentation.includes("own?'プレイ中'"),'Own board badge is not labelled プレイ中');
|
||||
assert(!claimRequest.includes('toast(')&&!removeClaim.includes('toast('),'Board ownership still emits bottom notifications');
|
||||
assert(functionSource('applyCloudEnvelope').includes('applyPlayerEconomyEnvelope(result)'),'Clear push response does not update the local gem wallet');
|
||||
assert(functionSource('checkSolvedAndExpand').includes('data.states[b.id]=previous.state')&&functionSource('checkSolvedAndExpand').includes('for(let attempt=0;attempt<3&&!published;attempt++)'),'Unconfirmed clears can remain locally solved');
|
||||
assert(functionSource('canExpandSharedBoard').includes('state.solvedById===currentPlayerId()'),'A non-solving player can generate transient expansion boards');
|
||||
const normalStarterIds=new Set(catalog.filter(item=>item.lineColor&&!item.aurora&&item.cost===5000).map(item=>item.id));
|
||||
assert(normalStarterIds.size>=2,'Normal starter color pool is missing');
|
||||
assert(app.includes("LINE_COLOR_ITEMS.filter(item=>item.effectLabel==='ラインカラー')"),'Client starter color pool includes premium colors');
|
||||
|
||||
const BendPuzzle=loadBendPuzzle();
|
||||
const dataDir=fs.mkdtempSync(path.join(os.tmpdir(),'linkfield-v4800-'));
|
||||
const port=33000+Math.floor(Math.random()*2000),base=`http://127.0.0.1:${port}`,sleep=ms=>new Promise(resolve=>setTimeout(resolve,ms));
|
||||
let child=null,stderr='';
|
||||
function start(){
|
||||
stderr='';child=spawn(process.execPath,[path.join(root,'server.js')],{env:{...process.env,PORT:String(port),HOST:'127.0.0.1',LINK_FIELD_TEST_DATA_ROOT:dataDir},stdio:['ignore','pipe','pipe']});
|
||||
child.stderr.on('data',chunk=>stderr+=chunk);
|
||||
}
|
||||
async function stop(){if(!child)return;const target=child;child=null;target.kill('SIGTERM');await Promise.race([new Promise(resolve=>target.once('exit',resolve)),sleep(3000)]);if(target.exitCode==null)target.kill('SIGKILL')}
|
||||
async function request(url,options={}){const response=await fetch(base+url,options),text=await response.text();let body;try{body=JSON.parse(text)}catch{body={raw:text}}return{response,body}}
|
||||
async function ready(){for(let i=0;i<120;i++){try{const result=await request('/api/cloud/status');if(result.response.ok)return result.body}catch{}await sleep(30)}throw new Error(`Server did not start: ${stderr}`)}
|
||||
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:BendPuzzle.solverDifficulty(puzzle,1),targetLevel:1,seed,axis:puzzle.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])})),specialProgress:{crossings:[]},solved:true,expanded:false,rev:2,revAuthor:'client'}}
|
||||
|
||||
(async()=>{
|
||||
start();await ready();
|
||||
const sessions=[];for(let index=0;index<12;index++)sessions.push((await request('/api/cloud/session',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({name:`Player ${index}`})})).body);
|
||||
for(const session of sessions)assert(normalStarterIds.has(session.starterLineColor),`Premium or unknown starter color returned: ${session.starterLineColor}`);
|
||||
assert(new Set(sessions.map(session=>session.starterLineColor)).size>1,'Initial line color is not distributed across the normal color pool');
|
||||
const [alice,bob]=sessions;
|
||||
const puzzle=starterPuzzle(),b0=boardMeta('B0',0,15,puzzle);
|
||||
let result=await request('/api/cloud/push',{method:'POST',headers:auth(alice),body:JSON.stringify({baseRevision:0,global:{nextId:1},metas:[b0],states:[{id:'B0',value:{paths:[],specialProgress:{crossings:[]},solved:false}}]})});
|
||||
assert.equal(result.response.status,200,JSON.stringify(result.body));
|
||||
const connected=await request('/api/realtime/connect',{method:'POST',headers:auth(alice),body:'{}'});assert.equal(connected.response.status,200);const presenceId=connected.body.presenceId;
|
||||
const claim=await request('/api/realtime/claim',{method:'POST',headers:auth(alice),body:JSON.stringify({presenceId,boardId:'B0'})});assert.equal(claim.response.status,200);assert.equal(claim.body.ok,true);
|
||||
result=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)}]})});
|
||||
assert.equal(result.response.status,200,JSON.stringify(result.body));assert.equal(result.body.clearEvents.length,1,'Clear was not authoritatively accepted');assert(result.body.player.earnedScore>0,'Clear response did not include earned gems');assert.equal(result.body.player.availableScore,result.body.player.earnedScore);
|
||||
const reward=result.body.player.earnedScore,clearRevision=result.body.revision;
|
||||
const polling=await request(`/api/realtime/poll?presenceId=${encodeURIComponent(presenceId)}&afterSequence=${connected.body.sequence||0}`,{headers:auth(alice)});assert.equal(polling.response.status,200);assert(polling.body.messages.some(message=>message.type==='claim-release'&&message.boardId==='B0'),'Clear did not release the プレイ中 claim');
|
||||
let pull=await request('/api/cloud/pull?since=0',{headers:auth(bob)});assert.equal(pull.body.page.states.B0.solved,true,'Other player did not receive the clear');
|
||||
const playerState=await request('/api/player/state',{headers:auth(alice)});assert.equal(playerState.body.player.earnedScore,reward,'Gem wallet did not persist the clear reward');
|
||||
const stale=await request('/api/cloud/push',{method:'POST',headers:auth(bob),body:JSON.stringify({baseRevision:clearRevision,global:{nextId:1},metas:[],states:[{id:'B0',value:{paths:[],specialProgress:{crossings:[]},solved:false}}]})});assert.equal(stale.response.status,200,JSON.stringify(stale.body));
|
||||
pull=await request('/api/cloud/pull?since=0',{headers:auth(bob)});assert.equal(pull.body.page.states.B0.solved,true,'A stale unfinished update reverted a cleared board');
|
||||
const b1Puzzle=BendPuzzle.generatePuzzle([[0,0]],0x48000001,1,1,0),b1=boardMeta('B1',1,0x48000001,b1Puzzle);
|
||||
result=await request('/api/cloud/push',{method:'POST',headers:auth(alice),body:JSON.stringify({baseRevision:stale.body.revision,global:{nextId:2},metas:[b1],states:[{id:'B1',value:{paths:[],specialProgress:{crossings:[]},solved:false}}]})});
|
||||
assert.equal(result.response.status,200,JSON.stringify(result.body));const expansionRevision=result.body.revision;
|
||||
pull=await request('/api/cloud/pull?since=0',{headers:auth(bob)});assert(pull.body.page.metas.B1&&pull.body.page.states.B1,'New shared board disappeared before the next pull');
|
||||
await stop();start();const restarted=await ready();assert.equal(restarted.revision,expansionRevision);
|
||||
pull=await request('/api/cloud/pull?since=0',{headers:auth(bob)});assert.equal(pull.body.page.states.B0.solved,true,'Cleared board reverted after server restart');assert(pull.body.page.metas.B1&&pull.body.page.states.B1,'New board disappeared after server restart');
|
||||
const economyAfterRestart=await request('/api/player/state',{headers:auth(alice)});assert.equal(economyAfterRestart.body.player.earnedScore,reward,'Gem wallet disappeared after server restart');
|
||||
console.log('LinkField v48.00 shared clear, claim release, expansion, and gem persistence smoke test passed');
|
||||
})().catch(error=>{console.error(error);if(stderr)console.error(stderr);process.exitCode=1}).finally(async()=>{await stop();fs.rmSync(dataDir,{recursive:true,force:true})});
|
||||
Loading…
Add table
Add a link
Reference in a new issue