bend_puzzle/test/v4800-shared-clear-economy-smoke-test.js
2026-08-01 16:06:14 +09:00

64 lines
8.2 KiB
JavaScript

'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})});