'use strict'; const {spawn}=require('child_process'); const fs=require('fs'); const os=require('os'); const path=require('path'); const assert=require('assert/strict'); const {root,starterPuzzle,read,functionSource}=require('./helpers/app-source'); const {connectRealtime}=require('./helpers/realtime-client'); const app=read('app.js'),html=read('index.html'),css=read('style.css'),serverSource=read('server.js'),realtimeSource=read('realtime-server.js'); assert(html.includes('id="reactionCanvas"')&&html.includes('id="reactionRadial"')&&css.includes('#reactionCanvas')&&css.includes('z-index:2'),'Reaction layer is not behind boards'); assert(functionSource('beginReactionGesture').includes('REACTION_LONG_PRESS_MS')&&functionSource('reactionAllowedAt').includes('metaState(boardId).solved'),'Reaction click/long-press eligibility is missing'); assert(functionSource('drawReactionLayer').includes('REALTIME_REACTION_DURATION')||app.includes('REALTIME_REACTION_DURATION=4500'),'Reaction animation is not bounded'); assert(functionSource('inventoryEntries').includes('personalEconomyMode()')&&functionSource('purchaseStoreItem').includes('buyPersonalStoreItem'),'Player inventory or personal purchasing is not server-backed'); 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(),'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}} function auth(session){return{authorization:`Bearer ${session.playerId}.${session.token}`,'content-type':'application/json'}} function boardMeta(id,x,seed,puzzle){return{id,x,y:0,chunks:[[0,0]],level:1,targetLevel:1,seed,axis:'MIX',sealedSides:[],puzzle,rev:1,revAuthor:'client'}} function solvedState(puzzle){return{paths:puzzle.solution.map(route=>({startGate:route.startGate,endGate:route.endGate,cells:route.cells.map(cell=>[...cell])})),solved:true,expanded:false,scoreAwarded:100000,scoreVersion:6,store:{owner:'',pathIndex:0,cellIndex:0,itemIds:['score-lens'],purchases:[],priceVersion:1,priceCoefficient:1},rev:2,revAuthor:'client'}} (async()=>{ for(let i=0;i<100;i++){try{if((await request('/api/cloud/status')).response.ok)break}catch(_){}await sleep(40)} const status=(await request('/api/cloud/status')).body;assert.equal(status.reactions,true);assert.equal(status.playerEconomy,true);assert.equal(status.sharedItems,false); const alice=(await request('/api/cloud/session',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({name:'Alice'})})).body; const bob=(await request('/api/cloud/session',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({name:'Bob'})})).body; const puzzle=starterPuzzle(),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,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:'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:'🤩',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(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'})}) ]);assert.equal(renamed.response.status,200);assert.equal(purchase.response.status,201);assert.equal(purchase.body.player.purchases.length,1);assert.equal(purchase.body.purchase.itemId,'score-lens'); const racedRecord=JSON.parse(fs.readFileSync(alicePath,'utf8'));assert.equal(racedRecord.name,'Alice Concurrent');assert.equal(racedRecord.purchases.length,1,'Concurrent profile update overwrote the purchase'); const duplicate=await request('/api/player/purchase',{method:'POST',headers:auth(alice),body:JSON.stringify({boardId:'B0',itemId:'score-lens'})});assert.equal(duplicate.response.status,200);assert.equal(duplicate.body.purchase.purchaseId,purchase.body.purchase.purchaseId); const bobState=await request('/api/player/state',{headers:auth(bob)});assert.deepEqual(bobState.body.player.purchases,[]); const retiredPurchase=await request('/api/player/purchase',{method:'POST',headers:auth(alice),body:JSON.stringify({boardId:'B0',itemId:'level-min-10'})});assert.equal(retiredPurchase.response.status,400); const retiredPlacement=await request('/api/player/place-field',{method:'POST',headers:auth(alice),body:JSON.stringify({boardId:'B0',itemId:'level-min-10',x:4.25,y:-2.5})});assert.equal(retiredPlacement.response.status,404); const pull=await request('/api/cloud/pull?since=0&eventsSince=0',{headers:auth(bob)});assert.equal(pull.response.status,200);assert.equal(pull.body.page.global.fieldEffects,undefined); assert(!app.includes('level-min-10')&&!app.includes('level-max-10')&&!app.includes('fieldOverlayCanvas'),'Retired difficulty items remain in the client bundle'); console.log('Shared-world reactions and personal item economy smoke test passed'); })().catch(error=>{console.error(error);if(stderr)console.error(stderr);process.exitCode=1}).finally(()=>{aliceWs?.close();bobWs?.close();child.kill('SIGTERM');fs.rmSync(dataDir,{recursive:true,force:true})});