'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'); const { createHttpRouter } = require('./server/http-router'); const { createAuthenticator } = require('./server/auth'); const { createJsonRepository } = require('./server/json-repository'); const { installApacheBridge } = require('./server/apache-bridge'); const { createPlayerService } = require('./server/player-service'); const { createWorldBackup } = require('./server/world-backup'); const BuildMeta = require('./build-meta'); const SharedContracts = require('./shared-contracts'); 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)])); const STARTER_LINE_COLOR_IDS=Object.freeze([...STORE_CATALOG.values()].filter(item=>item.lineColor&&!item.aurora&&item.cost===5000).map(item=>item.id)); const ROOT = __dirname; const PUBLIC_ROOT = path.resolve(process.env.LINK_FIELD_PUBLIC_DIR || ROOT); const PRODUCTION_DATA_DIR = path.resolve(String(process.env.LINK_FIELD_WORLD_DIR || '/link-field/world')); const TEST_DATA_ROOT = String(process.env.LINK_FIELD_TEST_DATA_ROOT || '').trim(); const DATA_DIR = TEST_DATA_ROOT ? path.join(path.resolve(TEST_DATA_ROOT), 'world') : PRODUCTION_DATA_DIR; const 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()); const INSTANCE_LOCK_FILE = path.join(DATA_DIR, 'server.pid'); const WORLD_FILE = path.join(DATA_DIR, 'shared-world.json'); const WORLD_COMMIT_FILE = path.join(DATA_DIR, 'shared-world.commit.json'); const WORLD_BOARDS_DIR = path.join(DATA_DIR, 'shared-world.boards'); const DEFAULT_PORT = 8080; const FALLBACK_PORT_START = 3000; const FALLBACK_PORT_COUNT = 20; function commandLineOption(name) { const direct = process.argv.find(argument => argument.startsWith(`${name}=`)); if (direct) return direct.slice(name.length + 1); const index = process.argv.indexOf(name); return index >= 0 ? process.argv[index + 1] : undefined; } function parsePort(value, fallback = DEFAULT_PORT) { if (value == null || String(value).trim() === '') return fallback; const port = Number(value); if (!Number.isSafeInteger(port) || port < 0 || port > 65535) throw new Error(`Invalid server port: ${value}`); return port; } function uniquePorts(values) { return [...new Set(values.filter(port => Number.isSafeInteger(port) && port >= 0 && port <= 65535))]; } function defaultPortCandidates(preferredPort = DEFAULT_PORT) { const fallbackPorts = Array.from({length:FALLBACK_PORT_COUNT}, (_, index) => FALLBACK_PORT_START + index); return uniquePorts([preferredPort, ...fallbackPorts, 0]); } const CLI_HOST = commandLineOption('--host'); const CLI_PORT = commandLineOption('--port'); const HOST = CLI_HOST || process.env.HOST || '127.0.0.1'; const PORT_SOURCE = CLI_PORT ?? process.env.LINK_FIELD_PORT ?? process.env.PORT; const PORT_EXPLICIT = PORT_SOURCE != null && String(PORT_SOURCE).trim() !== ''; const PORT_STRICT = process.argv.includes('--strict-port') || ['1','true','on','yes'].includes(String(process.env.LINK_FIELD_STRICT_PORT || '').trim().toLowerCase()); const PORT = parsePort(PORT_SOURCE, DEFAULT_PORT); const APACHE_BRIDGE_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; const CHANGE_HISTORY_LIMIT = 256; const CLEAR_EVENT_LIMIT = 64; const MAX_PLAYER_PURCHASES = 10000; const MAX_PLAYER_RECORDS = 100000; 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; const MUTATION_RE=/^[A-Za-z0-9_-]{8,96}$/; const BOARD_RE = SharedContracts.BOARD_ID_RE; 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', }; 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']); let worldQueue = Promise.resolve(); let worldCache=null; const playerQueues = new Map(); let playerCapacityQueue=Promise.resolve(); let realtimeHub = null; let playerRecordCount = null; let instanceLockToken=null,instanceLockHeartbeatTimer=null; const jsonRepository = createJsonRepository({fsp,crypto}); 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} 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=''){ return SharedContracts.cleanPlayerName(value,fallback); } function defaultPlayerName(playerId){return `旅人-${String(playerId).slice(0,4).toUpperCase()}`} function cleanId(value,maxLength=64){return SharedContracts.cleanContractId(value,maxLength)} function storeItem(itemId){return STORE_CATALOG.get(String(itemId||''))||null} function normalizePlayerPurchases(raw){ return SharedContracts.normalizePlayerPurchases(raw,{resolveItem:storeItem,maxPurchases:MAX_PLAYER_PURCHASES}); } 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} 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)}} 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`)} async function atomicWriteJson(file,value){return jsonRepository.write(file,value)} async function readPlayer(playerId){ 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} catch(error){if(error.code==='ENOENT')throw Object.assign(new Error('Cloud profile not found'),{status:404});throw error} } 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}} async function readWorld(){ if(worldCache)return worldCache; try{ const value=JSON.parse(await fsp.readFile(WORLD_FILE,'utf8'));if(!value||typeof value!=='object')throw new Error('Invalid shared world'); if(value.global?.worldGeneration!==BuildMeta.WORLD_GENERATION)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} } 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}} 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(){ 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} let removed=0; for(const name of names){ const match=/^(B(?:0|[1-9][0-9]*))\.([0-9]+)\.json$/.exec(name);if(!match)continue; const revision=Number(match[2]);if(world.boardVersions[match[1]]===revision)continue; await fsp.unlink(path.join(WORLD_BOARDS_DIR,name)).catch(error=>{if(error.code!=='ENOENT')throw error});removed++; } return removed; } async function 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}`)}); } } 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{if(error.code!=='ENOENT')throw error});return false; } if(commit.nextPlayer?.playerId)await writePlayerRecord(commit.nextPlayer); await atomicWriteJson(WORLD_FILE,commit.world);worldCache=commit.world; }else if(current.revision===commit.revision&&commit.nextPlayer?.playerId)await writePlayerRecord(commit.nextPlayer); await fsp.unlink(WORLD_COMMIT_FILE).catch(error=>{if(error.code!=='ENOENT')throw error});return true; } async function commitWorldMutation(world,{nextPlayer=null,previousPlayer=null,changedBoardIds=[]}={}){ const commit={revision:world.revision,world,nextPlayer,previousPlayer,changedBoardIds:[...changedBoardIds],preparedAt:serverTime()}; await atomicWriteJson(WORLD_COMMIT_FILE,commit); if(nextPlayer)await writePlayerRecord(nextPlayer); await atomicWriteJson(WORLD_FILE,world);worldCache=world; await fsp.unlink(WORLD_COMMIT_FILE).catch(error=>{if(error.code!=='ENOENT')throw error}); } const authenticator=createAuthenticator({playerPattern:PLAYER_RE,tokenPattern:TOKEN_RE,readPlayer,hashToken:tokenHash,safeEqual:safeEqualHex}); function authenticate(req){return authenticator.parse(req)} async function authenticatedPlayer(req){return authenticator.player(req)} 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})}} function cellKey(cell){return `${cell[0]},${cell[1]}`} function normalizedRouteKey(cells){const forward=cells.map(cellKey).join('|'),reverse=[...cells].reverse().map(cellKey).join('|');return forwardmeta.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(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}`); 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=indexpath.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; } function validateMeta(meta){ if(!meta||typeof meta!=='object'||!BOARD_RE.test(meta.id))badRequest('Invalid board metadata');const chunks=Array.isArray(meta.chunks)?meta.chunks:[]; if(!chunks.length||chunks.length>50)badRequest(`Invalid chunks for ${meta.id}`);const seen=new Set();for(const cell of chunks){if(!Array.isArray(cell)||cell.length!==2||!Number.isSafeInteger(cell[0])||!Number.isSafeInteger(cell[1])||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}`); if(!Number.isSafeInteger(meta.x)||!Number.isSafeInteger(meta.y)||Math.abs(meta.x)>100000000||Math.abs(meta.y)>100000000)badRequest(`Invalid board coordinate for ${meta.id}`); if(!Number.isSafeInteger(meta.seed)||meta.seed<0||meta.seed>0xffffffff)badRequest(`Invalid seed for ${meta.id}`); if(!Number.isInteger(meta.level)||meta.level<1||meta.level>10||!Number.isInteger(meta.targetLevel)||meta.targetLevel<1||meta.targetLevel>10)badRequest(`Invalid level for ${meta.id}`);const sectionRange=AppLogic.sectionCountRange(meta.targetLevel);if(chunks.lengthsectionRange.max||meta.level<=3&&chunks.length!==1)badRequest(`Invalid section count for ${meta.id}`); if(meta.entrySide!=null&&!['N','S','W','E'].includes(meta.entrySide))badRequest(`Invalid entry side for ${meta.id}`); if(meta.sealedSides!=null&&(!Array.isArray(meta.sealedSides)||meta.sealedSides.some(side=>!['N','S','W','E'].includes(side))))badRequest(`Invalid sealed sides for ${meta.id}`); const clean=JSON.parse(JSON.stringify(meta));validatePuzzle(clean); const derivedLevel=PuzzleCore.solverDifficulty(clean.puzzle,clean.targetLevel);if(clean.level!==derivedLevel)badRequest(`Unverified difficulty for ${clean.id}`); clean.level=derivedLevel;clean.puzzle.level=derivedLevel;clean.puzzle.difficulty=derivedLevel;clean.puzzle.complexity=PuzzleCore.solutionComplexity(clean.puzzle);clean.puzzle.interactionBurden=AppLogic.interactionBurden(clean.puzzle); delete clean.puzzle.solutionQuality;delete clean.puzzle.uniqueness; if(clean.targetLevel>=6){ const verification=PuzzleCore.verifyPuzzleUniqueness(clean.puzzle,{maxMs:1000,nodeCap:100000,analyzeQuality:false}); if(verification.status!=='unique'||verification.signature!==PuzzleCore.puzzleSignature(clean.puzzle))badRequest(`Puzzle is not server-verified: ${clean.id}`); clean.puzzle.uniqueness={status:'unique',signature:verification.signature,ruleVersion:verification.ruleVersion,nodes:verification.nodes}; } 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}; } 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}`); 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({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}; } 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={}; 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); clean.specialMechanicsSeen=Array.isArray(clean.specialMechanicsSeen)?SharedContracts.normalizeSpecialMechanics(clean.specialMechanicsSeen):SharedContracts.normalizeSpecialMechanics(prior.specialMechanicsSeen); if(Array.isArray(source.specialMechanicsSeen))clean.specialMechanicsSeen=SharedContracts.normalizeSpecialMechanics([...(prior.specialMechanicsSeen||[]),...source.specialMechanicsSeen]); clean.updatedAt=serverTime();return clean; } const STORE_CHANCE=1/10,STORE_PRICE_VERSION=1,EXPANSION_GRANT_TTL_MS=10*60*1000,MAX_NEW_BOARDS_PER_GRANT=8; 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))), 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); } 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){ 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:[]}, [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){ 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}; } 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;indexreadWorldBoard(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;indexundefined,()=>undefined);return run} 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; } 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){ const direct=Array.isArray(store?.cell)?store.cell:null,path=store&&state?.paths?.[store.pathIndex],cell=direct||path?.cells?.[store.cellIndex]; 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'); 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'); 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(availablepurchase.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)} 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, starterLineColor:starterLineColorForPlayer, boardPattern:BOARD_RE }); async function handleCloudStatus(_req,res){ 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()}); } async function handleCloudSession(req,res){ 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); } async function handleCloudProfile(req,res){ const auth=await authenticatedPlayer(req),body=await readJsonBody(req),result=await playerService.updateProfile(auth.playerId,body.name);return json(res,result.status,result.body); } async function handlePlayerState(req,res){ const player=await authenticatedPlayer(req),result=playerService.getState(player.record);return json(res,result.status,result.body); } async function handleGenerationBonus(req,res){ const auth=await authenticatedPlayer(req),body=await readJsonBody(req),result=await playerService.awardGenerationBonus(auth.playerId,String(body.boardId||''));return json(res,result.status,result.body); } async function handlePurchase(req,res){ const auth=await authenticatedPlayer(req),body=await readJsonBody(req),result=await playerService.purchase(auth.playerId,String(body.boardId||''),String(body.itemId||''));return json(res,result.status,result.body); } async function pullCloudWorldService(player,parameters){ const record=await readWorld(),since=Math.max(0,Math.floor(finiteNumber(parameters.get('since'),0))),eventsSince=Math.max(0,Math.floor(finiteNumber(parameters.get('eventsSince'),0))),changed=since!==record.revision, clearEvents=record.clearEvents.filter(event=>(event.revision||0)>eventsSince),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()}}; const cursor=Math.max(0,Math.floor(finiteNumber(parameters.get('cursor'),0))),at=Math.max(0,Math.floor(finiteNumber(parameters.get('at'),record.revision)));if(cursor&&at!==record.revision)return{status:409,body:{error:'World changed during paged pull',revision:record.revision,serverTime:serverTime()}}; const delta=cloudDeltaSince(record,since),fullSnapshot=!delta,metaIds=delta?.metaIds||new Set(Object.keys(record.boardVersions)),stateIds=delta?.stateIds||new Set(Object.keys(record.boardVersions)),ids=[...new Set([...metaIds,...stateIds])].filter(id=>Number.isSafeInteger(record.boardVersions[id])).sort((a,b)=>Number(a.slice(1))-Number(b.slice(1))),pageIds=ids.slice(cursor,cursor+CLOUD_PAGE_LIMIT),nextCursor=cursor+pageIds.lengthwithWorldQueue(async()=>{ 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.expiresAtMath.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{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]} 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; 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}}} 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} 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; record.changes.push({revision:nextRevision,metaIds:metas.map(meta=>meta.id),stateIds:states.map(row=>row.id),deleted});if(record.changes.length>CHANGE_HISTORY_LIMIT)record.changes.splice(0,record.changes.length-CHANGE_HISTORY_LIMIT); record.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))} await commitWorldMutation(record,{nextPlayer:clearEvents.length?playerRecord:null,previousPlayer:clearEvents.length?previousPlayerRecord:null,changedBoardIds:changedBoards.keys()}); 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}}; })); } async function handleCloudPull(req,res,url){ const player=await authenticatedPlayer(req),result=await pullCloudWorldService(player,url.searchParams);return json(res,result.status,result.body); } async function handleCloudPush(req,res){ const player=await authenticatedPlayer(req),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); } async function pollingIdentity(req){ const player=await authenticatedPlayer(req);return{playerId:player.playerId,name:player.record.name}; } async function handleRealtimeConnect(req,res){ const identity=await pollingIdentity(req);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); } async function handleRealtimeClaim(req,res){ const identity=await pollingIdentity(req),body=await readJsonBody(req),result=await realtimeHub?.claimBoard(identity,body.boardId,body.presenceId);if(!result)throw Object.assign(new Error('Realtime service unavailable'),{status:503});return json(res,200,result); } async function handleRealtimeSend(req,res){ const identity=await pollingIdentity(req);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); } async function handleRealtimePoll(req,res,url){ 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); } 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()}); } 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) .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); async function handleApi(req,res,url){return apiRouter.dispatch(req,res,url)} async function serveStatic(req,res,url){ 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'})} if(pathname==='/'||pathname.endsWith('/')||pathname.endsWith('/debug-items'))pathname='/index.html'; const candidates=[pathname];for(let offset=pathname.indexOf('/',1);offset>=0;offset=pathname.indexOf('/',offset+1))candidates.push(pathname.slice(offset)); let file=null,stat=null,resolvedPathname=pathname;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}} if(!file||!stat)return json(res,404,{error:'Not found'}); if(stat.isDirectory())return json(res,403,{error:'Directory listing is disabled'}); 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; res.writeHead(200,{ 'content-type':MIME[ext]||'application/octet-stream', 'content-length':body?body.length:stat.size, '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', '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); } 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() { 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; } function displayServerUrl(host, port) { const displayHost = host === '0.0.0.0' || host === '::' ? '127.0.0.1' : host; return `http://${displayHost}:${port}`; } function processIsRunning(pid){ if(!Number.isSafeInteger(pid)||pid<=0)return false; try{process.kill(pid,0);return true}catch(error){return error?.code==='EPERM'} } async function 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()}); } 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); instanceLockToken=crypto.randomBytes(16).toString('hex');await handle.writeFile(`${JSON.stringify({pid:process.pid,token:instanceLockToken,root:ROOT,heartbeatAt:serverTime()})}\n`,'utf8'); await handle.close(); instanceLockHeartbeatTimer=setInterval(()=>{void refreshInstanceLock().catch(error=>console.warn(`Instance lock heartbeat warning: ${error.message}`))},2000);instanceLockHeartbeatTimer.unref?.(); return process.pid; }catch(error){ if(error?.code!=='EEXIST')throw error; 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'}); 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(){ 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} } 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} async function main(){ await acquireInstanceLock(); let server=null; try { 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}); 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}`); 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}; } catch (error) { realtimeHub?.close(); realtimeHub=null; if(server?.listening){server.closeAllConnections?.();await new Promise(resolve=>server.close(()=>resolve()))} await fsp.unlink(PUBLIC_BRIDGE_PORT_FILE).catch(()=>{}); await releaseInstanceLock(); throw error; } } async function closeApplication(result){ if(!result)return; clearTimeout(result.backupTimer);clearInterval(result.backupInterval);clearInterval(result.maintenanceInterval); 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}); }