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' ) ;
2026-08-01 22:31:04 +09:00
const { createWorldBackup } = require ( './server/world-backup' ) ;
2026-07-31 12:54:46 +09:00
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 ) ;
2026-08-01 22:31:04 +09:00
const PRODUCTION _DATA _DIR = path . resolve ( String ( process . env . LINK _FIELD _WORLD _DIR || '/link-field/world' ) ) ;
2026-08-01 16:06:14 +09:00
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 ;
2026-08-01 22:31:04 +09:00
const BACKUP _DIR = path . resolve ( String ( process . env . LINK _FIELD _BACKUP _DIR || path . join ( path . dirname ( DATA _DIR ) , 'backups' ) ) ) ;
const BACKUPS _ENABLED = ! TEST _DATA _ROOT && ! [ '0' , 'false' , 'off' , 'no' ] . includes ( String ( process . env . LINK _FIELD _BACKUPS || '' ) . trim ( ) . toLowerCase ( ) ) ;
2026-08-01 16:06:14 +09:00
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 ) ;
2026-08-01 22:31:04 +09:00
const APACHE _BRIDGE _SETTING = String ( process . env . LINK _FIELD _APACHE _BRIDGE || '' ) . trim ( ) . toLowerCase ( ) ;
const APACHE _BRIDGE _ENABLED = TEST _DATA _ROOT ? [ '1' , 'true' , 'on' , 'yes' ] . includes ( APACHE _BRIDGE _SETTING ) : ! [ '0' , 'false' , 'off' , 'no' ] . includes ( APACHE _BRIDGE _SETTING ) ;
const PUBLIC _BRIDGE _PORT _FILE = TEST _DATA _ROOT && ! process . env . LINK _FIELD _PUBLIC _DIR ? path . join ( path . resolve ( TEST _DATA _ROOT ) , '.linkfield-port' ) : path . join ( PUBLIC _ROOT , '.linkfield-port' ) ;
const MAX _BODY _BYTES = 8 * 1024 * 1024 ;
const MAX _BOARDS _PER _PUSH = 16 ;
const MAX _METAS _PER _PUSH = 8 ;
const MAX _PATHS _PER _BOARD = 128 ;
const MAX _CELLS _PER _PATH = 1250 ;
const CLOUD _PAGE _LIMIT = 64 ;
const CLOUD _READ _CONCURRENCY = 16 ;
2026-07-30 15:13:53 +09:00
const CHANGE _HISTORY _LIMIT = 256 ;
const CLEAR _EVENT _LIMIT = 64 ;
const MAX _PLAYER _PURCHASES = 10000 ;
2026-08-01 22:31:04 +09:00
const MAX _PLAYER _RECORDS = 100000 ;
2026-07-30 15:13:53 +09:00
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-08-01 22:31:04 +09:00
const MUTATION _RE = /^[A-Za-z0-9_-]{8,96}$/ ;
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' ,
} ;
2026-08-01 22:31:04 +09:00
const PUBLIC _STATIC _ROOTS = new Set ( [ 'index.html' , 'style.css' , 'favicon.svg' , 'favicon.ico' , 'build-meta.js' , 'runtime-config.js' , 'shared-contracts.js' , 'store-catalog.generated.js' , 'store-catalog.json' , 'puzzle-patterns.js' , 'puzzle-core.js' , 'app-logic.js' , 'archive-codec.js' , 'field-persistence.js' , 'field-persistence-worker.js' , 'puzzle-worker.js' , 'app.js' , 'assets' , 'client' ] ) ;
2026-07-30 15:13:53 +09:00
let worldQueue = Promise . resolve ( ) ;
2026-08-01 22:31:04 +09:00
let worldCache = null ;
2026-07-30 15:13:53 +09:00
const playerQueues = new Map ( ) ;
2026-08-01 22:31:04 +09:00
let playerCapacityQueue = Promise . resolve ( ) ;
2026-07-30 15:13:53 +09:00
let realtimeHub = null ;
2026-08-01 22:31:04 +09:00
let playerRecordCount = null ;
let instanceLockToken = null , instanceLockHeartbeatTimer = null ;
2026-07-31 12:54:46 +09:00
const jsonRepository = createJsonRepository ( { fsp , crypto } ) ;
2026-08-01 22:31:04 +09:00
const rateWindows = new Map ( ) ;
function rateLimit ( key , limit , windowMs , cost = 1 ) {
const now = serverTime ( ) , normalized = String ( key || 'unknown' ) , current = rateWindows . get ( normalized ) ;
if ( rateWindows . size > 10000 ) { for ( const [ item , value ] of rateWindows ) if ( value . resetAt <= now ) rateWindows . delete ( item ) ; if ( rateWindows . size > 20000 ) throw Object . assign ( new Error ( 'Request limiter capacity has been reached' ) , { status : 503 } ) }
cost = Math . max ( 1 , Math . floor ( Number ( cost ) || 1 ) ) ;
if ( ! current || current . resetAt <= now ) { if ( cost <= limit ) { rateWindows . set ( normalized , { count : cost , resetAt : now + windowMs } ) ; return true } const error = Object . assign ( new Error ( 'Too many requests' ) , { status : 429 , retryAfter : Math . max ( 1 , Math . ceil ( windowMs / 1000 ) ) } ) ; throw error }
current . count += cost ;
if ( current . count <= limit ) return true ;
const error = Object . assign ( new Error ( 'Too many requests' ) , { status : 429 , retryAfter : Math . max ( 1 , Math . ceil ( ( current . resetAt - now ) / 1000 ) ) } ) ;
throw error ;
}
function requestIp ( req ) {
const direct = String ( req ? . socket ? . remoteAddress || 'unknown' ) . replace ( /^::ffff:/ , '' ) ;
if ( ! [ '127.0.0.1' , '::1' ] . includes ( direct ) ) return direct ;
const forwarded = String ( req ? . headers ? . [ 'x-forwarded-for' ] || '' ) . split ( ',' ) . at ( - 1 ) . trim ( ) ;
return /^[0-9a-f:.]{3,64}$/i . test ( forwarded ) ? forwarded : direct ;
}
async function assertPlayerCapacity ( ) {
if ( playerRecordCount == null ) { let names = [ ] ; try { names = await fsp . readdir ( DATA _DIR ) } catch ( error ) { if ( error . code !== 'ENOENT' ) throw error } playerRecordCount = names . filter ( name => / ^ [ a - f0 - 9 ] { 16 , 64 } \ . json$ / i . test ( name ) ) . length }
if ( playerRecordCount >= MAX _PLAYER _RECORDS ) throw Object . assign ( new Error ( 'Player capacity has been reached' ) , { status : 503 } ) ;
}
function withPlayerCapacity ( task ) { const run = playerCapacityQueue . then ( task , task ) ; playerCapacityQueue = run . then ( ( ) => undefined , ( ) => undefined ) ; return run }
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-08-01 22:31:04 +09:00
function emptyWorld ( ) { const now = serverTime ( ) ; return { revision : 0 , rowRevision : now * 1000 , expansionGrants : { } , recentMutations : [ ] , 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 : [ ] , clearEventsDroppedThrough : 0 , createdAt : now , updatedAt : now } }
2026-07-30 15:13:53 +09:00
async function readWorld ( ) {
2026-08-01 22:31:04 +09:00
if ( worldCache ) return worldCache ;
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' ) ;
2026-08-01 22:31:04 +09:00
if ( value . global ? . worldGeneration !== BuildMeta . WORLD _GENERATION ) throw Object . assign ( new Error ( ` Stored world generation ${ String ( value . global ? . worldGeneration || 'missing' ) } does not match ${ BuildMeta . WORLD _GENERATION } ; restore or migrate the world before startup. ` ) , { status : 503 , code : 'EWORLDGENERATION' } ) ;
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 . recentMutations = Array . isArray ( value . recentMutations ) ? value . recentMutations . slice ( - 1024 ) : [ ] ; value . clearEvents = Array . isArray ( value . clearEvents ) ? value . clearEvents : [ ] ; value . clearEventsDroppedThrough = Number . isSafeInteger ( value . clearEventsDroppedThrough ) ? value . clearEventsDroppedThrough : 0 ; value . expansionGrants = value . expansionGrants && typeof value . expansionGrants === 'object' ? value . expansionGrants : { } ; value . global = value . global && typeof value . global === 'object' ? value . global : { } ; worldCache = value ; return value
} catch ( error ) { if ( error . code === 'ENOENT' ) { worldCache = emptyWorld ( ) ; return worldCache } 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 ( ) {
2026-08-01 22:31:04 +09:00
let world ; try { world = JSON . parse ( await fsp . readFile ( WORLD _FILE , 'utf8' ) ) } catch ( error ) { if ( error . code === 'ENOENT' ) return 0 ; throw error }
if ( ! world || world . global ? . worldGeneration !== BuildMeta . WORLD _GENERATION || ! world . boardVersions || typeof world . boardVersions !== 'object' ) return 0 ;
let names = [ ] ; try { names = await fsp . readdir ( WORLD _BOARDS _DIR ) } catch ( error ) { if ( error . code === 'ENOENT' ) return 0 ; throw error }
2026-07-31 12:54:46 +09:00
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 ;
}
2026-08-01 22:31:04 +09:00
async function removeRetiredBoardVersions ( entries ) {
for ( const entry of entries || [ ] ) {
if ( ! entry || ! BOARD _RE . test ( entry . id ) || ! Number . isSafeInteger ( entry . revision ) || entry . revision < 0 ) continue ;
await fsp . unlink ( worldBoardVersionPath ( entry . id , entry . revision ) ) . catch ( error => { if ( error . code !== 'ENOENT' ) console . warn ( ` Retired board cleanup warning: ${ error . message } ` ) } ) ;
}
}
2026-07-31 12:54:46 +09:00
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 ) ;
2026-08-01 22:31:04 +09:00
await atomicWriteJson ( WORLD _FILE , commit . world ) ; worldCache = commit . world ;
2026-07-31 12:54:46 +09:00
} 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 ) ;
2026-08-01 22:31:04 +09:00
await atomicWriteJson ( WORLD _FILE , world ) ; worldCache = world ;
2026-07-31 12:54:46 +09:00
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-08-01 22:31:04 +09:00
async function readJsonBody ( req ) { const declared = Number ( req . headers ? . [ 'content-length' ] ) ; if ( Number . isFinite ( declared ) && declared > MAX _BODY _BYTES ) throw Object . assign ( new Error ( 'Request body is too large' ) , { status : 413 } ) ; const chunks = [ ] ; let bytes = 0 ; for await ( const chunk of req ) { bytes += chunk . length ; if ( bytes > MAX _BODY _BYTES ) { req . destroy ( ) ; 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 } ) } }
2026-07-30 15:13:53 +09:00
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 : [ ] ;
2026-08-01 22:31:04 +09:00
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 ] ) || cell [ 0 ] < 0 || cell [ 1 ] < 0 || cell [ 0 ] > 64 || cell [ 1 ] > 64 || seen . has ( cellKey ( cell ) ) ) badRequest ( ` Invalid chunk coordinate for ${ meta . id } ` ) ; seen . add ( cellKey ( cell ) ) }
if ( Math . min ( ... chunks . map ( cell => cell [ 0 ] ) ) !== 0 || Math . min ( ... chunks . map ( cell => cell [ 1 ] ) ) !== 0 ) badRequest ( ` Unanchored chunks for ${ meta . id } ` ) ; const reached = new Set ( [ cellKey ( chunks [ 0 ] ) ] ) , queue = [ chunks [ 0 ] ] ; while ( queue . length ) { const [ x , y ] = queue . shift ( ) ; for ( const [ dx , dy ] of [ [ 1 , 0 ] , [ - 1 , 0 ] , [ 0 , 1 ] , [ 0 , - 1 ] ] ) { const key = ` ${ x + dx } , ${ y + dy } ` ; if ( seen . has ( key ) && ! reached . has ( key ) ) { reached . add ( key ) ; queue . push ( [ x + dx , y + dy ] ) } } } if ( reached . size !== seen . size ) badRequest ( ` Disconnected chunks for ${ meta . id } ` ) ;
2026-07-30 15:13:53 +09:00
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 } ` ) ;
2026-08-01 22:31:04 +09:00
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 } ` ) ; const sectionRange = AppLogic . sectionCountRange ( meta . targetLevel ) ; if ( chunks . length < sectionRange . min || chunks . length > sectionRange . max || meta . level <= 3 && chunks . length !== 1 ) badRequest ( ` Invalid section count for ${ meta . id } ` ) ;
2026-07-30 15:13:53 +09:00
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 ) {
2026-08-01 22:31:04 +09:00
const verification = PuzzleCore . verifyPuzzleUniqueness ( clean . puzzle , { maxMs : 1000 , nodeCap : 100000 , analyzeQuality : false } ) ;
2026-07-31 12:54:46 +09:00
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 } ;
}
2026-08-01 22:31:04 +09:00
const source = clean . puzzle , bounds = source . bounds && Number . isInteger ( source . bounds . w ) && Number . isInteger ( source . bounds . h ) && source . bounds . w > 0 && source . bounds . w <= 325 && source . bounds . h > 0 && source . bounds . h <= 325 ? { w : source . bounds . w , h : source . bounds . h } : null ;
clean . puzzle = { g : source . g , n : source . n , valid : source . valid , obstacles : Array . isArray ( source . obstacles ) ? source . obstacles : [ ] , specialCells : { crossings : Array . isArray ( source . specialCells ? . crossings ) ? source . specialCells . crossings : [ ] , warps : Array . isArray ( source . specialCells ? . warps ) ? source . specialCells . warps : [ ] , locks : Array . isArray ( source . specialCells ? . locks ) ? source . specialCells . locks : [ ] , internalGates : Array . isArray ( source . specialCells ? . internalGates ) ? source . specialCells . internalGates : [ ] } , bounds , axis : typeof source . axis === 'string' ? source . axis . slice ( 0 , 16 ) : 'MIX' , solution : source . solution , level : derivedLevel , difficulty : derivedLevel , maxTurns : Number . isFinite ( source . maxTurns ) ? source . maxTurns : 0 , totalTurns : Number . isFinite ( source . totalTurns ) ? source . totalTurns : 0 , style : typeof source . style === 'string' ? source . style . slice ( 0 , 32 ) : 'stored-procedural' , complexity : source . complexity , regionalTarget : Number . isInteger ( source . regionalTarget ) ? source . regionalTarget : clean . targetLevel , regionalFallback : source . regionalFallback === true , regionalOutlier : source . regionalOutlier === true , uniqueness : source . uniqueness || null , interactionBurden : source . interactionBurden } ;
return { id : clean . id , x : clean . x , y : clean . y , chunks : clean . chunks . map ( cell => [ ... cell ] ) , level : derivedLevel , targetLevel : clean . targetLevel , seed : clean . seed , axis : clean . puzzle . axis , entrySide : [ 'N' , 'S' , 'W' , 'E' ] . includes ( clean . entrySide ) ? clean . entrySide : null , sealedSides : [ ... new Set ( ( clean . sealedSides || [ ] ) . filter ( side => [ 'N' , 'S' , 'W' , 'E' ] . includes ( side ) ) ) ] , puzzle : clean . puzzle , generatorVersion : BuildMeta . GENERATOR _VERSION } ;
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-08-01 22:31:04 +09:00
const cleanPaths = paths . map ( item => ( { startGate : item . startGate , endGate : item . endGate ? ? null , openGate : Number . isInteger ( item . openGate ) ? item . openGate : null , detachedStart : item . detachedStart === true && item . endGate == null , cells : item . cells . map ( cell => [ ... cell ] ) , colorIndex : Number . isInteger ( item . colorIndex ) && item . colorIndex >= 0 && item . colorIndex < 64 ? item . colorIndex : 0 , startColorIndex : Number . isInteger ( item . startColorIndex ) && item . startColorIndex >= 0 && item . startColorIndex < 64 ? item . startColorIndex : 0 , endColorIndex : Number . isInteger ( item . endColorIndex ) && item . endColorIndex >= 0 && item . endColorIndex < 64 ? item . endColorIndex : null , lineEffect : typeof item . lineEffect === 'string' && item . lineEffect . length <= 64 ? item . lineEffect : null , ownerId : PLAYER _RE . test ( String ( item . ownerId || '' ) ) ? String ( item . ownerId ) . toLowerCase ( ) : null } ) ) ;
const crossings = [ ... new Set ( ( Array . isArray ( state . specialProgress ? . crossings ) ? state . specialProgress . crossings : [ ] ) . filter ( value => typeof value === 'string' && /^-?\d+,-?\d+$/ . test ( value ) ) ) ] . slice ( 0 , 64 ) ;
return { paths : cleanPaths , specialProgress : { crossings } , solved : state . solved === true , expanded : state . expanded === true , expansionRetryRound : Number . isInteger ( state . expansionRetryRound ) && state . expansionRetryRound >= 0 ? Math . min ( state . expansionRetryRound , 1000000 ) : 0 , scoreVersion : Number . isInteger ( state . scoreVersion ) && state . scoreVersion >= 0 ? Math . min ( state . scoreVersion , 1000 ) : 0 } ;
2026-07-30 15:13:53 +09:00
}
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 = { } ;
2026-08-01 22:31:04 +09:00
if ( Number . isInteger ( source . gameplayVersion ) && source . gameplayVersion >= 0 && source . gameplayVersion <= 1000 ) clean . gameplayVersion = source . gameplayVersion ;
if ( Number . isFinite ( source . lastSolveAt ) && source . lastSolveAt > 0 ) clean . lastSolveAt = Math . min ( serverTime ( ) + 60_000 , source . lastSolveAt ) ;
2026-07-31 12:54:46 +09:00
clean . specialMechanicsSeen = Array . isArray ( clean . specialMechanicsSeen ) ? SharedContracts . normalizeSpecialMechanics ( clean . specialMechanicsSeen ) : SharedContracts . normalizeSpecialMechanics ( prior . specialMechanicsSeen ) ;
2026-08-01 22:31:04 +09:00
if ( Array . isArray ( source . specialMechanicsSeen ) ) clean . specialMechanicsSeen = SharedContracts . normalizeSpecialMechanics ( [ ... ( prior . specialMechanicsSeen || [ ] ) , ... source . 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 } }
2026-08-01 22:31:04 +09:00
async function readWorldBoardsBounded ( record , ids ) { const rows = [ ] ; for ( let offset = 0 ; offset < ids . length ; offset += CLOUD _READ _CONCURRENCY ) rows . push ( ... await Promise . all ( ids . slice ( offset , offset + CLOUD _READ _CONCURRENCY ) . map ( id => readWorldBoard ( record , id ) ) ) ) ; return rows }
function completedProgressState ( state ) { if ( ! state || state . solved === true ) return state ; const visible = JSON . parse ( JSON . stringify ( state ) ) ; visible . paths = ( visible . paths || [ ] ) . filter ( path => Number . isInteger ( path ? . endGate ) ) ; visible . specialProgress = { crossings : [ ] } ; return visible }
function completedProgressFingerprint ( state ) { const visible = completedProgressState ( state ) || { } ; return JSON . stringify ( { paths : visible . paths || [ ] , solved : visible . solved === true , expanded : visible . expanded === true } ) }
async function publicWorldPage ( record , ids , metaIds = null , stateIds = null , viewerPlayerId = '' ) { const rows = await readWorldBoardsBounded ( record , ids ) , 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 ] = realtimeHub ? . hasClaim ( viewerPlayerId , id ) ? row . state : completedProgressState ( row . state ) } return { global : { ... ( record . global || { } ) , cloudProfile : null , cloudRevision : record . revision } , metas , states } }
2026-07-30 15:13:53 +09:00
function withWorldQueue ( task ) { const run = worldQueue . then ( task , task ) ; worldQueue = run . then ( ( ) => undefined , ( ) => undefined ) ; return run }
2026-08-01 22:31:04 +09:00
function canRebaseWorldPush ( record , baseRevision , metas , states ) {
if ( baseRevision === record . revision ) return true ;
if ( baseRevision < 0 || baseRevision > record . revision ) return false ;
const delta = cloudDeltaSince ( record , baseRevision ) ; if ( ! delta ) return false ;
const touched = new Set ( [ ... metas . map ( meta => String ( meta ? . id || '' ) ) , ... states . map ( row => String ( row ? . id || '' ) ) ] ) ;
if ( metas . some ( meta => ! Object . prototype . hasOwnProperty . call ( record . boardVersions , meta ? . id ) ) ) return false ;
for ( const id of touched ) if ( delta . metaIds . has ( id ) || delta . stateIds . has ( id ) || delta . deleted . has ( id ) ) return false ;
return true ;
}
2026-07-30 15:13:53 +09:00
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 ) {
2026-08-01 22:31:04 +09:00
const ip = requestIp ( req ) ; rateLimit ( ` session: ${ ip } ` , 30 , 60_000 ) ; const body = await readJsonBody ( req ) , result = await withPlayerCapacity ( async ( ) => { await assertPlayerCapacity ( ) ; const created = await playerService . createSession ( body . name ) ; playerRecordCount ++ ; return created } ) ; return json ( res , result . status , result . body ) ;
2026-07-31 12:54:46 +09:00
}
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 ,
2026-08-01 22:31:04 +09:00
clearEvents = record . clearEvents . filter ( event => ( event . revision || 0 ) > eventsSince ) , clearEventsGap = eventsSince > 0 && eventsSince < ( record . clearEventsDroppedThrough || 0 ) ;
if ( ! changed ) return { status : 200 , body : { changed : false , revision : record . revision , latestEventRevision : record . clearEvents . at ( - 1 ) ? . revision || record . clearEventsDroppedThrough || 0 , clearEvents , clearEventsGap , 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 ( ) } } ;
2026-08-01 22:31:04 +09:00
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 , player . playerId ) ;
return { status : 200 , body : { changed : true , revision : record . revision , latestEventRevision : record . clearEvents . at ( - 1 ) ? . revision || record . clearEventsDroppedThrough || 0 , clearEvents : cursor ? [ ] : clearEvents , clearEventsGap : cursor ? false : clearEventsGap , 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 ( ) => {
2026-08-01 22:31:04 +09:00
const playerRecord = await readPlayer ( player . playerId ) , previousPlayerRecord = JSON . parse ( JSON . stringify ( playerRecord ) ) , record = await ensureWorldIndexes ( JSON . parse ( JSON . stringify ( await readWorld ( ) ) ) ) , baseRevision = Math . max ( 0 , Math . floor ( finiteNumber ( body . baseRevision , 0 ) ) ) , requestedMutationId = String ( body . mutationId || '' ) , mutationId = requestedMutationId || ` legacy- ${ crypto . randomBytes ( 16 ) . toString ( 'hex' ) } ` , metas = Array . isArray ( body . metas ) ? body . metas : [ ] , states = Array . isArray ( body . states ) ? body . states : [ ] , deleted = [ ] ;
if ( requestedMutationId && ! MUTATION _RE . test ( requestedMutationId ) ) badRequest ( 'Invalid mutation id' ) ; const priorMutation = requestedMutationId && ( record . recentMutations || [ ] ) . find ( row => row ? . playerId === player . playerId && row ? . mutationId === mutationId ) ; if ( priorMutation ) { const clearEvents = record . clearEvents . filter ( event => event . revision === priorMutation . revision && event . playerId === player . playerId ) ; return { status : 200 , body : { revision : priorMutation . revision , duplicate : true , clearEvents , latestEventRevision : record . clearEvents . at ( - 1 ) ? . revision || record . clearEventsDroppedThrough || 0 , player : publicCloudPlayer ( playerRecord ) , serverTime : serverTime ( ) } } }
if ( metas . length > MAX _METAS _PER _PUSH || states . length > MAX _BOARDS _PER _PUSH || metas . length + states . length > MAX _BOARDS _PER _PUSH * 2 ) throw Object . assign ( new Error ( 'Too many board changes' ) , { status : 413 } ) ;
const metaIds = metas . map ( meta => String ( meta ? . id || '' ) ) , stateIds = states . map ( row => String ( row ? . id || '' ) ) ; if ( new Set ( metaIds ) . size !== metaIds . length || new Set ( stateIds ) . size !== stateIds . length ) badRequest ( 'Duplicate board changes' ) ;
if ( ! canRebaseWorldPush ( record , baseRevision , metas , states ) ) return { status : 409 , body : { error : 'Revision conflict' , revision : record . revision , serverTime : serverTime ( ) } } ;
if ( metaIds . some ( id => ! BOARD _RE . test ( id ) ) ) badRequest ( 'Invalid board metadata' ) ; const existingIds = new Set ( Object . keys ( record . boardVersions ) ) , rawNewMetaIds = metaIds . filter ( id => ! existingIds . has ( id ) ) ; if ( rawNewMetaIds . length > MAX _NEW _BOARDS _PER _GRANT ) badRequest ( 'Too many generated boards' ) ;
let expansionGrant = null ; if ( record . revision > 0 && rawNewMetaIds . length ) { expansionGrant = record . expansionGrants ? . [ player . playerId ] ; if ( ! expansionGrant || expansionGrant . expiresAt < serverTime ( ) ) throw Object . assign ( new Error ( 'Expansion grant is required' ) , { status : 403 } ) ; if ( rawNewMetaIds . length > Math . min ( MAX _NEW _BOARDS _PER _GRANT , expansionGrant . maxBoards || 0 ) ) badRequest ( 'Too many generated boards' ) ; const expectedStart = Math . max ( 1 , Number ( record . global ? . nextId ) || 1 ) , numbers = rawNewMetaIds . 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' ) }
const nextRevision = record . revision + 1 , rowRevision = Math . max ( ( Number ( record . rowRevision ) || 0 ) + 1 , serverTime ( ) * 1000 ) , changedBoards = new Map ( ) , visibleBoardIds = new Set ( metaIds ) , 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 } ;
for ( const rawMeta of metas ) { const row = await loadChangedBoard ( rawMeta . id ) , unchanged = row . meta && rawMeta && typeof rawMeta === 'object' && worldMetaFingerprint ( row . meta ) === worldMetaFingerprint ( rawMeta ) , clean = unchanged ? JSON . parse ( JSON . stringify ( row . meta ) ) : validateMeta ( rawMeta ) ; 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 }
const newMetaIds = [ ... changedBoards . keys ( ) ] . filter ( id => ! existingIds . has ( id ) ) , occupancy = { ... record . occupancy } ;
if ( record . revision > 0 && newMetaIds . length ) { for ( const id of newMetaIds . sort ( ( a , b ) => Number ( a . slice ( 1 ) ) - Number ( b . slice ( 1 ) ) ) ) addMetaToWorldOccupancy ( changedBoards . get ( id ) . meta , occupancy , { requireTouch : true } ) ; expansionGrant . maxBoards -= newMetaIds . length ; if ( expansionGrant . maxBoards <= 0 ) delete record . expansionGrants [ player . playerId ] }
2026-07-31 12:54:46 +09:00
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 22:31:04 +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 ) , previousProgress = completedProgressFingerprint ( row . state ) ; if ( record . revision > 0 && existingBoard && unfinishedBoard && realtimeHub && ! realtimeHub . hasClaim ( player . playerId , rawRow . id ) ) throw Object . assign ( new Error ( 'Board claim is required' ) , { status : 423 , boardId : rawRow . id } ) ; 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 ( previousProgress !== completedProgressFingerprint ( row . state ) ) visibleBoardIds . add ( rawRow . id ) ; 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 }
2026-08-01 22:31:04 +09:00
const retiredVersions = [ ] ; await fsp . mkdir ( WORLD _BOARDS _DIR , { recursive : true , mode : 0o700 } ) ; for ( const [ id , row ] of changedBoards ) { const previousRevision = record . boardVersions [ id ] ; await atomicWriteJson ( worldBoardVersionPath ( id , nextRevision ) , row ) ; record . boardVersions [ id ] = nextRevision ; if ( Number . isSafeInteger ( previousRevision ) && previousRevision !== nextRevision ) retiredVersions . push ( { id , revision : previousRevision } ) }
record . occupancy = occupancy ; record . global = { ... ( record . global || { } ) , ... sanitizeWorldGlobal ( body . global , record ) , schema : BuildMeta . SAVE _SCHEMA , appVersion : BuildMeta . APP _VERSION , generatorVersion : BuildMeta . GENERATOR _VERSION , worldGeneration : BuildMeta . WORLD _GENERATION } ; 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 ) ;
2026-08-01 22:31:04 +09:00
record . recentMutations = Array . isArray ( record . recentMutations ) ? record . recentMutations : [ ] ; record . recentMutations . push ( { playerId : player . playerId , mutationId , revision : nextRevision , committedAt : record . updatedAt } ) ; if ( record . recentMutations . length > 1024 ) record . recentMutations . splice ( 0 , record . recentMutations . length - 1024 ) ;
record . clearEvents . push ( ... clearEvents ) ; if ( record . clearEvents . length > CLEAR _EVENT _LIMIT ) { const removed = record . clearEvents . splice ( 0 , record . clearEvents . length - CLEAR _EVENT _LIMIT ) ; record . clearEventsDroppedThrough = Math . max ( record . clearEventsDroppedThrough || 0 , ... removed . map ( event => Number ( event ? . revision ) || 0 ) ) }
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 22:31:04 +09:00
await removeRetiredBoardVersions ( retiredVersions ) ;
const realtimePage = { global : { ... ( record . global || { } ) , cloudProfile : null , cloudRevision : nextRevision } , metas : { } , states : { } } ; for ( const id of visibleBoardIds ) { const row = changedBoards . get ( id ) ; if ( ! row ) continue ; if ( metaIds . includes ( id ) && row . meta ) realtimePage . metas [ id ] = row . meta ; if ( row . state ) realtimePage . states [ id ] = completedProgressState ( row . state ) }
for ( const [ id , row ] of changedBoards ) if ( row . state ? . solved === true ) realtimeHub ? . releaseBoardClaim ( id , 'cleared' ) ; realtimeHub ? . broadcastClearEvents ( clearEvents ) ; if ( visibleBoardIds . size ) realtimeHub ? . broadcastWorldRevision ( nextRevision , visibleBoardIds , realtimePage ) ;
return { status : 200 , body : { revision : nextRevision , duplicate : false , rebased : baseRevision !== record . revision - 1 , clearEvents , latestEventRevision : record . clearEvents . at ( - 1 ) ? . revision || record . clearEventsDroppedThrough || 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 ) {
2026-08-01 22:31:04 +09:00
const player = await authenticatedPlayer ( req ) , ip = requestIp ( req ) ; rateLimit ( ` push: ${ player . playerId } ` , 60 , 60_000 ) ; const body = await readJsonBody ( req ) ; if ( Array . isArray ( body . metas ) && body . metas . length ) { const cost = body . metas . length ; rateLimit ( ` meta-push: ${ player . playerId } ` , 16 , 60_000 , cost ) ; rateLimit ( ` meta-push-ip: ${ ip } ` , 32 , 60_000 , cost ) ; rateLimit ( 'meta-push-global' , 32 , 60_000 , cost ) } const 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 ) {
2026-08-01 22:31:04 +09:00
const identity = await pollingIdentity ( req ) ; rateLimit ( ` realtime-connect: ${ identity . playerId } ` , 20 , 60_000 ) ; const result = realtimeHub ? . createPollingClient ( identity ) ; if ( ! result ) throw Object . assign ( new Error ( 'Realtime capacity has been reached' ) , { status : 503 } ) ; return json ( res , 200 , result ) ;
2026-08-01 16:06:14 +09:00
}
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 ) {
2026-08-01 22:31:04 +09:00
const identity = await pollingIdentity ( req ) ; rateLimit ( ` realtime-send: ${ identity . playerId } ` , 600 , 60_000 ) ; const 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 ) ;
2026-08-01 16:06:14 +09:00
}
async function handleRealtimePoll ( req , res , url ) {
2026-08-01 22:31:04 +09:00
const identity = await pollingIdentity ( req ) , wait = Math . max ( 0 , Math . min ( 25_000 , Math . floor ( finiteNumber ( url . searchParams . get ( 'wait' ) , 20_000 ) ) ) ) , result = await realtimeHub ? . pollPollingClient ( identity , String ( url . searchParams . get ( 'presenceId' ) || '' ) , url . searchParams . get ( 'after' ) , wait ) ; if ( ! result ) throw Object . assign ( new Error ( 'Realtime session expired' ) , { status : 410 } ) ; return json ( res , 200 , result ) ;
2026-08-01 16:06:14 +09:00
}
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 ) ) ;
2026-08-01 22:31:04 +09:00
let file = null , stat = null , resolvedPathname = pathname ; const publicReal = await fsp . realpath ( PUBLIC _ROOT ) , withinPublic = target => { const relative = path . relative ( publicReal , target ) ; return relative === '' || relative !== '..' && ! relative . startsWith ( ` .. ${ path . sep } ` ) && ! path . isAbsolute ( relative ) } ;
for ( const candidate of candidates ) { const relative = candidate . replace ( /^\/+/ , '' ) , top = relative . split ( '/' ) [ 0 ] ; if ( ! PUBLIC _STATIC _ROOTS . has ( top ) ) continue ; const target = path . resolve ( PUBLIC _ROOT , relative ) ; if ( path . basename ( target ) . startsWith ( '.' ) ) continue ; try { const realTarget = await fsp . realpath ( target ) ; if ( ! withinPublic ( realTarget ) ) continue ; const targetStat = await fsp . stat ( realTarget ) ; if ( targetStat . isFile ( ) ) { file = realTarget ; stat = targetStat ; resolvedPathname = candidate ; break } } catch ( error ) { if ( error . code !== 'ENOENT' ) throw error } }
2026-08-01 16:06:14 +09:00
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 22:31:04 +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:'auto'});})(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 ,
2026-08-01 22:31:04 +09:00
'cache-control' : ext === '.html' || resolvedPathname === '/runtime-config.js' ? 'no-store' : ext === '.js' || ext === '.css' ? 'public, max-age=300, stale-while-revalidate=3600' : 'public, max-age=86400' ,
2026-07-31 12:54:46 +09:00
'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 ( ) {
2026-08-01 22:31:04 +09:00
const server = 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 ) { rateLimit ( ` api: ${ requestIp ( req ) } ` , 600 , 60_000 ) ; url . pathname = url . pathname . slice ( apiOffset ) ; await handleApi ( req , res , url ) } else await serveStatic ( req , res , url ) } catch ( error ) { if ( ( error . status || 500 ) >= 500 ) console . error ( error ) ; else if ( process . env . LINK _FIELD _LOG _CLIENT _ERRORS === '1' ) console . warn ( ` ${ req . method } ${ req . url } : ${ error . message } ` ) ; if ( ! res . headersSent ) { if ( error . retryAfter ) res . setHeader ( 'retry-after' , String ( error . retryAfter ) ) ; json ( res , error . status || 500 , { error : error . status ? error . message : 'Internal server error' , ... ( error . boardId ? { boardId : error . boardId } : { } ) , serverTime : serverTime ( ) } ) } else res . destroy ( ) } } ) ;
server . requestTimeout = 35_000 ; server . headersTimeout = 10_000 ; server . keepAliveTimeout = 5_000 ; server . maxRequestsPerSocket = 100 ;
return server ;
2026-08-01 16:06:14 +09:00
}
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' }
}
2026-08-01 22:31:04 +09:00
async function processLooksLikeServer ( pid ) {
if ( process . platform === 'linux' ) try { const command = ( await fsp . readFile ( ` /proc/ ${ pid } /cmdline ` ) ) . toString ( 'utf8' ) . replace ( /\0/g , ' ' ) , cwd = path . resolve ( await fsp . readlink ( ` /proc/ ${ pid } /cwd ` ) ) , entryMatches = command . includes ( path . basename ( _ _filename ) ) || command . includes ( 'service-control.js' ) ; return entryMatches && ( command . includes ( ROOT ) || cwd === ROOT ) } catch ( error ) { if ( error ? . code === 'ENOENT' ) return false }
return null ;
}
async function readInstanceLock ( ) { try { const raw = ( await fsp . readFile ( INSTANCE _LOCK _FILE , 'utf8' ) ) . trim ( ) ; if ( /^\d+$/ . test ( raw ) ) return { pid : Number ( raw ) , legacy : true , heartbeatAt : 0 } ; const value = JSON . parse ( raw ) ; return value && typeof value === 'object' ? value : null } catch ( error ) { if ( error ? . code === 'ENOENT' ) return null ; return null } }
async function refreshInstanceLock ( ) {
if ( ! instanceLockToken ) return ; const current = await readInstanceLock ( ) ; if ( current ? . pid !== process . pid || current ? . token !== instanceLockToken ) return ;
await atomicWriteJson ( INSTANCE _LOCK _FILE , { pid : process . pid , token : instanceLockToken , root : ROOT , heartbeatAt : serverTime ( ) } ) ;
}
2026-08-01 16:06:14 +09:00
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 ) ;
2026-08-01 22:31:04 +09:00
instanceLockToken = crypto . randomBytes ( 16 ) . toString ( 'hex' ) ; await handle . writeFile ( ` ${ JSON . stringify ( { pid : process . pid , token : instanceLockToken , root : ROOT , heartbeatAt : serverTime ( ) } )} \n ` , 'utf8' ) ;
2026-08-01 16:06:14 +09:00
await handle . close ( ) ;
2026-08-01 22:31:04 +09:00
instanceLockHeartbeatTimer = setInterval ( ( ) => { void refreshInstanceLock ( ) . catch ( error => console . warn ( ` Instance lock heartbeat warning: ${ error . message } ` ) ) } , 2000 ) ; instanceLockHeartbeatTimer . unref ? . ( ) ;
2026-08-01 16:06:14 +09:00
return process . pid ;
} catch ( error ) {
if ( error ? . code !== 'EEXIST' ) throw error ;
2026-08-01 22:31:04 +09:00
const lock = await readInstanceLock ( ) , existing = Number ( lock ? . pid ) || 0 ; let active = false ; if ( processIsRunning ( existing ) ) { const identity = await processLooksLikeServer ( existing ) ; active = identity === true || identity == null && ( ! lock ? . heartbeatAt || serverTime ( ) - lock . heartbeatAt < 10_000 ) }
if ( active ) throw Object . assign ( new Error ( ` Another LinkField server is already running (PID ${ existing } ). Stop it before starting a second shared world. ` ) , { code : 'EALREADY' } ) ;
2026-08-01 16:06:14 +09:00
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 ( ) {
2026-08-01 22:31:04 +09:00
clearInterval ( instanceLockHeartbeatTimer ) ; instanceLockHeartbeatTimer = null ; try { const owner = await readInstanceLock ( ) ; if ( owner ? . pid === process . pid && ( ! owner . token || owner . token === instanceLockToken ) ) await fsp . unlink ( INSTANCE _LOCK _FILE ) } catch ( error ) { if ( error ? . code !== 'ENOENT' ) console . warn ( ` Instance lock cleanup warning: ${ error . message } ` ) } finally { instanceLockToken = null }
2026-08-01 16:06:14 +09:00
}
2026-08-01 22:31:04 +09:00
async function createAutomaticWorldBackup ( ) { let failure = null ; for ( let attempt = 0 ; attempt < 3 ; attempt ++ ) { try { return await createWorldBackup ( DATA _DIR , BACKUP _DIR , { retain : 7 , appVersion : BuildMeta . APP _VERSION } ) } catch ( error ) { failure = error ; if ( attempt < 2 ) await new Promise ( resolve => setTimeout ( resolve , 250 ) ) } } throw failure }
2026-08-01 16:06:14 +09:00
async function main ( ) {
await acquireInstanceLock ( ) ;
2026-08-01 22:31:04 +09:00
let server = null ;
2026-08-01 16:06:14 +09:00
try {
2026-08-01 22:31:04 +09:00
await fsp . mkdir ( DATA _DIR , { recursive : true , mode : 0o700 } ) ;
await fsp . mkdir ( PUBLIC _ROOT , { recursive : true , mode : 0o755 } ) ;
await recoverPendingWorldCommit ( ) ;
server = createApplicationServer ( ) ;
realtimeHub = createRealtimeHub ( { server , authenticate : authenticateRealtime , getBoardInfo : realtimeBoardInfo , maxClients : 500 , maxClientsPerPlayer : 5 } ) ;
2026-08-01 16:06:14 +09:00
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 } ` ) ;
2026-08-01 22:31:04 +09:00
void withWorldQueue ( ( ) => collectRetiredBoardVersions ( ) ) . then ( count => { if ( count ) console . log ( ` Removed ${ count } retired board version ${ count === 1 ? '' : 's' } . ` ) } ) . catch ( error => console . warn ( ` Retired board cleanup warning: ${ error . message } ` ) ) ;
const maintenanceInterval = setInterval ( ( ) => { void withWorldQueue ( ( ) => collectRetiredBoardVersions ( ) ) . catch ( error => console . warn ( ` Retired board cleanup warning: ${ error . message } ` ) ) } , 60 * 60 * 1000 ) ; maintenanceInterval . unref ? . ( ) ;
let backupTimer = null , backupInterval = null ; if ( BACKUPS _ENABLED ) { const backup = ( ) => createAutomaticWorldBackup ( ) . then ( result => console . log ( ` Shared world backup: ${ result . destination } ` ) ) . catch ( error => console . warn ( ` Shared world backup warning: ${ error . message } ` ) ) ; backupTimer = setTimeout ( backup , 5000 ) ; backupTimer . unref ? . ( ) ; backupInterval = setInterval ( backup , 24 * 60 * 60 * 1000 ) ; backupInterval . unref ? . ( ) }
return { server , realtimeHub , port : listening . port , host : HOST , dataDir : DATA _DIR , backupDir : BACKUP _DIR , backupTimer , backupInterval , maintenanceInterval , publicRoot : PUBLIC _ROOT , portFile : PUBLIC _BRIDGE _PORT _FILE , apacheBridge } ;
2026-08-01 16:06:14 +09:00
} catch ( error ) {
realtimeHub ? . close ( ) ;
realtimeHub = null ;
2026-08-01 22:31:04 +09:00
if ( server ? . listening ) { server . closeAllConnections ? . ( ) ; await new Promise ( resolve => server . close ( ( ) => resolve ( ) ) ) }
2026-08-01 16:06:14 +09:00
await fsp . unlink ( PUBLIC _BRIDGE _PORT _FILE ) . catch ( ( ) => { } ) ;
await releaseInstanceLock ( ) ;
throw error ;
}
}
async function closeApplication ( result ) {
if ( ! result ) return ;
2026-08-01 22:31:04 +09:00
clearTimeout ( result . backupTimer ) ; clearInterval ( result . backupInterval ) ; clearInterval ( result . maintenanceInterval ) ;
2026-08-01 16:06:14 +09:00
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 } ) ;
}