2026-07-30 15:13:53 +09:00
'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' ) ;
2026-07-31 12:54:46 +09:00
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' ) ;
2026-07-30 15:13:53 +09:00
assert ( realtimeSource . includes ( "message.type === 'reaction'" ) && realtimeSource . includes ( 'REACTION_MIN_INTERVAL_MS' ) && ! realtimeSource . includes ( 'broadcastFieldEffect' ) , 'Realtime reaction throttling is missing or field broadcasts remain' ) ;
const port = 24000 + Math . floor ( Math . random ( ) * 8000 ) , dataDir = fs . mkdtempSync ( path . join ( os . tmpdir ( ) , 'bend-field-complete-' ) ) ;
const child = spawn ( process . execPath , [ path . join ( root , 'server.js' ) ] , { env : { ... process . env , PORT : String ( port ) , HOST : '127.0.0.1' , BEND _FIELD _DATA _DIR : dataDir } , stdio : [ 'ignore' , 'pipe' , 'pipe' ] } ) ;
let stderr = '' , aliceWs = null , bobWs = null ; child . stderr . on ( 'data' , chunk => stderr += chunk ) ;
const base = ` http://127.0.0.1: ${ port } ` , sleep = ms => new Promise ( resolve => setTimeout ( resolve , ms ) ) ;
async function request ( url , options = { } ) { const response = await fetch ( base + url , options ) , body = await response . json ( ) ; return { response , body } }
function auth ( session ) { return { authorization : ` Bearer ${ session . playerId } . ${ session . token } ` , 'content-type' : 'application/json' } }
function boardMeta ( id , x , seed , puzzle ) { return { id , x , y : 0 , chunks : [ [ 0 , 0 ] ] , level : 1 , targetLevel : 1 , seed , axis : 'MIX' , sealedSides : [ ] , puzzle , rev : 1 , revAuthor : 'client' } }
function solvedState ( puzzle ) { return { paths : puzzle . solution . map ( route => ( { startGate : route . startGate , endGate : route . endGate , cells : route . cells . map ( cell => [ ... cell ] ) } ) ) , solved : true , expanded : false , scoreAwarded : 100000 , scoreVersion : 6 , store : { owner : '' , pathIndex : 0 , cellIndex : 0 , itemIds : [ 'score-lens' ] , purchases : [ ] , priceVersion : 1 , priceCoefficient : 1 } , rev : 2 , revAuthor : 'client' } }
( async ( ) => {
for ( let i = 0 ; i < 100 ; i ++ ) { try { if ( ( await request ( '/api/cloud/status' ) ) . response . ok ) break } catch ( _ ) { } await sleep ( 40 ) }
const status = ( await request ( '/api/cloud/status' ) ) . body ; assert . equal ( status . reactions , true ) ; assert . equal ( status . playerEconomy , true ) ; assert . equal ( status . sharedItems , false ) ;
const alice = ( await request ( '/api/cloud/session' , { method : 'POST' , headers : { 'content-type' : 'application/json' } , body : JSON . stringify ( { name : 'Alice' } ) } ) ) . body ;
const bob = ( await request ( '/api/cloud/session' , { method : 'POST' , headers : { 'content-type' : 'application/json' } , body : JSON . stringify ( { name : 'Bob' } ) } ) ) . body ;
2026-07-31 12:54:46 +09:00
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 ;
const b0 = boardMeta ( 'B0' , 0 , 15 , puzzle ) ;
2026-07-30 15:13:53 +09:00
let pushed = await request ( '/api/cloud/push' , { method : 'POST' , headers : auth ( alice ) , body : JSON . stringify ( { baseRevision : 0 , global : { schema : 31 , appVersion : '47.70' , generatorVersion : 5 , worldGeneration : 'v47-field-reset-20260728-interaction-fix' , nextId : 1 } , metas : [ b0 ] , states : [ { id : 'B0' , value : { paths : [ ] , solved : false , rev : 1 , revAuthor : 'alice' } } ] , deleted : [ ] } ) } ) ; assert . equal ( pushed . response . status , 200 ) ;
aliceWs = await connectRealtime ( base , alice ) ; bobWs = await connectRealtime ( base , bob ) ; aliceWs . send ( { type : 'viewport' , minX : - 8 , minY : - 8 , maxX : 8 , maxY : 8 } ) ; bobWs . send ( { type : 'viewport' , minX : - 8 , minY : - 8 , maxX : 8 , maxY : 8 } ) ; await aliceWs . waitFor ( 'snapshot' ) ; await bobWs . waitFor ( 'snapshot' ) ;
aliceWs . send ( { type : 'reaction' , id : 'reaction-one' , emoji : '🎉' , x : . 4 , y : . 6 } ) ; const reaction = await bobWs . waitFor ( message => message . type === 'reaction' && message . reaction ? . id === 'reaction-one' ) ; assert . equal ( reaction . reaction . emoji , '🎉' ) ; assert . equal ( reaction . reaction . playerName , 'Alice' ) ; assert ( reaction . reaction . expiresAt > reaction . reaction . createdAt ) ;
aliceWs . send ( { type : 'claim' , requestId : 'claim-b0' , boardId : 'B0' } ) ; assert . equal ( ( await aliceWs . waitFor ( message => message . type === 'claim-result' && message . requestId === 'claim-b0' ) ) . ok , true ) ;
pushed = await request ( '/api/cloud/push' , { method : 'POST' , headers : auth ( alice ) , body : JSON . stringify ( { baseRevision : 1 , global : { nextId : 1 } , metas : [ ] , states : [ { id : 'B0' , value : solvedState ( puzzle ) } ] , deleted : [ ] } ) } ) ; assert . equal ( pushed . response . status , 200 ) ; let revision = pushed . body . revision ;
const alicePath = path . join ( dataDir , ` ${ alice . playerId } .json ` ) , aliceRecord = JSON . parse ( fs . readFileSync ( alicePath , 'utf8' ) ) ; aliceRecord . earnedScore = 250000 ; fs . writeFileSync ( alicePath , JSON . stringify ( aliceRecord ) ) ;
2026-07-31 12:54:46 +09:00
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' ) ;
2026-07-30 15:13:53 +09:00
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 } ) } ) ;