'use strict'; const assert=require('assert/strict'); const vm=require('vm'); const {functionSource,loadAppLogic}=require('./helpers/app-source'); const {normalizeSpecialMechanics}=require('../shared-contracts'); const outboxContext={ data:{states:{B0:{solved:false},B1:{solved:true}}},cloudApiEnabled:true, cloudJournalMetaIds:new Set(),cloudJournalStateIds:new Set(),cloudJournalDeletedIds:new Set(),cloudOutboxDeleteKeys:new Set(),cloudJournalChangeSeq:0,cloudJournalGlobalChanged:false }; vm.createContext(outboxContext); vm.runInContext(`${functionSource('currentCloudPending')}\n${functionSource('noteCloudRow')}\nthis.logic={currentCloudPending,noteCloudRow};`,outboxContext); outboxContext.logic.noteCloudRow('state','B0'); assert.equal(outboxContext.cloudJournalStateIds.has('B0'),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],['B0','B1']); const AppLogic=loadAppLogic(),now=1_800_000_000_000; const leaseContext={ cloudAvailable:true,data:{cloudProfile:{playerId:'bbbbbbbbbbbbbbbb'}},currentPlayerId:()=> 'bbbbbbbbbbbbbbbb',trustedNow:()=>now,AppLogic, SHARED_EXPANSION_GRACE_MS:60_000,SHARED_EXPANSION_JITTER_MS:30_000 }; vm.createContext(leaseContext); vm.runInContext(`${functionSource('sharedExpansionRepairDelay')}\nthis.sharedExpansionRepairDelay=sharedExpansionRepairDelay;`,leaseContext); assert.equal(leaseContext.sharedExpansionRepairDelay({solved:true,solvedById:'bbbbbbbbbbbbbbbb',solvedAt:now}),0,'The solving client cannot expand its own clear'); const wait=leaseContext.sharedExpansionRepairDelay({solved:true,solvedById:'aaaaaaaaaaaaaaaa',solvedAt:now}); assert.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}; const authoritativeContext={ cloudApiEnabled:true, data:{ metas:{B0:localMeta},states:{B0:{solved:true,solvedBy:'Local',paths:[{cells:[[0,0]]}],rev:9_999}}, nextId:99,solved:1,lastSolveAt:123,specialMechanicsSeen:['warp'],quarantine:{local:true}, playerName:'Player',score:777,cursorStyle:'flag-jp' }, isPlainObject:value=>value&&typeof value==='object'&&!Array.isArray(value),lastRevision:0,normalizedStateObjects:new Set(), normalizeSpecialMechanics, cloudJournalMetaIds:new Set(['B0']),cloudJournalStateIds:new Set(['B0']),cloudJournalDeletedIds:new Set(),cloudOutboxDeleteKeys:new Set(),cloudJournalChangeSeq:0, sameMetaGeometry:(a,b)=>a.x===b.x&&a.y===b.y&&a.seed===b.seed&&JSON.stringify(a.chunks)===JSON.stringify(b.chunks)&&JSON.stringify(a.sealedSides||[])===JSON.stringify(b.sealedSides||[]), destroyBoard:()=>{},rendered:new Map(),deepClone:value=>JSON.parse(JSON.stringify(value)),sameDataValue:(a,b)=>JSON.stringify(a)===JSON.stringify(b), compareRevisionVersions:(a,b)=>(a?.rev||0)-(b?.rev||0), mergeBoardStates:(current,incoming)=>{ if(current?.solved&&!incoming?.solved)return JSON.parse(JSON.stringify(current)); 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:()=>{},boardClaimOwnedByMe:()=>false, mergeGlobalFields:()=>{throw new Error('Authoritative shared global unexpectedly used generic merge')},resolveMergedOverlaps:()=>[],statsDirty:false }; vm.createContext(authoritativeContext); vm.runInContext(`${functionSource('clearSharedWorldJournalRow')}\n${functionSource('noteCloudRow')}\n${functionSource('applyAuthoritativeSharedGlobal')}\n${functionSource('mergeSnapshotIntoData')}\nthis.mergeSnapshotIntoData=mergeSnapshotIntoData;`,authoritativeContext); authoritativeContext.mergeSnapshotIntoData({ metas:{B0:remoteMeta},states:{B0:{solved:false,paths:[],rev:2_000}}, nextId:2,solved:0,lastSolveAt:0,specialMechanicsSeen:['lock'],quarantine:{shared:true} },{finalize:false,authoritativeWorld:true}); assert.equal(authoritativeContext.data.metas.B0.seed,2,'An existing local world row overrode the authoritative shared board'); assert.equal(authoritativeContext.data.states.B0.solved,false,'A clear from a replaced board definition leaked into the authoritative shared board'); assert.equal(authoritativeContext.data.nextId,2,'A private local board counter leaked into the shared world'); assert.equal(authoritativeContext.data.playerName,'Player');assert.equal(authoritativeContext.data.score,777);assert.equal(authoritativeContext.data.cursorStyle,'flag-jp'); assert.equal(authoritativeContext.cloudJournalMetaIds.size,0);assert.equal(authoritativeContext.cloudJournalStateIds.size,0); assert(authoritativeContext.cloudOutboxDeleteKeys.has('meta:B0')&&authoritativeContext.cloudOutboxDeleteKeys.has('state:B0'),'Replaced shared rows did not clear stale local outbox records'); // A durable clear for the same board definition survives a stale authoritative snapshot and is queued for upload. authoritativeContext.cloudJournalStateIds.clear();authoritativeContext.cloudOutboxDeleteKeys.clear(); authoritativeContext.data.metas.B0=JSON.parse(JSON.stringify(remoteMeta)); authoritativeContext.data.states.B0={solved:true,solvedBy:'Local',paths:[{cells:[[0,0]]}],rev:9_999}; 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.solved,true,'A durable clear was downgraded by a stale authoritative snapshot of the same board'); 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 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,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');