2026-07-30 15:13:53 +09:00
'use strict' ;
const http = require ( 'http' ) ;
const fs = require ( 'fs' ) ;
const fsp = fs . promises ;
const path = require ( 'path' ) ;
const crypto = require ( 'crypto' ) ;
const { URL } = require ( 'url' ) ;
const { createRealtimeHub } = require ( './realtime-server' ) ;
2026-07-31 12:54:46 +09:00
const { createHttpRouter } = require ( './server/http-router' ) ;
const { createAuthenticator } = require ( './server/auth' ) ;
const { createJsonRepository } = require ( './server/json-repository' ) ;
2026-08-01 16:06:14 +09:00
const { installApacheBridge } = require ( './server/apache-bridge' ) ;
2026-07-31 12:54:46 +09:00
const { createPlayerService } = require ( './server/player-service' ) ;
const BuildMeta = require ( './build-meta' ) ;
const SharedContracts = require ( './shared-contracts' ) ;
2026-07-30 15:13:53 +09:00
const AppLogic = require ( './app-logic' ) ;
const PuzzleCore = require ( './puzzle-core' ) ;
const STORE _CATALOG = new Map ( require ( './store-catalog.json' ) . map ( item => [ item . id , Object . freeze ( item ) ] ) ) ;
2026-08-01 16:06:14 +09:00
const STARTER _LINE _COLOR _IDS = Object . freeze ( [ ... STORE _CATALOG . values ( ) ] . filter ( item => item . lineColor && ! item . aurora && item . cost === 5000 ) . map ( item => item . id ) ) ;
2026-07-30 15:13:53 +09:00
const ROOT = _ _dirname ;
2026-08-01 16:06:14 +09:00
const PUBLIC _ROOT = path . resolve ( process . env . LINK _FIELD _PUBLIC _DIR || ROOT ) ;
const PRODUCTION _DATA _DIR = path . resolve ( '/link-field/world' ) ;
const TEST _DATA _ROOT = String ( process . env . LINK _FIELD _TEST _DATA _ROOT || '' ) . trim ( ) ;
const DATA _DIR = TEST _DATA _ROOT ? path . join ( path . resolve ( TEST _DATA _ROOT ) , 'world' ) : PRODUCTION _DATA _DIR ;
const INSTANCE _LOCK _FILE = path . join ( DATA _DIR , 'server.pid' ) ;
2026-07-30 15:13:53 +09:00
const WORLD _FILE = path . join ( DATA _DIR , 'shared-world.json' ) ;
2026-07-31 12:54:46 +09:00
const WORLD _COMMIT _FILE = path . join ( DATA _DIR , 'shared-world.commit.json' ) ;
2026-07-30 15:13:53 +09:00
const WORLD _BOARDS _DIR = path . join ( DATA _DIR , 'shared-world.boards' ) ;
2026-08-01 16:06:14 +09:00
const DEFAULT _PORT = 8080 ;
const FALLBACK _PORT _START = 3000 ;
const FALLBACK _PORT _COUNT = 20 ;
function commandLineOption ( name ) {
const direct = process . argv . find ( argument => argument . startsWith ( ` ${ name } = ` ) ) ;
if ( direct ) return direct . slice ( name . length + 1 ) ;
const index = process . argv . indexOf ( name ) ;
return index >= 0 ? process . argv [ index + 1 ] : undefined ;
}
function parsePort ( value , fallback = DEFAULT _PORT ) {
if ( value == null || String ( value ) . trim ( ) === '' ) return fallback ;
const port = Number ( value ) ;
if ( ! Number . isSafeInteger ( port ) || port < 0 || port > 65535 ) throw new Error ( ` Invalid server port: ${ value } ` ) ;
return port ;
}
function uniquePorts ( values ) {
return [ ... new Set ( values . filter ( port => Number . isSafeInteger ( port ) && port >= 0 && port <= 65535 ) ) ] ;
}
function defaultPortCandidates ( preferredPort = DEFAULT _PORT ) {
const fallbackPorts = Array . from ( { length : FALLBACK _PORT _COUNT } , ( _ , index ) => FALLBACK _PORT _START + index ) ;
return uniquePorts ( [ preferredPort , ... fallbackPorts , 0 ] ) ;
}
const CLI _HOST = commandLineOption ( '--host' ) ;
const CLI _PORT = commandLineOption ( '--port' ) ;
const HOST = CLI _HOST || process . env . HOST || '127.0.0.1' ;
const PORT _SOURCE = CLI _PORT ? ? process . env . LINK _FIELD _PORT ? ? process . env . PORT ;
const PORT _EXPLICIT = PORT _SOURCE != null && String ( PORT _SOURCE ) . trim ( ) !== '' ;
const PORT _STRICT = process . argv . includes ( '--strict-port' ) || [ '1' , 'true' , 'on' , 'yes' ] . includes ( String ( process . env . LINK _FIELD _STRICT _PORT || '' ) . trim ( ) . toLowerCase ( ) ) ;
const PORT = parsePort ( PORT _SOURCE , DEFAULT _PORT ) ;
const APACHE _BRIDGE _ENABLED = ! [ '0' , 'false' , 'off' , 'no' ] . includes ( String ( process . env . LINK _FIELD _APACHE _BRIDGE || '' ) . trim ( ) . toLowerCase ( ) ) ;
const PUBLIC _BRIDGE _PORT _FILE = path . join ( PUBLIC _ROOT , '.linkfield-port' ) ;
2026-07-30 15:13:53 +09:00
const MAX _BODY _BYTES = 64 * 1024 * 1024 ;
const MAX _BOARDS _PER _PUSH = 10000 ;
const MAX _PATHS _PER _BOARD = 512 ;
const MAX _CELLS _PER _PATH = 5000 ;
const CLOUD _PAGE _LIMIT = 512 ;
const CHANGE _HISTORY _LIMIT = 256 ;
const CLEAR _EVENT _LIMIT = 64 ;
const MAX _PLAYER _PURCHASES = 10000 ;
const GENERATION _FAILURE _BONUS = 2500 ;
const GENERATION _FAILURE _MIN _DELAY _MS = 8000 ;
const PLAYER _RE = /^[a-f0-9]{16,64}$/i ;
const TOKEN _RE = /^[a-f0-9]{32,128}$/i ;
2026-07-31 12:54:46 +09:00
const BOARD _RE = SharedContracts . BOARD _ID _RE ;
2026-07-30 15:13:53 +09:00
const MIME = {
'.html' : 'text/html; charset=utf-8' , '.js' : 'text/javascript; charset=utf-8' , '.css' : 'text/css; charset=utf-8' ,
'.svg' : 'image/svg+xml' , '.ico' : 'image/x-icon' , '.ttf' : 'font/ttf' , '.json' : 'application/json; charset=utf-8' ,
'.txt' : 'text/plain; charset=utf-8' , '.md' : 'text/markdown; charset=utf-8' ,
} ;
let worldQueue = Promise . resolve ( ) ;
const playerQueues = new Map ( ) ;
let realtimeHub = null ;
2026-07-31 12:54:46 +09:00
const jsonRepository = createJsonRepository ( { fsp , crypto } ) ;
2026-07-30 15:13:53 +09:00
function json ( res , status , value ) {
const body = JSON . stringify ( value ) ;
res . writeHead ( status , { 'content-type' : 'application/json; charset=utf-8' , 'content-length' : Buffer . byteLength ( body ) , 'cache-control' : 'no-store' , 'x-content-type-options' : 'nosniff' } ) ;
res . end ( body ) ;
}
function serverTime ( ) { return Date . now ( ) }
function tokenHash ( token ) { return crypto . createHash ( 'sha256' ) . update ( token ) . digest ( 'hex' ) }
function safeEqualHex ( a , b ) { if ( typeof a !== 'string' || typeof b !== 'string' || a . length !== b . length ) return false ; try { return crypto . timingSafeEqual ( Buffer . from ( a , 'hex' ) , Buffer . from ( b , 'hex' ) ) } catch { return false } }
function badRequest ( message ) { throw Object . assign ( new Error ( message ) , { status : 400 } ) }
function finiteNumber ( value , fallback = 0 ) { return Number . isFinite ( Number ( value ) ) ? Number ( value ) : fallback }
function cleanPlayerName ( value , fallback = '' ) {
2026-07-31 12:54:46 +09:00
return SharedContracts . cleanPlayerName ( value , fallback ) ;
2026-07-30 15:13:53 +09:00
}
function defaultPlayerName ( playerId ) { return ` 旅人- ${ String ( playerId ) . slice ( 0 , 4 ) . toUpperCase ( ) } ` }
2026-07-31 12:54:46 +09:00
function cleanId ( value , maxLength = 64 ) { return SharedContracts . cleanContractId ( value , maxLength ) }
2026-07-30 15:13:53 +09:00
function storeItem ( itemId ) { return STORE _CATALOG . get ( String ( itemId || '' ) ) || null }
function normalizePlayerPurchases ( raw ) {
2026-07-31 12:54:46 +09:00
return SharedContracts . normalizePlayerPurchases ( raw , { resolveItem : storeItem , maxPurchases : MAX _PLAYER _PURCHASES } ) ;
2026-07-30 15:13:53 +09:00
}
function normalizeGenerationBonuses ( raw ) { return [ ... new Set ( ( Array . isArray ( raw ) ? raw : [ ] ) . map ( value => String ( value || '' ) ) . filter ( value => BOARD _RE . test ( value ) ) ) ] . slice ( - 10000 ) }
function playerSpentScore ( record ) { return normalizePlayerPurchases ( record ? . purchases ) . reduce ( ( sum , purchase ) => sum + Math . max ( 0 , Number ( purchase . paidCost ) || 0 ) , 0 ) }
function playerEarnedScore ( record ) { return Number . isSafeInteger ( record ? . earnedScore ) && record . earnedScore >= 0 ? record . earnedScore : 0 }
2026-08-01 16:06:14 +09:00
function starterLineColorForPlayer ( playerId ) { const digest = crypto . createHash ( 'sha256' ) . update ( ` bend-field-line-color: ${ String ( playerId || '' ) } ` ) . digest ( ) ; return STARTER _LINE _COLOR _IDS [ digest . readUInt32BE ( 0 ) % STARTER _LINE _COLOR _IDS . length ] }
function normalizeStarterLineColor ( value , playerId ) { return STARTER _LINE _COLOR _IDS . includes ( value ) ? value : starterLineColorForPlayer ( playerId ) }
function publicPlayerState ( record ) { const earnedScore = playerEarnedScore ( record ) , spentScore = playerSpentScore ( record ) ; return { revision : Number . isSafeInteger ( record ? . economyRevision ) ? record . economyRevision : 0 , purchases : normalizePlayerPurchases ( record ? . purchases ) , starterLineColor : normalizeStarterLineColor ( record ? . starterLineColor , record ? . playerId ) , earnedScore , spentScore , availableScore : Math . max ( 0 , earnedScore - spentScore ) , updatedAt : finiteNumber ( record ? . updatedAt , 0 ) } }
function publicCloudPlayer ( record ) { return { id : record . playerId , name : record . name , ... publicPlayerState ( record ) } }
2026-07-30 15:13:53 +09:00
function withPlayerQueue ( playerId , task ) { const previous = playerQueues . get ( playerId ) || Promise . resolve ( ) , run = previous . then ( task , task ) , tail = run . then ( ( ) => undefined , ( ) => undefined ) ; playerQueues . set ( playerId , tail ) ; return run . finally ( ( ) => { if ( playerQueues . get ( playerId ) === tail ) playerQueues . delete ( playerId ) } ) }
function playerPath ( playerId ) { if ( ! PLAYER _RE . test ( playerId ) ) badRequest ( 'Invalid player id' ) ; return path . join ( DATA _DIR , ` ${ playerId . toLowerCase ( ) } .json ` ) }
function worldBoardVersionPath ( boardId , revision ) { if ( ! BOARD _RE . test ( boardId ) || ! Number . isSafeInteger ( revision ) || revision < 0 ) badRequest ( 'Invalid board version' ) ; return path . join ( WORLD _BOARDS _DIR , ` ${ boardId } . ${ revision } .json ` ) }
2026-07-31 12:54:46 +09:00
async function atomicWriteJson ( file , value ) { return jsonRepository . write ( file , value ) }
2026-07-30 15:13:53 +09:00
async function readPlayer ( playerId ) {
2026-08-01 16:06:14 +09:00
try { const value = JSON . parse ( await fsp . readFile ( playerPath ( playerId ) , 'utf8' ) ) ; if ( ! value || typeof value !== 'object' ) throw new Error ( 'Invalid player record' ) ; value . name = cleanPlayerName ( value . name , defaultPlayerName ( playerId ) ) ; value . purchases = normalizePlayerPurchases ( value . purchases ) ; value . generationBonuses = normalizeGenerationBonuses ( value . generationBonuses ) ; value . economyRevision = Number . isSafeInteger ( value . economyRevision ) ? value . economyRevision : 0 ; value . earnedScore = playerEarnedScore ( value ) ; value . starterLineColor = normalizeStarterLineColor ( value . starterLineColor , playerId ) ; return value }
2026-07-30 15:13:53 +09:00
catch ( error ) { if ( error . code === 'ENOENT' ) throw Object . assign ( new Error ( 'Cloud profile not found' ) , { status : 404 } ) ; throw error }
}
2026-07-31 12:54:46 +09:00
function emptyWorld ( ) { const now = serverTime ( ) ; return { revision : 0 , rowRevision : now * 1000 , expansionGrants : { } , global : { schema : BuildMeta . SAVE _SCHEMA , appVersion : BuildMeta . APP _VERSION , generatorVersion : BuildMeta . GENERATOR _VERSION , worldGeneration : BuildMeta . WORLD _GENERATION , nextId : 1 , solved : 0 , earnedScore : 0 , specialMechanicsSeen : [ ] , updatedAt : now , cloudRevision : 0 } , boardVersions : { } , occupancy : { } , changes : [ ] , clearEvents : [ ] , createdAt : now , updatedAt : now } }
2026-07-30 15:13:53 +09:00
async function readWorld ( ) {
2026-08-01 16:06:14 +09:00
try {
const value = JSON . parse ( await fsp . readFile ( WORLD _FILE , 'utf8' ) ) ; if ( ! value || typeof value !== 'object' ) throw new Error ( 'Invalid shared world' ) ;
if ( value . global ? . worldGeneration !== BuildMeta . WORLD _GENERATION ) return emptyWorld ( ) ;
value . revision = Number . isSafeInteger ( value . revision ) ? value . revision : 0 ; value . rowRevision = Number . isSafeInteger ( value . rowRevision ) ? value . rowRevision : Math . max ( 0 , serverTime ( ) * 1000 ) ; value . boardVersions = value . boardVersions && typeof value . boardVersions === 'object' ? value . boardVersions : { } ; value . changes = Array . isArray ( value . changes ) ? value . changes : [ ] ; value . clearEvents = Array . isArray ( value . clearEvents ) ? value . clearEvents : [ ] ; value . expansionGrants = value . expansionGrants && typeof value . expansionGrants === 'object' ? value . expansionGrants : { } ; value . global = value . global && typeof value . global === 'object' ? value . global : { } ; return value
} catch ( error ) { if ( error . code === 'ENOENT' ) return emptyWorld ( ) ; throw error }
2026-07-30 15:13:53 +09:00
}
async function readWorldBoard ( record , id ) { const revision = record . boardVersions [ id ] ; if ( ! Number . isSafeInteger ( revision ) ) return null ; try { const value = JSON . parse ( await fsp . readFile ( worldBoardVersionPath ( id , revision ) , 'utf8' ) ) ; return value && typeof value === 'object' ? value : null } catch ( error ) { if ( error . code === 'ENOENT' ) throw Object . assign ( new Error ( ` Shared board is missing: ${ id } ` ) , { status : 500 } ) ; throw error } }
2026-07-31 12:54:46 +09:00
async function ensureWorldIndexes ( record ) {
if ( record . occupancy && typeof record . occupancy === 'object' && ! Array . isArray ( record . occupancy ) && Number . isSafeInteger ( record . global ? . solved ) && Number . isSafeInteger ( record . global ? . earnedScore ) ) return record ;
const occupancy = { } ; let solved = 0 , earnedScore = 0 ;
for ( const id of Object . keys ( record . boardVersions ) ) { const row = await readWorldBoard ( record , id ) ; if ( ! row ? . meta ) continue ; for ( const [ dx , dy ] of row . meta . chunks || [ ] ) { const key = ` ${ row . meta . x + dx } , ${ row . meta . y + dy } ` ; if ( occupancy [ key ] && occupancy [ key ] !== id ) throw new Error ( ` Stored board overlap: ${ occupancy [ key ] } / ${ id } ` ) ; occupancy [ key ] = id } if ( row . state ? . solved ) { solved ++ ; const award = Number ( row . state . scoreAwarded ) || 0 ; if ( Number . isSafeInteger ( award ) && award > 0 ) earnedScore += award } }
record . occupancy = occupancy ; record . global = { ... ( record . global || { } ) , solved , earnedScore : Math . max ( 0 , earnedScore ) } ; return record ;
}
function addMetaToWorldOccupancy ( meta , occupancy , { requireTouch = false } = { } ) {
let touches = ! requireTouch ; const keys = [ ] ;
for ( const [ dx , dy ] of meta . chunks || [ ] ) { const x = meta . x + dx , y = meta . y + dy , key = ` ${ x } , ${ y } ` ; if ( occupancy [ key ] && occupancy [ key ] !== meta . id ) badRequest ( ` Board overlap: ${ occupancy [ key ] } / ${ meta . id } ` ) ; keys . push ( key ) ; for ( const [ ox , oy ] of [ [ 1 , 0 ] , [ - 1 , 0 ] , [ 0 , 1 ] , [ 0 , - 1 ] ] ) if ( occupancy [ ` ${ x + ox } , ${ y + oy } ` ] && occupancy [ ` ${ x + ox } , ${ y + oy } ` ] !== meta . id ) touches = true }
if ( ! touches ) badRequest ( ` Generated board is not adjacent: ${ meta . id } ` ) ; for ( const key of keys ) occupancy [ key ] = meta . id ; return true ;
}
async function collectRetiredBoardVersions ( ) {
const world = await readWorld ( ) ; let names = [ ] ; try { names = await fsp . readdir ( WORLD _BOARDS _DIR ) } catch ( error ) { if ( error . code === 'ENOENT' ) return 0 ; throw error }
let removed = 0 ;
for ( const name of names ) {
const match = /^(B(?:0|[1-9][0-9]*))\.([0-9]+)\.json$/ . exec ( name ) ; if ( ! match ) continue ;
const revision = Number ( match [ 2 ] ) ; if ( world . boardVersions [ match [ 1 ] ] === revision ) continue ;
await fsp . unlink ( path . join ( WORLD _BOARDS _DIR , name ) ) . catch ( error => { if ( error . code !== 'ENOENT' ) throw error } ) ; removed ++ ;
}
return removed ;
}
async function recoverPendingWorldCommit ( ) {
let commit ; try { commit = JSON . parse ( await fsp . readFile ( WORLD _COMMIT _FILE , 'utf8' ) ) } catch ( error ) { if ( error . code === 'ENOENT' ) return false ; throw error }
if ( ! commit || ! Number . isSafeInteger ( commit . revision ) || ! commit . world || commit . world . revision !== commit . revision ) throw new Error ( 'Invalid pending shared-world commit' ) ;
const current = await readWorld ( ) ;
if ( current . revision < commit . revision ) {
let prepared = true ;
for ( const id of commit . changedBoardIds || [ ] ) { const revision = commit . world . boardVersions ? . [ id ] ; if ( ! Number . isSafeInteger ( revision ) ) { prepared = false ; break } try { await fsp . access ( worldBoardVersionPath ( id , revision ) ) } catch { prepared = false ; break } }
if ( ! prepared ) {
if ( commit . previousPlayer ? . playerId ) await writePlayerRecord ( commit . previousPlayer ) ;
await fsp . unlink ( WORLD _COMMIT _FILE ) . catch ( error => { if ( error . code !== 'ENOENT' ) throw error } ) ; return false ;
}
if ( commit . nextPlayer ? . playerId ) await writePlayerRecord ( commit . nextPlayer ) ;
await atomicWriteJson ( WORLD _FILE , commit . world ) ;
} else if ( current . revision === commit . revision && commit . nextPlayer ? . playerId ) await writePlayerRecord ( commit . nextPlayer ) ;
await fsp . unlink ( WORLD _COMMIT _FILE ) . catch ( error => { if ( error . code !== 'ENOENT' ) throw error } ) ; return true ;
}
async function commitWorldMutation ( world , { nextPlayer = null , previousPlayer = null , changedBoardIds = [ ] } = { } ) {
const commit = { revision : world . revision , world , nextPlayer , previousPlayer , changedBoardIds : [ ... changedBoardIds ] , preparedAt : serverTime ( ) } ;
await atomicWriteJson ( WORLD _COMMIT _FILE , commit ) ;
if ( nextPlayer ) await writePlayerRecord ( nextPlayer ) ;
await atomicWriteJson ( WORLD _FILE , world ) ;
await fsp . unlink ( WORLD _COMMIT _FILE ) . catch ( error => { if ( error . code !== 'ENOENT' ) throw error } ) ;
}
const authenticator = createAuthenticator ( { playerPattern : PLAYER _RE , tokenPattern : TOKEN _RE , readPlayer , hashToken : tokenHash , safeEqual : safeEqualHex } ) ;
function authenticate ( req ) { return authenticator . parse ( req ) }
async function authenticatedPlayer ( req ) { return authenticator . player ( req ) }
2026-07-30 15:13:53 +09:00
async function readJsonBody ( req ) { const chunks = [ ] ; let bytes = 0 ; for await ( const chunk of req ) { bytes += chunk . length ; if ( bytes > MAX _BODY _BYTES ) throw Object . assign ( new Error ( 'Request body is too large' ) , { status : 413 } ) ; chunks . push ( chunk ) } if ( ! chunks . length ) return { } ; try { const parsed = JSON . parse ( Buffer . concat ( chunks ) . toString ( 'utf8' ) ) ; if ( ! parsed || typeof parsed !== 'object' || Array . isArray ( parsed ) ) throw new Error ( ) ; return parsed } catch { throw Object . assign ( new Error ( 'Invalid JSON body' ) , { status : 400 } ) } }
function cellKey ( cell ) { return ` ${ cell [ 0 ] } , ${ cell [ 1 ] } ` }
function normalizedRouteKey ( cells ) { const forward = cells . map ( cellKey ) . join ( '|' ) , reverse = [ ... cells ] . reverse ( ) . map ( cellKey ) . join ( '|' ) ; return forward < reverse ? forward : reverse }
function validatePuzzle ( meta ) {
const puzzle = meta . puzzle ; if ( ! puzzle || typeof puzzle !== 'object' || ! Array . isArray ( puzzle . valid ) || ! Array . isArray ( puzzle . g ) || ! Array . isArray ( puzzle . n ) || ! Array . isArray ( puzzle . solution ) ) badRequest ( ` Missing puzzle for ${ meta . id } ` ) ;
const valid = new Set ( ) ; for ( const cell of puzzle . valid ) { if ( ! Array . isArray ( cell ) || cell . length !== 2 || ! Number . isSafeInteger ( cell [ 0 ] ) || ! Number . isSafeInteger ( cell [ 1 ] ) || valid . has ( cellKey ( cell ) ) ) badRequest ( ` Invalid puzzle cell for ${ meta . id } ` ) ; valid . add ( cellKey ( cell ) ) }
if ( ! valid . size || valid . size > meta . chunks . length * 25 ) badRequest ( ` Invalid puzzle area for ${ meta . id } ` ) ;
const obstacles = new Set ( ) ; for ( const cell of Array . isArray ( puzzle . obstacles ) ? puzzle . obstacles : [ ] ) { if ( ! Array . isArray ( cell ) || cell . length !== 2 || ! Number . isSafeInteger ( cell [ 0 ] ) || ! Number . isSafeInteger ( cell [ 1 ] ) || valid . has ( cellKey ( cell ) ) || obstacles . has ( cellKey ( cell ) ) ) badRequest ( ` Invalid obstacle for ${ meta . id } ` ) ; obstacles . add ( cellKey ( cell ) ) }
if ( valid . size + obstacles . size !== meta . chunks . length * 25 ) badRequest ( ` Incomplete puzzle area for ${ meta . id } ` ) ;
const gates = puzzle . g ; if ( ! gates . length || gates . length % 2 !== 0 ) badRequest ( ` Invalid gates for ${ meta . id } ` ) ;
for ( const gate of gates ) if ( ! Array . isArray ( gate ) || gate . length !== 3 || ! Number . isSafeInteger ( gate [ 0 ] ) || ! Number . isSafeInteger ( gate [ 1 ] ) || ! valid . has ( ` ${ gate [ 0 ] } , ${ gate [ 1 ] } ` ) || ! [ 'N' , 'S' , 'W' , 'E' ] . includes ( gate [ 2 ] ) ) badRequest ( ` Invalid gate for ${ meta . id } ` ) ;
for ( const clue of puzzle . n ) if ( ! Array . isArray ( clue ) || clue . length !== 3 || ! Number . isSafeInteger ( clue [ 0 ] ) || ! Number . isSafeInteger ( clue [ 1 ] ) || ! Number . isSafeInteger ( clue [ 2 ] ) || clue [ 2 ] < 0 || clue [ 2 ] > 512 || ! valid . has ( ` ${ clue [ 0 ] } , ${ clue [ 1 ] } ` ) ) badRequest ( ` Invalid clue for ${ meta . id } ` ) ;
const warpMap = new Map ( ) ; for ( const pair of puzzle . specialCells ? . warps || [ ] ) { if ( ! Array . isArray ( pair ? . a ) || ! Array . isArray ( pair ? . b ) || ! valid . has ( cellKey ( pair . a ) ) || ! valid . has ( cellKey ( pair . b ) ) ) badRequest ( ` Invalid warp for ${ meta . id } ` ) ; warpMap . set ( cellKey ( pair . a ) , cellKey ( pair . b ) ) ; warpMap . set ( cellKey ( pair . b ) , cellKey ( pair . a ) ) }
const crossingSet = new Set ( ( puzzle . specialCells ? . crossings || [ ] ) . map ( cellKey ) ) , coverage = new Map ( ) ;
const validateRoute = ( route , label ) => { if ( ! route || typeof route !== 'object' || ! Array . isArray ( route . cells ) || ! route . cells . length || ! Number . isInteger ( route . startGate ) || ! Number . isInteger ( route . endGate ) || route . startGate < 0 || route . endGate < 0 || route . startGate >= gates . length || route . endGate >= gates . length ) badRequest ( ` Invalid ${ label } for ${ meta . id } ` ) ; if ( cellKey ( route . cells [ 0 ] ) !== ` ${ gates [ route . startGate ] [ 0 ] } , ${ gates [ route . startGate ] [ 1 ] } ` || cellKey ( route . cells [ route . cells . length - 1 ] ) !== ` ${ gates [ route . endGate ] [ 0 ] } , ${ gates [ route . endGate ] [ 1 ] } ` ) badRequest ( ` Gate mismatch in ${ label } for ${ meta . id } ` ) ; const local = new Set ( ) ; for ( let index = 0 ; index < route . cells . length ; index ++ ) { const cell = route . cells [ index ] , key = Array . isArray ( cell ) ? cellKey ( cell ) : '' ; if ( ! Array . isArray ( cell ) || cell . length !== 2 || ! Number . isSafeInteger ( cell [ 0 ] ) || ! Number . isSafeInteger ( cell [ 1 ] ) || ! valid . has ( key ) || local . has ( key ) ) badRequest ( ` Invalid ${ label } cell for ${ meta . id } ` ) ; local . add ( key ) ; if ( index ) { const previous = route . cells [ index - 1 ] , distance = Math . abs ( previous [ 0 ] - cell [ 0 ] ) + Math . abs ( previous [ 1 ] - cell [ 1 ] ) ; if ( distance !== 1 && warpMap . get ( cellKey ( previous ) ) !== key ) badRequest ( ` Disconnected ${ label } for ${ meta . id } ` ) } } return route . cells } ;
for ( const route of puzzle . solution ) for ( const cell of validateRoute ( route , 'solution path' ) ) { const key = cellKey ( cell ) , count = ( coverage . get ( key ) || 0 ) + 1 ; if ( count > ( crossingSet . has ( key ) ? 2 : 1 ) ) badRequest ( ` Overlapping solution for ${ meta . id } ` ) ; coverage . set ( key , count ) }
if ( coverage . size !== valid . size ) badRequest ( ` Incomplete solution for ${ meta . id } ` ) ;
2026-08-01 16:06:14 +09:00
return { valid , gates , clues : puzzle . n . map ( clue => [ ... clue ] ) , warpMap , warps : ( puzzle . specialCells ? . warps || [ ] ) . map ( pair => ( { a : [ ... pair . a ] , b : [ ... pair . b ] } ) ) , locks : ( puzzle . specialCells ? . locks || [ ] ) . map ( lock => ( { key : [ ... lock . key ] , door : [ ... lock . door ] } ) ) , internalGateIndexes : ( puzzle . specialCells ? . internalGates || [ ] ) . flatMap ( pair => [ pair ? . a , pair ? . b ] ) . filter ( Number . isInteger ) , crossingSet , solutionKeys : puzzle . solution . map ( route => normalizedRouteKey ( route . cells ) ) . sort ( ) } ;
}
function sameCellValue ( a , b ) { return Boolean ( a && b && a [ 0 ] === b [ 0 ] && a [ 1 ] === b [ 1 ] ) }
function gateOutsidePoint ( gate , index , internalGateIndexes ) { if ( internalGateIndexes . includes ( index ) ) return null ; const delta = { N : [ - 1 , 0 ] , S : [ 1 , 0 ] , W : [ 0 , - 1 ] , E : [ 0 , 1 ] } [ gate ? . [ 2 ] ] || [ 0 , 0 ] ; return [ ( gate ? . [ 0 ] || 0 ) + delta [ 0 ] , ( gate ? . [ 1 ] || 0 ) + delta [ 1 ] ] }
function pathAxisAtSolvedCell ( path , puzzle , cell ) { const index = path . cells . findIndex ( candidate => sameCellValue ( candidate , cell ) ) ; if ( index < 0 ) return null ; const previous = index ? path . cells [ index - 1 ] : gateOutsidePoint ( puzzle . gates [ path . startGate ] , path . startGate , puzzle . internalGateIndexes ) , next = index < path . cells . length - 1 ? path . cells [ index + 1 ] : gateOutsidePoint ( puzzle . gates [ path . endGate ] , path . endGate , puzzle . internalGateIndexes ) ; if ( ! previous || ! next || puzzle . warpMap . get ( cellKey ( previous ) ) === cellKey ( cell ) || puzzle . warpMap . get ( cellKey ( cell ) ) === cellKey ( next ) ) return null ; if ( previous [ 0 ] === cell [ 0 ] && next [ 0 ] === cell [ 0 ] ) return 'H' ; if ( previous [ 1 ] === cell [ 1 ] && next [ 1 ] === cell [ 1 ] ) return 'V' ; return null }
function solvedStateMatchesPuzzle ( state , puzzle ) {
const paths = Array . isArray ( state . paths ) ? state . paths : [ ] ; if ( paths . length !== puzzle . clues . length || paths . some ( path => path . endGate == null || path . detachedStart === true ) ) return false ;
const usedGates = new Set ( ) , coverage = new Map ( ) ;
for ( const path of paths ) {
if ( usedGates . has ( path . startGate ) || usedGates . has ( path . endGate ) ) return false ; usedGates . add ( path . startGate ) ; usedGates . add ( path . endGate ) ;
if ( ! sameCellValue ( path . cells [ 0 ] , puzzle . gates [ path . startGate ] ) || ! sameCellValue ( path . cells [ path . cells . length - 1 ] , puzzle . gates [ path . endGate ] ) ) return false ;
for ( const cell of path . cells ) { const key = cellKey ( cell ) , count = ( coverage . get ( key ) || 0 ) + 1 ; if ( count > ( puzzle . crossingSet . has ( key ) ? 2 : 1 ) ) return false ; coverage . set ( key , count ) }
for ( const pair of puzzle . warps ) { const ai = path . cells . findIndex ( cell => sameCellValue ( cell , pair . a ) ) , bi = path . cells . findIndex ( cell => sameCellValue ( cell , pair . b ) ) ; if ( ( ai >= 0 ) !== ( bi >= 0 ) || ai >= 0 && Math . abs ( ai - bi ) !== 1 ) return false }
for ( const lock of puzzle . locks ) { const keyIndex = path . cells . findIndex ( cell => sameCellValue ( cell , lock . key ) ) , doorIndex = path . cells . findIndex ( cell => sameCellValue ( cell , lock . door ) ) ; if ( doorIndex >= 0 && ( keyIndex < 0 || keyIndex > doorIndex ) ) return false }
const clues = puzzle . clues . filter ( clue => path . cells . some ( cell => cell [ 0 ] === clue [ 0 ] && cell [ 1 ] === clue [ 1 ] ) ) ; if ( clues . length !== 1 ) return false ;
const analysis = AppLogic . analyzePathTurns ( path , puzzle . gates , puzzle . warps , true , puzzle . internalGateIndexes ) , clue = clues [ 0 ] ; if ( analysis . count !== clue [ 2 ] || ! analysis . cells . some ( cell => cell [ 0 ] === clue [ 0 ] && cell [ 1 ] === clue [ 1 ] ) ) return false ;
}
if ( usedGates . size !== puzzle . gates . length || coverage . size !== puzzle . valid . size ) return false ;
for ( const key of puzzle . crossingSet ) { if ( coverage . get ( key ) !== 2 ) return false ; const cell = key . split ( ',' ) . map ( Number ) , axes = paths . filter ( path => path . cells . some ( candidate => sameCellValue ( candidate , cell ) ) ) . map ( path => pathAxisAtSolvedCell ( path , puzzle , cell ) ) . filter ( Boolean ) ; if ( axes . length !== 2 || axes [ 0 ] === axes [ 1 ] ) return false }
return true ;
2026-07-30 15:13:53 +09:00
}
function validateMeta ( meta ) {
if ( ! meta || typeof meta !== 'object' || ! BOARD _RE . test ( meta . id ) ) badRequest ( 'Invalid board metadata' ) ; const chunks = Array . isArray ( meta . chunks ) ? meta . chunks : [ ] ;
if ( ! chunks . length || chunks . length > 50 ) badRequest ( ` Invalid chunks for ${ meta . id } ` ) ; const seen = new Set ( ) ; for ( const cell of chunks ) { if ( ! Array . isArray ( cell ) || cell . length !== 2 || ! Number . isSafeInteger ( cell [ 0 ] ) || ! Number . isSafeInteger ( cell [ 1 ] ) || Math . abs ( cell [ 0 ] ) > 1000000 || Math . abs ( cell [ 1 ] ) > 1000000 || seen . has ( cellKey ( cell ) ) ) badRequest ( ` Invalid chunk coordinate for ${ meta . id } ` ) ; seen . add ( cellKey ( cell ) ) }
if ( ! Number . isSafeInteger ( meta . x ) || ! Number . isSafeInteger ( meta . y ) || Math . abs ( meta . x ) > 100000000 || Math . abs ( meta . y ) > 100000000 ) badRequest ( ` Invalid board coordinate for ${ meta . id } ` ) ;
if ( ! Number . isSafeInteger ( meta . seed ) || meta . seed < 0 || meta . seed > 0xffffffff ) badRequest ( ` Invalid seed for ${ meta . id } ` ) ;
if ( ! Number . isInteger ( meta . level ) || meta . level < 1 || meta . level > 10 || ! Number . isInteger ( meta . targetLevel ) || meta . targetLevel < 1 || meta . targetLevel > 10 ) badRequest ( ` Invalid level for ${ meta . id } ` ) ;
if ( meta . entrySide != null && ! [ 'N' , 'S' , 'W' , 'E' ] . includes ( meta . entrySide ) ) badRequest ( ` Invalid entry side for ${ meta . id } ` ) ;
if ( meta . sealedSides != null && ( ! Array . isArray ( meta . sealedSides ) || meta . sealedSides . some ( side => ! [ 'N' , 'S' , 'W' , 'E' ] . includes ( side ) ) ) ) badRequest ( ` Invalid sealed sides for ${ meta . id } ` ) ;
2026-07-31 12:54:46 +09:00
const clean = JSON . parse ( JSON . stringify ( meta ) ) ; validatePuzzle ( clean ) ;
const derivedLevel = PuzzleCore . solverDifficulty ( clean . puzzle , clean . targetLevel ) ; if ( clean . level !== derivedLevel ) badRequest ( ` Unverified difficulty for ${ clean . id } ` ) ;
clean . level = derivedLevel ; clean . puzzle . level = derivedLevel ; clean . puzzle . difficulty = derivedLevel ; clean . puzzle . complexity = PuzzleCore . solutionComplexity ( clean . puzzle ) ; clean . puzzle . interactionBurden = AppLogic . interactionBurden ( clean . puzzle ) ;
delete clean . puzzle . solutionQuality ; delete clean . puzzle . uniqueness ;
if ( clean . targetLevel >= 6 ) {
const verification = PuzzleCore . verifyPuzzleUniqueness ( clean . puzzle , { maxMs : 2000 , nodeCap : 150000 , analyzeQuality : false } ) ;
if ( verification . status !== 'unique' || verification . signature !== PuzzleCore . puzzleSignature ( clean . puzzle ) ) badRequest ( ` Puzzle is not server-verified: ${ clean . id } ` ) ;
clean . puzzle . uniqueness = { status : 'unique' , signature : verification . signature , ruleVersion : verification . ruleVersion , nodes : verification . nodes } ;
}
return clean ;
2026-07-30 15:13:53 +09:00
}
function validateStateRow ( row , meta ) {
if ( ! row || typeof row !== 'object' || ! BOARD _RE . test ( row . id ) || ! row . value || typeof row . value !== 'object' ) badRequest ( 'Invalid board state' ) ; if ( ! meta ) badRequest ( ` State without metadata: ${ row . id } ` ) ;
const state = JSON . parse ( JSON . stringify ( row . value ) ) , puzzle = validatePuzzle ( meta ) , paths = Array . isArray ( state . paths ) ? state . paths : [ ] ; if ( paths . length > MAX _PATHS _PER _BOARD ) badRequest ( ` Too many paths for ${ row . id } ` ) ;
2026-08-01 16:06:14 +09:00
for ( const item of paths ) { if ( ! item || typeof item !== 'object' || ! Array . isArray ( item . cells ) || ! item . cells . length || item . cells . length > MAX _CELLS _PER _PATH ) badRequest ( ` Invalid path for ${ row . id } ` ) ; if ( ! Number . isInteger ( item . startGate ) || item . startGate < 0 || item . startGate >= puzzle . gates . length || item . endGate != null && ( ! Number . isInteger ( item . endGate ) || item . endGate < 0 || item . endGate >= puzzle . gates . length ) ) badRequest ( ` Invalid path gates for ${ row . id } ` ) ; if ( item . detachedStart !== true && ! sameCellValue ( item . cells [ 0 ] , puzzle . gates [ item . startGate ] ) || item . endGate != null && ! sameCellValue ( item . cells [ item . cells . length - 1 ] , puzzle . gates [ item . endGate ] ) ) badRequest ( ` Path endpoint mismatch for ${ row . id } ` ) ; const seen = new Set ( ) ; for ( let index = 0 ; index < item . cells . length ; index ++ ) { const cell = item . cells [ index ] ; if ( ! Array . isArray ( cell ) || cell . length !== 2 || ! Number . isSafeInteger ( cell [ 0 ] ) || ! Number . isSafeInteger ( cell [ 1 ] ) ) badRequest ( ` Invalid path cell for ${ row . id } ` ) ; const key = cellKey ( cell ) ; if ( ! puzzle . valid . has ( key ) || seen . has ( key ) ) badRequest ( ` Invalid path cell for ${ row . id } ` ) ; seen . add ( key ) ; if ( index ) { const previous = item . cells [ index - 1 ] , distance = Math . abs ( previous [ 0 ] - cell [ 0 ] ) + Math . abs ( previous [ 1 ] - cell [ 1 ] ) ; if ( distance !== 1 && puzzle . warpMap . get ( cellKey ( previous ) ) !== key ) badRequest ( ` Disconnected path for ${ row . id } ` ) } } }
if ( state . solved === true && ! solvedStateMatchesPuzzle ( state , puzzle ) ) badRequest ( ` Solved state does not satisfy puzzle rules for ${ row . id } ` )
2026-07-30 15:13:53 +09:00
return state ;
}
function worldMetaFingerprint ( meta ) { const clean = JSON . parse ( JSON . stringify ( meta ) ) ; delete clean . rev ; delete clean . revAuthor ; delete clean . sealedSides ; return JSON . stringify ( clean ) }
function sanitizeWorldGlobal ( value , record ) {
const source = value && typeof value === 'object' && ! Array . isArray ( value ) ? value : { } , prior = record ? . global || { } , clean = { } ;
for ( const key of [ 'schema' , 'gameplayVersion' , 'worldGeneration' , 'worldEpoch' , 'appVersion' , 'generatorVersion' , 'nextId' , 'solved' , 'lastSolveAt' , 'specialMechanicsSeen' , 'quarantine' ] ) if ( Object . prototype . hasOwnProperty . call ( source , key ) ) clean [ key ] = JSON . parse ( JSON . stringify ( source [ key ] ) ) ;
2026-07-31 12:54:46 +09:00
clean . specialMechanicsSeen = Array . isArray ( clean . specialMechanicsSeen ) ? SharedContracts . normalizeSpecialMechanics ( clean . specialMechanicsSeen ) : SharedContracts . normalizeSpecialMechanics ( prior . specialMechanicsSeen ) ;
2026-07-30 15:13:53 +09:00
clean . updatedAt = serverTime ( ) ; return clean ;
}
2026-07-31 12:54:46 +09:00
const STORE _CHANCE = 1 / 10 , STORE _PRICE _VERSION = 1 , EXPANSION _GRANT _TTL _MS = 10 * 60 * 1000 , MAX _NEW _BOARDS _PER _GRANT = 8 ;
2026-07-30 15:13:53 +09:00
function authoritativeStoreItemIds ( seed ) {
const all = [ ... STORE _CATALOG . values ( ) ] , cursor = all . filter ( item => item . cursorStyle ) , other = all . filter ( item => ! item . cursorStyle ) ,
cursorPool = PuzzleCore . shuffle ( [ ... cursor ] , PuzzleCore . rngFrom ( PuzzleCore . hash32 ( ( seed >>> 0 ) ^ 0x5f356495 ) ) ) ,
otherPool = PuzzleCore . shuffle ( [ ... other ] , PuzzleCore . rngFrom ( PuzzleCore . hash32 ( ( seed >>> 0 ) ^ 0x2c9277b5 ) ) ) ,
2026-08-01 16:06:14 +09:00
fixedTools = otherPool . filter ( item => item . scoreLens ) . slice ( 0 , 1 ) ,
cosmeticPool = otherPool . filter ( item => ! item . scoreLens ) ,
selectedOthers = PuzzleCore . shuffle ( [ ... fixedTools , ... cosmeticPool . slice ( 0 , 6 - fixedTools . length ) ] , PuzzleCore . rngFrom ( PuzzleCore . hash32 ( ( seed >>> 0 ) ^ 0x6d2b79f5 ) ) ) ;
return [ ... cursorPool . slice ( 0 , 6 ) , ... selectedOthers ] . map ( item => item . id ) ;
2026-07-30 15:13:53 +09:00
}
function authoritativeReward ( meta , state , worldSeed = 0 ) {
const level = Math . max ( 1 , Math . min ( 10 , Number ( meta . level ) || 1 ) ) , sections = Math . max ( 1 , meta . chunks ? . length || 1 ) ,
totalCells = ( state . paths || [ ] ) . reduce ( ( sum , path ) => sum + ( path . cells ? . length || 0 ) , 0 ) ,
base = Math . max ( 100 , Math . round ( ( level * level * 100 + totalCells * level * 20 ) / 80 ) * 10 ) ,
reward = AppLogic . deterministicBoardReward ( base , { worldSeed , meta , state , timeAttackModifier : 1 , scoreLensCount : 0 } ) ;
return { award : Math . max ( 13 , Math . min ( Number . MAX _SAFE _INTEGER , reward . award ) ) , identity : reward . identity , coefficient : reward . coefficient } ;
}
function authoritativeStore ( meta , state , playerName , worldSeed = 0 ) {
2026-07-31 12:54:46 +09:00
const roll = ( PuzzleCore . hash32 ( ( meta . seed >>> 0 ) ^ 0x7f4a7c15 ) >>> 0 ) / 4294967296 , obstacles = meta ? . puzzle ? . obstacles || [ ] ; if ( roll >= STORE _CHANCE || ! obstacles . length ) return null ;
const cell = [ ... obstacles [ ( PuzzleCore . hash32 ( ( meta . seed >>> 0 ) ^ 0x2fd51a37 ) >>> 0 ) % obstacles . length ] ] , store = { owner : playerName , pathIndex : - 1 , cellIndex : - 1 , cell , openedAt : serverTime ( ) , priceVersion : STORE _PRICE _VERSION , priceCoefficient : null , bonus : 0 , bonusVersion : 6 , itemIds : authoritativeStoreItemIds ( meta . seed ) , purchases : [ ] } ,
2026-07-30 15:13:53 +09:00
[ x , y ] = storeItemPriceLocation ( meta , state , store ) ; store . priceCoefficient = AppLogic . deterministicStorePrice ( 1 , worldSeed , x , y , STORE _PRICE _VERSION ) . coefficient ; return store ;
}
function boardTouchesWorld ( meta , rows ) { const occupied = new Set ( ) ; for ( const row of rows ) for ( const [ dx , dy ] of row ? . meta ? . chunks || [ ] ) occupied . add ( ` ${ row . meta . x + dx } , ${ row . meta . y + dy } ` ) ; for ( const [ dx , dy ] of meta . chunks || [ ] ) { const x = meta . x + dx , y = meta . y + dy ; for ( const [ ox , oy ] of [ [ 1 , 0 ] , [ - 1 , 0 ] , [ 0 , 1 ] , [ 0 , - 1 ] ] ) if ( occupied . has ( ` ${ x + ox } , ${ y + oy } ` ) ) return true } return false }
function publicStateForWorld ( incoming , current , player , worldRevision , rowRevision , meta , worldSeed = 0 ) {
const now = serverTime ( ) , wasSolved = current ? . solved === true , wantsSolved = incoming . solved === true , base = { paths : [ ] , specialProgress : { crossings : [ ] } , solved : false , expanded : false , expansionRetryRound : 0 , solvedBy : null , solvedById : null , solvedAt : null , scoreAwarded : 0 , scoreVersion : incoming . scoreVersion || 0 , rewardIdentity : null , rewardCoefficient : null , store : null , rev : rowRevision , revAuthor : 'shared-world' } ;
if ( wasSolved ) {
2026-08-01 16:06:14 +09:00
const stable = JSON . parse ( JSON . stringify ( current ) ) ; stable . expanded = stable . expanded === true || incoming . expanded === true ; stable . expansionRetryRound = stable . expanded ? 0 : Math . max ( stable . expansionRetryRound || 0 , incoming . expansionRetryRound || 0 ) ; stable . rev = rowRevision ; stable . revAuthor = 'shared-world' ; if ( stable . store ) { stable . store . purchases = [ ] ; if ( ! Array . isArray ( stable . store . itemIds ) || stable . store . itemIds . length !== 12 ) stable . store . itemIds = authoritativeStoreItemIds ( meta . seed ) } return { state : stable , clearEvent : null } ;
}
if ( ! wantsSolved ) {
const state = JSON . parse ( JSON . stringify ( incoming ) ) ; state . solved = false ; state . expanded = false ; state . expansionRetryRound = 0 ; state . solvedBy = null ; state . solvedById = null ; state . solvedAt = null ; state . scoreAwarded = 0 ; state . rewardIdentity = null ; state . rewardCoefficient = null ; state . store = null ; state . rev = rowRevision ; state . revAuthor = 'shared-world' ; return { state , clearEvent : null } ;
2026-07-30 15:13:53 +09:00
}
const state = JSON . parse ( JSON . stringify ( incoming ) ) ; state . solved = true ; state . solvedBy = player . name ; state . solvedById = player . playerId ; state . solvedAt = now ; state . rev = rowRevision ; state . revAuthor = 'shared-world' ; state . expanded = false ; state . expansionRetryRound = 0 ; const reward = authoritativeReward ( meta , state , worldSeed ) ; state . scoreAwarded = reward . award ; state . scoreVersion = 6 ; state . rewardIdentity = reward . identity ; state . rewardCoefficient = reward . coefficient ; state . store = authoritativeStore ( meta , state , player . name , worldSeed ) ;
return { state , clearEvent : { id : meta . id , playerId : player . playerId , playerName : player . name , x : meta . x , y : meta . y , level : meta . level , scoreAwarded : state . scoreAwarded , solvedAt : now , revision : worldRevision } } ;
}
function cloudDeltaSince ( record , since ) { if ( ! ( since > 0 ) || since >= record . revision ) return null ; const changes = record . changes . filter ( change => Number . isSafeInteger ( change ? . revision ) && change . revision > since ) . sort ( ( a , b ) => a . revision - b . revision ) ; if ( ! changes . length || changes [ 0 ] . revision !== since + 1 || changes [ changes . length - 1 ] . revision !== record . revision ) return null ; for ( let index = 1 ; index < changes . length ; index ++ ) if ( changes [ index ] . revision !== changes [ index - 1 ] . revision + 1 ) return null ; const metaIds = new Set ( ) , stateIds = new Set ( ) , deleted = new Set ( ) ; for ( const change of changes ) { for ( const id of change . deleted || [ ] ) { metaIds . delete ( id ) ; stateIds . delete ( id ) ; deleted . add ( id ) } for ( const id of change . metaIds || [ ] ) { deleted . delete ( id ) ; metaIds . add ( id ) } for ( const id of change . stateIds || [ ] ) { deleted . delete ( id ) ; stateIds . add ( id ) } } return { metaIds , stateIds , deleted } }
async function publicWorldPage ( record , ids , metaIds = null , stateIds = null ) { const rows = await Promise . all ( ids . map ( id => readWorldBoard ( record , id ) ) ) , metas = { } , states = { } ; for ( let index = 0 ; index < ids . length ; index ++ ) { const id = ids [ index ] , row = rows [ index ] ; if ( ! row ) continue ; if ( ( ! metaIds || metaIds . has ( id ) ) && row . meta ) metas [ id ] = row . meta ; if ( ( ! stateIds || stateIds . has ( id ) ) && row . state ) states [ id ] = row . state } return { global : { ... ( record . global || { } ) , cloudProfile : null , cloudRevision : record . revision } , metas , states } }
function withWorldQueue ( task ) { const run = worldQueue . then ( task , task ) ; worldQueue = run . then ( ( ) => undefined , ( ) => undefined ) ; return run }
async function authenticateRealtime ( { playerId , token } ) {
if ( ! PLAYER _RE . test ( String ( playerId || '' ) ) || ! TOKEN _RE . test ( String ( token || '' ) ) ) throw new Error ( 'Unauthorized' ) ;
const normalizedId = String ( playerId ) . toLowerCase ( ) , record = await readPlayer ( normalizedId ) ;
if ( ! safeEqualHex ( record . tokenHash , tokenHash ( String ( token ) . toLowerCase ( ) ) ) ) throw new Error ( 'Unauthorized' ) ;
return { playerId : normalizedId , name : record . name } ;
}
async function realtimeBoardInfo ( boardId ) {
if ( ! BOARD _RE . test ( String ( boardId || '' ) ) ) return null ; const record = await readWorld ( ) , row = await readWorldBoard ( record , boardId ) ; if ( ! row ? . meta ) return null ;
let minX = Infinity , minY = Infinity , maxX = - Infinity , maxY = - Infinity ; for ( const [ dx , dy ] of row . meta . chunks || [ ] ) { const x = row . meta . x + dx , y = row . meta . y + dy ; minX = Math . min ( minX , x ) ; minY = Math . min ( minY , y ) ; maxX = Math . max ( maxX , x + 1 ) ; maxY = Math . max ( maxY , y + 1 ) }
if ( ! Number . isFinite ( minX ) ) return null ; return { solved : row . state ? . solved === true , bounds : { minX , minY , maxX , maxY , x : ( minX + maxX ) / 2 , y : ( minY + maxY ) / 2 } } ;
}
function assertNoOverlaps ( rows ) { const occupied = new Map ( ) ; for ( const row of rows ) { const meta = row ? . meta ; if ( ! meta ) continue ; for ( const [ dx , dy ] of meta . chunks ) { const key = ` ${ meta . x + dx } , ${ meta . y + dy } ` , prior = occupied . get ( key ) ; if ( prior && prior !== meta . id ) badRequest ( ` Board overlap: ${ prior } / ${ meta . id } ` ) ; occupied . set ( key , meta . id ) } } }
function storeItemPriceLocation ( meta , state , store ) {
2026-07-31 12:54:46 +09:00
const direct = Array . isArray ( store ? . cell ) ? store . cell : null , path = store && state ? . paths ? . [ store . pathIndex ] , cell = direct || path ? . cells ? . [ store . cellIndex ] ;
2026-07-30 15:13:53 +09:00
if ( Array . isArray ( cell ) ) return [ meta . x + ( cell [ 1 ] + . 5 ) / 5 , meta . y + ( cell [ 0 ] + . 5 ) / 5 ] ;
const count = Math . max ( 1 , meta . chunks ? . length || 1 ) , x = meta . x + . 5 + ( meta . chunks || [ ] ) . reduce ( ( sum , chunk ) => sum + chunk [ 0 ] , 0 ) / count , y = meta . y + . 5 + ( meta . chunks || [ ] ) . reduce ( ( sum , chunk ) => sum + chunk [ 1 ] , 0 ) / count ; return [ x , y ] ;
}
async function storePurchaseContext ( world , boardId , itemId ) {
if ( ! BOARD _RE . test ( String ( boardId || '' ) ) ) badRequest ( 'Invalid store board' ) ; const item = STORE _CATALOG . get ( itemId ) ; if ( ! item ) badRequest ( 'Invalid store item' ) ;
const row = await readWorldBoard ( world , boardId ) ; if ( ! row ? . meta || row . state ? . solved !== true || ! row . state ? . store ) badRequest ( 'Store is not available' ) ;
2026-08-01 16:06:14 +09:00
const store = row . state . store , itemIds = Array . isArray ( store . itemIds ) && store . itemIds . length === 12 ? store . itemIds : authoritativeStoreItemIds ( row . meta . seed ) ; if ( ! itemIds . includes ( item . id ) ) badRequest ( 'Item is not sold by this store' ) ;
2026-07-30 15:13:53 +09:00
const starter = await readWorldBoard ( world , 'B0' ) , worldSeed = starter ? . meta ? . seed || 0 , [ x , y ] = storeItemPriceLocation ( row . meta , row . state , store ) , computed = AppLogic . deterministicStorePrice ( 1 , worldSeed , x , y , store . priceVersion || 1 ) , coefficient = computed . coefficient , adjusted = Math . round ( Math . max ( 0 , Number ( item . cost ) || 0 ) * coefficient ) , price = item . id . startsWith ( 'cursor-face-' ) ? Math . max ( 500 , Math . min ( 50000 , adjusted ) ) : Math . max ( item . cursorStyle ? 500 : 3000 , adjusted ) ;
return { row , item , price } ;
}
async function sharedWorldEarnedScore ( world ) {
const cached = Number ( world ? . global ? . earnedScore ) ; if ( Number . isSafeInteger ( cached ) && cached >= 0 ) return cached ;
let total = 0 ; for ( const id of Object . keys ( world ? . boardVersions || { } ) ) { const row = await readWorldBoard ( world , id ) , award = Number ( row ? . state ? . scoreAwarded ) || 0 ; if ( row ? . state ? . solved === true && Number . isSafeInteger ( award ) && award > 0 ) total += award }
return Math . max ( 0 , total ) ;
}
async function assertPlayerCanAfford ( world , record , price ) {
const earned = playerEarnedScore ( record ) , spent = playerSpentScore ( record ) , available = Math . max ( 0 , earned - spent ) ; if ( available < price ) throw Object . assign ( new Error ( ` Insufficient score: ${ available } / ${ price } ` ) , { status : 402 } ) ; return { earned , spent , available } ;
}
function purchaseForStoreItem ( record , boardId , itemId ) { return normalizePlayerPurchases ( record . purchases ) . find ( purchase => purchase . boardId === boardId && purchase . itemId === itemId ) || null }
function createPlayerPurchase ( record , boardId , item , price , timestamp = serverTime ( ) ) {
const purchase = { purchaseId : crypto . randomBytes ( 16 ) . toString ( 'hex' ) , boardId , itemId : item . id , buyer : record . name , boughtAt : timestamp , paidCost : price } ;
record . purchases = normalizePlayerPurchases ( [ ... ( record . purchases || [ ] ) , purchase ] ) ;
record . economyRevision = ( record . economyRevision || 0 ) + 1 ;
record . updatedAt = timestamp ;
return record . purchases . find ( row => row . purchaseId === purchase . purchaseId ) || purchase ;
}
async function writePlayerRecord ( record ) { record . purchases = normalizePlayerPurchases ( record . purchases ) ; record . generationBonuses = normalizeGenerationBonuses ( record . generationBonuses ) ; await atomicWriteJson ( playerPath ( record . playerId ) , record ) }
2026-07-31 12:54:46 +09:00
const playerService = createPlayerService ( {
randomHex : bytes => crypto . randomBytes ( bytes ) . toString ( 'hex' ) ,
now : serverTime ,
cleanName : cleanPlayerName ,
defaultName : defaultPlayerName ,
hashToken : tokenHash ,
writePlayer : writePlayerRecord ,
readPlayer ,
withPlayerQueue ,
withWorldQueue ,
readWorld ,
readWorldBoard ,
normalizeBonuses : normalizeGenerationBonuses ,
earnedScore : playerEarnedScore ,
publicState : publicPlayerState ,
bonusAmount : GENERATION _FAILURE _BONUS ,
bonusDelayMs : GENERATION _FAILURE _MIN _DELAY _MS ,
notifyProfile : ( playerId , name ) => realtimeHub ? . notifyProfileChange ( playerId , name ) ,
purchaseContext : storePurchaseContext ,
findPurchase : purchaseForStoreItem ,
assertAffordable : assertPlayerCanAfford ,
createPurchase : createPlayerPurchase ,
2026-08-01 16:06:14 +09:00
starterLineColor : starterLineColorForPlayer ,
2026-07-31 12:54:46 +09:00
boardPattern : BOARD _RE
} ) ;
2026-07-30 15:13:53 +09:00
2026-07-31 12:54:46 +09:00
async function handleCloudStatus ( _req , res ) {
2026-08-01 16:06:14 +09:00
const world = await readWorld ( ) ;
return json ( res , 200 , { available : true , sharedWorld : true , singleWorld : true , worldId : 'link-field-main' , appVersion : BuildMeta . APP _VERSION , worldGeneration : BuildMeta . WORLD _GENERATION , realtime : true , reactions : true , playerEconomy : true , sharedItems : false , revision : world . revision || 0 , boardCount : Object . keys ( world . boardVersions || { } ) . length , claimTtlMs : realtimeHub ? . claimTtlMs || 300000 , serverTime : serverTime ( ) } ) ;
2026-07-31 12:54:46 +09:00
}
2026-08-01 16:06:14 +09:00
2026-07-31 12:54:46 +09:00
async function handleCloudSession ( req , res ) {
const body = await readJsonBody ( req ) , result = await playerService . createSession ( body . name ) ; return json ( res , result . status , result . body ) ;
}
async function handleCloudProfile ( req , res ) {
const auth = await authenticatedPlayer ( req ) , body = await readJsonBody ( req ) , result = await playerService . updateProfile ( auth . playerId , body . name ) ; return json ( res , result . status , result . body ) ;
}
async function handlePlayerState ( req , res ) {
const player = await authenticatedPlayer ( req ) , result = playerService . getState ( player . record ) ; return json ( res , result . status , result . body ) ;
}
async function handleGenerationBonus ( req , res ) {
const auth = await authenticatedPlayer ( req ) , body = await readJsonBody ( req ) , result = await playerService . awardGenerationBonus ( auth . playerId , String ( body . boardId || '' ) ) ; return json ( res , result . status , result . body ) ;
}
async function handlePurchase ( req , res ) {
const auth = await authenticatedPlayer ( req ) , body = await readJsonBody ( req ) , result = await playerService . purchase ( auth . playerId , String ( body . boardId || '' ) , String ( body . itemId || '' ) ) ; return json ( res , result . status , result . body ) ;
}
async function pullCloudWorldService ( player , parameters ) {
const record = await readWorld ( ) , since = Math . max ( 0 , Math . floor ( finiteNumber ( parameters . get ( 'since' ) , 0 ) ) ) , eventsSince = Math . max ( 0 , Math . floor ( finiteNumber ( parameters . get ( 'eventsSince' ) , 0 ) ) ) , changed = since !== record . revision ,
clearEvents = record . clearEvents . filter ( event => ( event . revision || 0 ) > eventsSince ) ;
2026-08-01 16:06:14 +09:00
if ( ! changed ) return { status : 200 , body : { changed : false , revision : record . revision , latestEventRevision : record . clearEvents . at ( - 1 ) ? . revision || 0 , clearEvents , player : publicCloudPlayer ( player . record ) , serverTime : serverTime ( ) } } ;
2026-07-31 12:54:46 +09:00
const cursor = Math . max ( 0 , Math . floor ( finiteNumber ( parameters . get ( 'cursor' ) , 0 ) ) ) , at = Math . max ( 0 , Math . floor ( finiteNumber ( parameters . get ( 'at' ) , record . revision ) ) ) ; if ( cursor && at !== record . revision ) return { status : 409 , body : { error : 'World changed during paged pull' , revision : record . revision , serverTime : serverTime ( ) } } ;
const delta = cloudDeltaSince ( record , since ) , fullSnapshot = ! delta , metaIds = delta ? . metaIds || new Set ( Object . keys ( record . boardVersions ) ) , stateIds = delta ? . stateIds || new Set ( Object . keys ( record . boardVersions ) ) , ids = [ ... new Set ( [ ... metaIds , ... stateIds ] ) ] . filter ( id => Number . isSafeInteger ( record . boardVersions [ id ] ) ) . sort ( ( a , b ) => Number ( a . slice ( 1 ) ) - Number ( b . slice ( 1 ) ) ) , pageIds = ids . slice ( cursor , cursor + CLOUD _PAGE _LIMIT ) , nextCursor = cursor + pageIds . length < ids . length ? cursor + pageIds . length : null , page = await publicWorldPage ( record , pageIds , metaIds , stateIds ) ;
2026-08-01 16:06:14 +09:00
return { status : 200 , body : { changed : true , revision : record . revision , latestEventRevision : record . clearEvents . at ( - 1 ) ? . revision || 0 , clearEvents : cursor ? [ ] : clearEvents , player : publicCloudPlayer ( player . record ) , serverTime : serverTime ( ) , fullSnapshot , page : { ... page , deleted : delta ? [ ... delta . deleted ] : [ ] } , nextCursor } } ;
2026-07-31 12:54:46 +09:00
}
async function pushCloudWorldService ( player , body ) {
return withPlayerQueue ( player . playerId , ( ) => withWorldQueue ( async ( ) => {
const playerRecord = await readPlayer ( player . playerId ) , previousPlayerRecord = JSON . parse ( JSON . stringify ( playerRecord ) ) , record = await ensureWorldIndexes ( await readWorld ( ) ) , baseRevision = Math . max ( 0 , Math . floor ( finiteNumber ( body . baseRevision , 0 ) ) ) ; if ( baseRevision !== record . revision ) return { status : 409 , body : { error : 'Revision conflict' , revision : record . revision , serverTime : serverTime ( ) } } ;
2026-07-30 15:13:53 +09:00
const metas = Array . isArray ( body . metas ) ? body . metas : [ ] , states = Array . isArray ( body . states ) ? body . states : [ ] , deleted = [ ] ; if ( metas . length + states . length > MAX _BOARDS _PER _PUSH * 2 ) throw Object . assign ( new Error ( 'Too many board changes' ) , { status : 413 } ) ;
2026-07-31 12:54:46 +09:00
const nextRevision = record . revision + 1 , rowRevision = Math . max ( ( Number ( record . rowRevision ) || 0 ) + 1 , serverTime ( ) * 1000 ) , changedBoards = new Map ( ) , loadChangedBoard = async id => { if ( changedBoards . has ( id ) ) return changedBoards . get ( id ) ; const current = await readWorldBoard ( record , id ) || { meta : null , state : null } ; changedBoards . set ( id , current ) ; return current } ;
2026-07-30 15:13:53 +09:00
for ( const rawMeta of metas ) { const clean = validateMeta ( rawMeta ) , row = await loadChangedBoard ( clean . id ) ; if ( row . meta && worldMetaFingerprint ( row . meta ) !== worldMetaFingerprint ( clean ) ) badRequest ( ` Existing board is immutable: ${ clean . id } ` ) ; clean . rev = rowRevision ; clean . revAuthor = 'shared-world' ; row . meta = clean }
2026-07-31 12:54:46 +09:00
const existingIds = new Set ( Object . keys ( record . boardVersions ) ) , newMetaIds = [ ... changedBoards . keys ( ) ] . filter ( id => ! existingIds . has ( id ) ) , occupancy = { ... record . occupancy } ;
if ( record . revision > 0 && newMetaIds . length ) { const grant = record . expansionGrants ? . [ player . playerId ] ; if ( ! grant || grant . expiresAt < serverTime ( ) ) throw Object . assign ( new Error ( 'Expansion grant is required' ) , { status : 403 } ) ; if ( newMetaIds . length > Math . min ( MAX _NEW _BOARDS _PER _GRANT , grant . maxBoards || 0 ) ) badRequest ( 'Too many generated boards' ) ; const expectedStart = Math . max ( 1 , Number ( record . global ? . nextId ) || 1 ) , numbers = newMetaIds . map ( id => Number ( id . slice ( 1 ) ) ) . sort ( ( a , b ) => a - b ) ; for ( let i = 0 ; i < numbers . length ; i ++ ) if ( numbers [ i ] !== expectedStart + i ) badRequest ( 'Generated board ids are not contiguous' ) ; for ( const id of newMetaIds . sort ( ( a , b ) => Number ( a . slice ( 1 ) ) - Number ( b . slice ( 1 ) ) ) ) addMetaToWorldOccupancy ( changedBoards . get ( id ) . meta , occupancy , { requireTouch : true } ) ; grant . maxBoards -= newMetaIds . length ; if ( grant . maxBoards <= 0 ) delete record . expansionGrants [ player . playerId ] }
else for ( const id of newMetaIds . sort ( ( a , b ) => Number ( a . slice ( 1 ) ) - Number ( b . slice ( 1 ) ) ) ) addMetaToWorldOccupancy ( changedBoards . get ( id ) . meta , occupancy ) ;
const clearEvents = [ ] ; let solved = Math . max ( 0 , Number ( record . global ? . solved ) || 0 ) , earnedScore = Math . max ( 0 , Number ( record . global ? . earnedScore ) || 0 ) , starterRow = null ;
2026-08-01 16:06:14 +09:00
for ( const rawRow of states ) { if ( ! BOARD _RE . test ( rawRow ? . id ) ) badRequest ( 'Invalid board state' ) ; const row = await loadChangedBoard ( rawRow . id ) ; if ( ! row . meta ) badRequest ( ` Board metadata is missing: ${ rawRow . id } ` ) ; const incoming = validateStateRow ( rawRow , row . meta ) , firstSolve = row . state ? . solved !== true && incoming . solved === true , unfinishedBoard = row . state ? . solved !== true , existingBoard = existingIds . has ( rawRow . id ) ; if ( record . revision > 0 && existingBoard && unfinishedBoard && realtimeHub && ! realtimeHub . hasClaim ( player . playerId , rawRow . id ) ) throw Object . assign ( new Error ( 'Board claim is required' ) , { status : 423 } ) ; starterRow || = rawRow . id === 'B0' ? row : await readWorldBoard ( record , 'B0' ) ; const worldSeed = starterRow ? . meta ? . seed || 0 ; const published = publicStateForWorld ( incoming , row . state , playerRecord , nextRevision , rowRevision , row . meta , worldSeed ) ; row . state = published . state ; if ( published . clearEvent ) { clearEvents . push ( published . clearEvent ) ; solved ++ ; earnedScore = Math . min ( Number . MAX _SAFE _INTEGER , earnedScore + published . clearEvent . scoreAwarded ) ; playerRecord . earnedScore = Math . min ( Number . MAX _SAFE _INTEGER , playerEarnedScore ( playerRecord ) + published . clearEvent . scoreAwarded ) ; playerRecord . economyRevision = ( playerRecord . economyRevision || 0 ) + 1 ; playerRecord . updatedAt = published . clearEvent . solvedAt ; record . expansionGrants [ player . playerId ] = { boardId : rawRow . id , expiresAt : published . clearEvent . solvedAt + EXPANSION _GRANT _TTL _MS , maxBoards : MAX _NEW _BOARDS _PER _GRANT } } }
2026-07-30 15:13:53 +09:00
for ( const [ id , row ] of changedBoards ) { if ( ! row . meta ) badRequest ( ` Board metadata is missing: ${ id } ` ) ; if ( ! row . state ) row . state = publicStateForWorld ( { } , null , playerRecord , nextRevision , rowRevision , row . meta ) . state }
await fsp . mkdir ( WORLD _BOARDS _DIR , { recursive : true , mode : 0o700 } ) ; for ( const [ id , row ] of changedBoards ) { await atomicWriteJson ( worldBoardVersionPath ( id , nextRevision ) , row ) ; record . boardVersions [ id ] = nextRevision }
2026-07-31 12:54:46 +09:00
record . occupancy = occupancy ; record . global = { ... ( record . global || { } ) , ... sanitizeWorldGlobal ( body . global , record ) } ; const boardNumbers = Object . keys ( record . boardVersions ) . map ( id => Number ( id . slice ( 1 ) ) ) . filter ( Number . isSafeInteger ) ; record . global . nextId = Math . max ( Number ( record . global . nextId ) || 1 , ( boardNumbers . length ? Math . max ( ... boardNumbers ) + 1 : 1 ) ) ; record . global . solved = solved ; record . global . earnedScore = Math . max ( 0 , earnedScore ) ; record . revision = nextRevision ; record . rowRevision = rowRevision ; record . global . cloudRevision = nextRevision ; record . updatedAt = serverTime ( ) ; record . global . updatedAt = record . updatedAt ;
2026-07-30 15:13:53 +09:00
record . changes . push ( { revision : nextRevision , metaIds : metas . map ( meta => meta . id ) , stateIds : states . map ( row => row . id ) , deleted } ) ; if ( record . changes . length > CHANGE _HISTORY _LIMIT ) record . changes . splice ( 0 , record . changes . length - CHANGE _HISTORY _LIMIT ) ;
record . clearEvents . push ( ... clearEvents ) ; if ( record . clearEvents . length > CLEAR _EVENT _LIMIT ) record . clearEvents . splice ( 0 , record . clearEvents . length - CLEAR _EVENT _LIMIT ) ;
2026-07-31 12:54:46 +09:00
await commitWorldMutation ( record , { nextPlayer : clearEvents . length ? playerRecord : null , previousPlayer : clearEvents . length ? previousPlayerRecord : null , changedBoardIds : changedBoards . keys ( ) } ) ;
2026-08-01 16:06:14 +09:00
for ( const [ id , row ] of changedBoards ) if ( row . state ? . solved === true ) realtimeHub ? . releaseBoardClaim ( id , 'cleared' ) ; realtimeHub ? . broadcastClearEvents ( clearEvents ) ;
return { status : 200 , body : { revision : nextRevision , clearEvents , latestEventRevision : record . clearEvents . at ( - 1 ) ? . revision || 0 , player : publicCloudPlayer ( playerRecord ) , serverTime : record . updatedAt } } ;
2026-07-31 12:54:46 +09:00
} ) ) ;
}
async function handleCloudPull ( req , res , url ) {
const player = await authenticatedPlayer ( req ) , result = await pullCloudWorldService ( player , url . searchParams ) ; return json ( res , result . status , result . body ) ;
}
async function handleCloudPush ( req , res ) {
const player = await authenticatedPlayer ( req ) , body = await readJsonBody ( req ) , result = await pushCloudWorldService ( player , body ) ; return json ( res , result . status , result . body ) ;
2026-07-30 15:13:53 +09:00
}
2026-08-01 16:06:14 +09:00
async function pollingIdentity ( req ) {
const player = await authenticatedPlayer ( req ) ; return { playerId : player . playerId , name : player . record . name } ;
}
async function handleRealtimeConnect ( req , res ) {
const identity = await pollingIdentity ( req ) , result = realtimeHub ? . createPollingClient ( identity ) ; if ( ! result ) throw Object . assign ( new Error ( 'Realtime service unavailable' ) , { status : 503 } ) ; return json ( res , 200 , result ) ;
}
async function handleRealtimeClaim ( req , res ) {
const identity = await pollingIdentity ( req ) , body = await readJsonBody ( req ) , result = await realtimeHub ? . claimBoard ( identity , body . boardId , body . presenceId ) ; if ( ! result ) throw Object . assign ( new Error ( 'Realtime service unavailable' ) , { status : 503 } ) ; return json ( res , 200 , result ) ;
}
async function handleRealtimeSend ( req , res ) {
const identity = await pollingIdentity ( req ) , body = await readJsonBody ( req ) , result = await realtimeHub ? . handlePollingMessage ( identity , String ( body . presenceId || '' ) , body . message , body . afterSequence ) ; if ( ! result ) throw Object . assign ( new Error ( 'Realtime session expired' ) , { status : 410 } ) ; return json ( res , 200 , result ) ;
}
async function handleRealtimePoll ( req , res , url ) {
const identity = await pollingIdentity ( req ) , result = realtimeHub ? . pollPollingClient ( identity , String ( url . searchParams . get ( 'presenceId' ) || '' ) , url . searchParams . get ( 'after' ) ) ; if ( ! result ) throw Object . assign ( new Error ( 'Realtime session expired' ) , { status : 410 } ) ; return json ( res , 200 , result ) ;
}
async function handleRealtimeDisconnect ( req , res ) {
const identity = await pollingIdentity ( req ) , body = await readJsonBody ( req ) , disconnected = realtimeHub ? . disconnectPollingClient ( identity , String ( body . presenceId || '' ) ) === true ; return json ( res , 200 , { disconnected , serverTime : serverTime ( ) } ) ;
}
2026-07-31 12:54:46 +09:00
const apiRouter = createHttpRouter ( { notFound : ( _req , res ) => json ( res , 404 , { error : 'API endpoint not found' , serverTime : serverTime ( ) } ) } ) ;
apiRouter
. add ( 'GET' , '/api/cloud/status' , handleCloudStatus )
. add ( 'POST' , '/api/cloud/session' , handleCloudSession )
. add ( 'POST' , '/api/cloud/profile' , handleCloudProfile )
. add ( 'GET' , '/api/player/state' , handlePlayerState )
. add ( 'POST' , '/api/player/generation-bonus' , handleGenerationBonus )
. add ( 'POST' , '/api/player/purchase' , handlePurchase )
. add ( 'GET' , '/api/cloud/pull' , handleCloudPull )
2026-08-01 16:06:14 +09:00
. add ( 'POST' , '/api/cloud/push' , handleCloudPush )
. add ( 'POST' , '/api/realtime/connect' , handleRealtimeConnect )
. add ( 'POST' , '/api/realtime/claim' , handleRealtimeClaim )
. add ( 'POST' , '/api/realtime/send' , handleRealtimeSend )
. add ( 'GET' , '/api/realtime/poll' , handleRealtimePoll )
. add ( 'POST' , '/api/realtime/disconnect' , handleRealtimeDisconnect ) ;
2026-07-31 12:54:46 +09:00
async function handleApi ( req , res , url ) { return apiRouter . dispatch ( req , res , url ) }
2026-07-30 15:13:53 +09:00
async function serveStatic ( req , res , url ) {
2026-07-31 12:54:46 +09:00
if ( ! [ 'GET' , 'HEAD' ] . includes ( req . method ) ) return json ( res , 405 , { error : 'Method not allowed' } ) ;
let pathname ; try { pathname = decodeURIComponent ( url . pathname ) } catch { return json ( res , 400 , { error : 'Invalid path' } ) }
2026-08-01 16:06:14 +09:00
if ( pathname === '/' || pathname . endsWith ( '/' ) || pathname . endsWith ( '/debug-items' ) ) pathname = '/index.html' ;
const candidates = [ pathname ] ; for ( let offset = pathname . indexOf ( '/' , 1 ) ; offset >= 0 ; offset = pathname . indexOf ( '/' , offset + 1 ) ) candidates . push ( pathname . slice ( offset ) ) ;
let file = null , stat = null , resolvedPathname = pathname ;
for ( const candidate of candidates ) { const target = path . resolve ( ROOT , ` . ${ candidate } ` ) ; if ( ! target . startsWith ( ` ${ ROOT } ${ path . sep } ` ) || path . basename ( target ) . startsWith ( '.' ) ) continue ; try { const targetStat = await fsp . stat ( target ) ; if ( targetStat . isFile ( ) ) { file = target ; stat = targetStat ; resolvedPathname = candidate ; break } } catch ( error ) { if ( error . code !== 'ENOENT' ) throw error } }
if ( ! file || ! stat ) return json ( res , 404 , { error : 'Not found' } ) ;
2026-07-31 12:54:46 +09:00
if ( stat . isDirectory ( ) ) return json ( res , 403 , { error : 'Directory listing is disabled' } ) ;
2026-08-01 16:06:14 +09:00
const ext = path . extname ( file ) . toLowerCase ( ) , body = resolvedPathname === '/runtime-config.js' ? Buffer . from ( "'use strict';\n(function(root){let appBaseUrl='';try{appBaseUrl=new URL('./',root.document?.currentScript?.src||root.location?.href||'').href}catch(_){}root.BendRuntimeConfig=Object.freeze({cloudApi:true,singleSharedWorld:true,worldId:'link-field-main',appBaseUrl,realtimeTransport:'http-poll'});})(globalThis);\n" ) : null ;
2026-07-31 12:54:46 +09:00
res . writeHead ( 200 , {
'content-type' : MIME [ ext ] || 'application/octet-stream' ,
'content-length' : body ? body . length : stat . size ,
'cache-control' : ext === '.html' || ext === '.js' || ext === '.css' ? 'no-store' : 'public, max-age=86400' ,
'x-content-type-options' : 'nosniff' ,
'cross-origin-resource-policy' : 'same-origin' ,
'content-security-policy' : "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; worker-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'self'"
} ) ;
if ( req . method === 'HEAD' ) return res . end ( ) ;
if ( body ) return res . end ( body ) ;
const stream = fs . createReadStream ( file ) ;
stream . on ( 'error' , error => { console . error ( error ) ; if ( ! res . headersSent ) json ( res , error . code === 'ENOENT' ? 404 : 500 , { error : error . code === 'ENOENT' ? 'Not found' : 'Internal server error' } ) ; else res . destroy ( error ) } ) ;
stream . pipe ( res ) ;
2026-07-30 15:13:53 +09:00
}
2026-08-01 16:06:14 +09:00
function listenOnce ( server , port , host ) {
return new Promise ( ( resolve , reject ) => {
const cleanup = ( ) => {
server . off ( 'error' , onError ) ;
server . off ( 'listening' , onListening ) ;
} ;
const onError = error => { cleanup ( ) ; reject ( error ) ; } ;
const onListening = ( ) => { cleanup ( ) ; resolve ( server . address ( ) ) ; } ;
server . once ( 'error' , onError ) ;
server . once ( 'listening' , onListening ) ;
server . listen ( { port , host } ) ;
} ) ;
}
async function listenWithPortFallback ( server , { host = HOST , preferredPort = PORT , explicitPort = PORT _EXPLICIT , candidates = null } = { } ) {
const ports = explicitPort ? [ preferredPort ] : uniquePorts ( candidates || defaultPortCandidates ( preferredPort ) ) ;
let lastError = null ;
for ( let index = 0 ; index < ports . length ; index ++ ) {
const requestedPort = ports [ index ] ;
try {
const address = await listenOnce ( server , requestedPort , host ) ;
const actualPort = typeof address === 'object' && address ? address . port : requestedPort ;
return { address , port : actualPort , requestedPort , preferredPort , usedFallback : index > 0 } ;
} catch ( error ) {
lastError = error ;
if ( error ? . code !== 'EADDRINUSE' ) throw error ;
if ( explicitPort ) {
const configuredBy = CLI _PORT != null ? '--port' : process . env . LINK _FIELD _PORT != null ? 'LINK_FIELD_PORT' : 'PORT' ;
throw Object . assign ( new Error ( ` Port ${ requestedPort } is already in use. Change ${ configuredBy } or stop the process using that port. ` ) , { code : 'EADDRINUSE' , cause : error } ) ;
}
console . warn ( ` Port ${ requestedPort } is already in use; trying another port. ` ) ;
}
}
throw Object . assign ( new Error ( ` No available port was found. Tried: ${ ports . join ( ', ' ) } ` ) , { code : lastError ? . code || 'EADDRINUSE' , cause : lastError } ) ;
}
function createApplicationServer ( ) {
return http . createServer ( async ( req , res ) => { try { const url = new URL ( req . url , ` http:// ${ req . headers . host || 'localhost' } ` ) , apiOffset = url . pathname . lastIndexOf ( '/api/' ) ; if ( apiOffset >= 0 ) { url . pathname = url . pathname . slice ( apiOffset ) ; await handleApi ( req , res , url ) } else await serveStatic ( req , res , url ) } catch ( error ) { console . error ( error ) ; if ( ! res . headersSent ) json ( res , error . status || 500 , { error : error . status ? error . message : 'Internal server error' , serverTime : serverTime ( ) } ) ; else res . destroy ( ) } } ) ;
}
function displayServerUrl ( host , port ) {
const displayHost = host === '0.0.0.0' || host === '::' ? '127.0.0.1' : host ;
return ` http:// ${ displayHost } : ${ port } ` ;
}
function processIsRunning ( pid ) {
if ( ! Number . isSafeInteger ( pid ) || pid <= 0 ) return false ;
try { process . kill ( pid , 0 ) ; return true } catch ( error ) { return error ? . code === 'EPERM' }
}
async function acquireInstanceLock ( ) {
await fsp . mkdir ( DATA _DIR , { recursive : true , mode : 0o700 } ) ;
for ( let attempt = 0 ; attempt < 2 ; attempt ++ ) {
try {
const handle = await fsp . open ( INSTANCE _LOCK _FILE , 'wx' , 0o600 ) ;
await handle . writeFile ( ` ${ process . pid }
` ,'utf8');
await handle . close ( ) ;
return process . pid ;
} catch ( error ) {
if ( error ? . code !== 'EEXIST' ) throw error ;
let existing = 0 ; try { existing = Number ( ( await fsp . readFile ( INSTANCE _LOCK _FILE , 'utf8' ) ) . trim ( ) ) } catch ( _ ) { }
if ( processIsRunning ( existing ) ) throw Object . assign ( new Error ( ` Another LinkField server is already running (PID ${ existing } ). Stop it before starting a second shared world. ` ) , { code : 'EALREADY' } ) ;
await fsp . unlink ( INSTANCE _LOCK _FILE ) . catch ( unlinkError => { if ( unlinkError ? . code !== 'ENOENT' ) throw unlinkError } ) ;
}
}
throw new Error ( 'Could not acquire the LinkField single-world lock.' ) ;
}
async function releaseInstanceLock ( ) {
try { const owner = Number ( ( await fsp . readFile ( INSTANCE _LOCK _FILE , 'utf8' ) ) . trim ( ) ) ; if ( owner === process . pid ) await fsp . unlink ( INSTANCE _LOCK _FILE ) } catch ( error ) { if ( error ? . code !== 'ENOENT' ) console . warn ( ` Instance lock cleanup warning: ${ error . message } ` ) }
}
async function main ( ) {
await acquireInstanceLock ( ) ;
await fsp . mkdir ( DATA _DIR , { recursive : true , mode : 0o700 } ) ;
await fsp . mkdir ( PUBLIC _ROOT , { recursive : true , mode : 0o755 } ) ;
await recoverPendingWorldCommit ( ) ;
await collectRetiredBoardVersions ( ) ;
const server = createApplicationServer ( ) ;
realtimeHub = createRealtimeHub ( { server , authenticate : authenticateRealtime , getBoardInfo : realtimeBoardInfo } ) ;
try {
const listening = await listenWithPortFallback ( server , { explicitPort : PORT _STRICT } ) ;
console . log ( ` LinkField v ${ BuildMeta . APP _VERSION } shared world: ${ displayServerUrl ( HOST , listening . port ) } ` ) ;
if ( listening . usedFallback ) console . log ( ` Default port ${ listening . preferredPort } was unavailable; using port ${ listening . port } . ` ) ;
await fsp . writeFile ( PUBLIC _BRIDGE _PORT _FILE , ` ${ listening . port } \n ` , { encoding : 'utf8' , mode : 0o644 } ) ;
console . log ( ` Public PHP bridge: ${ PUBLIC _BRIDGE _PORT _FILE } -> 127.0.0.1: ${ listening . port } ` ) ;
let apacheBridge = null ;
try {
apacheBridge = await installApacheBridge ( { fsp , root : PUBLIC _ROOT , port : listening . port , enabled : APACHE _BRIDGE _ENABLED } ) ;
if ( apacheBridge . enabled ) console . log ( ` Apache bridge: ${ apacheBridge . file } -> 127.0.0.1: ${ listening . port } ` ) ;
else console . log ( 'Apache bridge: disabled by LINK_FIELD_APACHE_BRIDGE' ) ;
} catch ( error ) {
console . warn ( ` Apache bridge could not be installed: ${ error . message } ` ) ;
console . warn ( 'The public /api/ path must be forwarded to this Node.js port by the web server.' ) ;
}
console . log ( ` Shared world data: ${ DATA _DIR } ` ) ;
return { server , realtimeHub , port : listening . port , host : HOST , dataDir : DATA _DIR , publicRoot : PUBLIC _ROOT , portFile : PUBLIC _BRIDGE _PORT _FILE , apacheBridge } ;
} catch ( error ) {
realtimeHub ? . close ( ) ;
realtimeHub = null ;
await fsp . unlink ( PUBLIC _BRIDGE _PORT _FILE ) . catch ( ( ) => { } ) ;
await releaseInstanceLock ( ) ;
throw error ;
}
}
async function closeApplication ( result ) {
if ( ! result ) return ;
try { result . realtimeHub ? . close ( ) } catch ( error ) { console . warn ( ` Realtime shutdown warning: ${ error . message } ` ) }
if ( result . server ? . listening ) await new Promise ( resolve => result . server . close ( ( ) => resolve ( ) ) ) ;
await fsp . unlink ( result . portFile || PUBLIC _BRIDGE _PORT _FILE ) . catch ( error => { if ( error ? . code !== 'ENOENT' ) console . warn ( ` Port file cleanup warning: ${ error . message } ` ) } ) ;
await releaseInstanceLock ( ) ;
}
module . exports = Object . freeze ( { normalizePlayerPurchases , sanitizeWorldGlobal , storeItem , collectRetiredBoardVersions , recoverPendingWorldCommit , commitWorldMutation , parsePort , defaultPortCandidates , listenWithPortFallback , createApplicationServer , closeApplication , main } ) ;
if ( require . main === module ) {
let active = null , stopping = false ;
const shutdown = async signal => {
if ( stopping ) return ;
stopping = true ;
console . log ( ` LinkField received ${ signal } ; stopping. ` ) ;
await closeApplication ( active ) ;
process . exit ( 0 ) ;
} ;
main ( ) . then ( result => {
active = result ;
process . once ( 'SIGTERM' , ( ) => shutdown ( 'SIGTERM' ) ) ;
process . once ( 'SIGINT' , ( ) => shutdown ( 'SIGINT' ) ) ;
} ) . catch ( error => { console . error ( ` LinkField server failed to start: ${ error . message } ` ) ; if ( process . env . LINK _FIELD _STARTUP _DEBUG === '1' ) console . error ( error ) ; process . exitCode = 1 } ) ;
}