This commit is contained in:
33333-33333 2026-08-01 16:06:14 +09:00
commit 4c4e767ec6
73 changed files with 3502 additions and 741 deletions

273
server.js
View file

@ -4,28 +4,61 @@ const http = require('http');
const fs = require('fs');
const fsp = fs.promises;
const path = require('path');
const os = require('os');
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 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 DEFAULT_DATA_ROOT = process.env.LOCALAPPDATA || path.join(os.homedir(), '.local', 'share');
const DATA_DIR = path.resolve(process.env.BEND_FIELD_DATA_DIR || path.join(DEFAULT_DATA_ROOT, 'BendField', 'cloud-data'));
const PUBLIC_ROOT = path.resolve(process.env.LINK_FIELD_PUBLIC_DIR || ROOT);
const PRODUCTION_DATA_DIR = path.resolve('/link-field/world');
const TEST_DATA_ROOT = String(process.env.LINK_FIELD_TEST_DATA_ROOT || '').trim();
const DATA_DIR = TEST_DATA_ROOT ? path.join(path.resolve(TEST_DATA_ROOT), 'world') : PRODUCTION_DATA_DIR;
const INSTANCE_LOCK_FILE = path.join(DATA_DIR, 'server.pid');
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 HOST = process.env.HOST || '0.0.0.0';
const PORT = Number(process.env.PORT || 8080);
const DEFAULT_PORT = 8080;
const FALLBACK_PORT_START = 3000;
const FALLBACK_PORT_COUNT = 20;
function commandLineOption(name) {
const direct = process.argv.find(argument => argument.startsWith(`${name}=`));
if (direct) return direct.slice(name.length + 1);
const index = process.argv.indexOf(name);
return index >= 0 ? process.argv[index + 1] : undefined;
}
function parsePort(value, fallback = DEFAULT_PORT) {
if (value == null || String(value).trim() === '') return fallback;
const port = Number(value);
if (!Number.isSafeInteger(port) || port < 0 || port > 65535) throw new Error(`Invalid server port: ${value}`);
return port;
}
function uniquePorts(values) {
return [...new Set(values.filter(port => Number.isSafeInteger(port) && port >= 0 && port <= 65535))];
}
function defaultPortCandidates(preferredPort = DEFAULT_PORT) {
const fallbackPorts = Array.from({length:FALLBACK_PORT_COUNT}, (_, index) => FALLBACK_PORT_START + index);
return uniquePorts([preferredPort, ...fallbackPorts, 0]);
}
const CLI_HOST = commandLineOption('--host');
const CLI_PORT = commandLineOption('--port');
const HOST = CLI_HOST || process.env.HOST || '127.0.0.1';
const PORT_SOURCE = CLI_PORT ?? process.env.LINK_FIELD_PORT ?? process.env.PORT;
const PORT_EXPLICIT = PORT_SOURCE != null && String(PORT_SOURCE).trim() !== '';
const PORT_STRICT = process.argv.includes('--strict-port') || ['1','true','on','yes'].includes(String(process.env.LINK_FIELD_STRICT_PORT || '').trim().toLowerCase());
const PORT = parsePort(PORT_SOURCE, DEFAULT_PORT);
const APACHE_BRIDGE_ENABLED = !['0','false','off','no'].includes(String(process.env.LINK_FIELD_APACHE_BRIDGE || '').trim().toLowerCase());
const PUBLIC_BRIDGE_PORT_FILE = path.join(PUBLIC_ROOT, '.linkfield-port');
const MAX_BODY_BYTES = 64 * 1024 * 1024;
const MAX_BOARDS_PER_PUSH = 10000;
const MAX_PATHS_PER_BOARD = 512;
@ -71,19 +104,25 @@ function normalizePlayerPurchases(raw){
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 publicPlayerState(record){const earnedScore=playerEarnedScore(record),spentScore=playerSpentScore(record);return{revision:Number.isSafeInteger(record?.economyRevision)?record.economyRevision:0,purchases:normalizePlayerPurchases(record?.purchases),earnedScore,spentScore,availableScore:Math.max(0,earnedScore-spentScore),updatedAt:finiteNumber(record?.updatedAt,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);return value}
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:{},global:{schema:BuildMeta.SAVE_SCHEMA,appVersion:BuildMeta.APP_VERSION,generatorVersion:BuildMeta.GENERATOR_VERSION,worldGeneration:BuildMeta.WORLD_GENERATION,nextId:1,solved:0,earnedScore:0,specialMechanicsSeen:[],updatedAt:now,cloudRevision:0},boardVersions:{},occupancy:{},changes:[],clearEvents:[],createdAt:now,updatedAt:now}}
async function readWorld(){
try{const value=JSON.parse(await fsp.readFile(WORLD_FILE,'utf8'));if(!value||typeof value!=='object')throw new Error('Invalid shared world');value.revision=Number.isSafeInteger(value.revision)?value.revision:0;value.rowRevision=Number.isSafeInteger(value.rowRevision)?value.rowRevision:Math.max(0,serverTime()*1000);value.boardVersions=value.boardVersions&&typeof value.boardVersions==='object'?value.boardVersions:{};value.changes=Array.isArray(value.changes)?value.changes:[];value.clearEvents=Array.isArray(value.clearEvents)?value.clearEvents:[];value.expansionGrants=value.expansionGrants&&typeof value.expansionGrants==='object'?value.expansionGrants:{};value.global=value.global&&typeof value.global==='object'?value.global:{};return value}
catch(error){if(error.code==='ENOENT')return emptyWorld();throw error}
try{
const value=JSON.parse(await fsp.readFile(WORLD_FILE,'utf8'));if(!value||typeof value!=='object')throw new Error('Invalid shared world');
if(value.global?.worldGeneration!==BuildMeta.WORLD_GENERATION)return emptyWorld();
value.revision=Number.isSafeInteger(value.revision)?value.revision:0;value.rowRevision=Number.isSafeInteger(value.rowRevision)?value.rowRevision:Math.max(0,serverTime()*1000);value.boardVersions=value.boardVersions&&typeof value.boardVersions==='object'?value.boardVersions:{};value.changes=Array.isArray(value.changes)?value.changes:[];value.clearEvents=Array.isArray(value.clearEvents)?value.clearEvents:[];value.expansionGrants=value.expansionGrants&&typeof value.expansionGrants==='object'?value.expansionGrants:{};value.global=value.global&&typeof value.global==='object'?value.global:{};return value
}catch(error){if(error.code==='ENOENT')return emptyWorld();throw error}
}
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){
@ -150,7 +189,27 @@ function validatePuzzle(meta){
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}`);
return{valid,gates,warpMap,crossingSet,solutionKeys:puzzle.solution.map(route=>normalizedRouteKey(route.cells)).sort()};
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;
}
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:[];
@ -174,8 +233,8 @@ function validateMeta(meta){
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>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}`);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){const solvedKeys=paths.map(route=>route.endGate==null?'':normalizedRouteKey(route.cells)).sort();if(solvedKeys.length!==puzzle.solutionKeys.length||solvedKeys.some((key,index)=>!key||key!==puzzle.solutionKeys[index]))badRequest(`Solved state does not match puzzle 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<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}`)
return state;
}
function worldMetaFingerprint(meta){const clean=JSON.parse(JSON.stringify(meta));delete clean.rev;delete clean.revAuthor;delete clean.sealedSides;return JSON.stringify(clean)}
@ -191,8 +250,10 @@ 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))),
selected=[...cursorPool.slice(0,12),...otherPool.slice(0,1)];
return PuzzleCore.shuffle(selected,PuzzleCore.rngFrom(PuzzleCore.hash32((seed>>>0)^0x6d2b79f5))).map(item=>item.id);
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),
@ -211,9 +272,11 @@ function boardTouchesWorld(meta,rows){const occupied=new Set();for(const row of
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=[];return{state:stable,clearEvent:null};
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};
}
if(!wantsSolved)return{state:base,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}};
}
@ -241,7 +304,7 @@ function storeItemPriceLocation(meta,state,store){
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:[];if(!itemIds.includes(item.id))badRequest('Item is not sold by this store');
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};
}
@ -284,12 +347,15 @@ const playerService=createPlayerService({
findPurchase:purchaseForStoreItem,
assertAffordable:assertPlayerCanAfford,
createPurchase:createPlayerPurchase,
starterLineColor:starterLineColorForPlayer,
boardPattern:BOARD_RE
});
async function handleCloudStatus(_req,res){
return json(res,200,{available:true,sharedWorld:true,realtime:true,reactions:true,playerEconomy:true,sharedItems:false,claimTtlMs:realtimeHub?.claimTtlMs||300000,serverTime:serverTime()});
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 body=await readJsonBody(req),result=await playerService.createSession(body.name);return json(res,result.status,result.body);
}
@ -308,10 +374,10 @@ async function handlePurchase(req,res){
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);
if(!changed)return{status:200,body:{changed:false,revision:record.revision,latestEventRevision:record.clearEvents.at(-1)?.revision||0,clearEvents,player:{id:player.playerId,name:player.record.name},serverTime:serverTime()}};
if(!changed)return{status:200,body:{changed:false,revision:record.revision,latestEventRevision:record.clearEvents.at(-1)?.revision||0,clearEvents,player:publicCloudPlayer(player.record),serverTime:serverTime()}};
const cursor=Math.max(0,Math.floor(finiteNumber(parameters.get('cursor'),0))),at=Math.max(0,Math.floor(finiteNumber(parameters.get('at'),record.revision)));if(cursor&&at!==record.revision)return{status:409,body:{error:'World changed during paged pull',revision:record.revision,serverTime:serverTime()}};
const delta=cloudDeltaSince(record,since),fullSnapshot=!delta,metaIds=delta?.metaIds||new Set(Object.keys(record.boardVersions)),stateIds=delta?.stateIds||new Set(Object.keys(record.boardVersions)),ids=[...new Set([...metaIds,...stateIds])].filter(id=>Number.isSafeInteger(record.boardVersions[id])).sort((a,b)=>Number(a.slice(1))-Number(b.slice(1))),pageIds=ids.slice(cursor,cursor+CLOUD_PAGE_LIMIT),nextCursor=cursor+pageIds.length<ids.length?cursor+pageIds.length:null,page=await publicWorldPage(record,pageIds,metaIds,stateIds);
return{status:200,body:{changed:true,revision:record.revision,latestEventRevision:record.clearEvents.at(-1)?.revision||0,clearEvents:cursor?[]:clearEvents,player:{id:player.playerId,name:player.record.name},serverTime:serverTime(),fullSnapshot,page:{...page,deleted:delta?[...delta.deleted]:[]},nextCursor}};
return{status:200,body:{changed:true,revision:record.revision,latestEventRevision:record.clearEvents.at(-1)?.revision||0,clearEvents:cursor?[]:clearEvents,player:publicCloudPlayer(player.record),serverTime:serverTime(),fullSnapshot,page:{...page,deleted:delta?[...delta.deleted]:[]},nextCursor}};
}
async function pushCloudWorldService(player,body){
return withPlayerQueue(player.playerId,()=>withWorldQueue(async()=>{
@ -323,15 +389,15 @@ async function pushCloudWorldService(player,body){
if(record.revision>0&&newMetaIds.length){const grant=record.expansionGrants?.[player.playerId];if(!grant||grant.expiresAt<serverTime())throw Object.assign(new Error('Expansion grant is required'),{status:403});if(newMetaIds.length>Math.min(MAX_NEW_BOARDS_PER_GRANT,grant.maxBoards||0))badRequest('Too many generated boards');const expectedStart=Math.max(1,Number(record.global?.nextId)||1),numbers=newMetaIds.map(id=>Number(id.slice(1))).sort((a,b)=>a-b);for(let i=0;i<numbers.length;i++)if(numbers[i]!==expectedStart+i)badRequest('Generated board ids are not contiguous');for(const id of newMetaIds.sort((a,b)=>Number(a.slice(1))-Number(b.slice(1))))addMetaToWorldOccupancy(changedBoards.get(id).meta,occupancy,{requireTouch:true});grant.maxBoards-=newMetaIds.length;if(grant.maxBoards<=0)delete record.expansionGrants[player.playerId]}
else for(const id of newMetaIds.sort((a,b)=>Number(a.slice(1))-Number(b.slice(1))))addMetaToWorldOccupancy(changedBoards.get(id).meta,occupancy);
const clearEvents=[];let solved=Math.max(0,Number(record.global?.solved)||0),earnedScore=Math.max(0,Number(record.global?.earnedScore)||0),starterRow=null;
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;if(firstSolve&&realtimeHub&&!realtimeHub.hasClaim(player.playerId,rawRow.id))throw Object.assign(new Error('Board claim is required'),{status:423});starterRow||=rawRow.id==='B0'?row:await readWorldBoard(record,'B0');const worldSeed=starterRow?.meta?.seed||0;const published=publicStateForWorld(incoming,row.state,playerRecord,nextRevision,rowRevision,row.meta,worldSeed);row.state=published.state;if(published.clearEvent){clearEvents.push(published.clearEvent);solved++;earnedScore=Math.min(Number.MAX_SAFE_INTEGER,earnedScore+published.clearEvent.scoreAwarded);playerRecord.earnedScore=Math.min(Number.MAX_SAFE_INTEGER,playerEarnedScore(playerRecord)+published.clearEvent.scoreAwarded);playerRecord.economyRevision=(playerRecord.economyRevision||0)+1;playerRecord.updatedAt=published.clearEvent.solvedAt;record.expansionGrants[player.playerId]={boardId:rawRow.id,expiresAt:published.clearEvent.solvedAt+EXPANSION_GRANT_TTL_MS,maxBoards:MAX_NEW_BOARDS_PER_GRANT}}}
for(const rawRow of states){if(!BOARD_RE.test(rawRow?.id))badRequest('Invalid board state');const row=await loadChangedBoard(rawRow.id);if(!row.meta)badRequest(`Board metadata is missing: ${rawRow.id}`);const incoming=validateStateRow(rawRow,row.meta),firstSolve=row.state?.solved!==true&&incoming.solved===true,unfinishedBoard=row.state?.solved!==true,existingBoard=existingIds.has(rawRow.id);if(record.revision>0&&existingBoard&&unfinishedBoard&&realtimeHub&&!realtimeHub.hasClaim(player.playerId,rawRow.id))throw Object.assign(new Error('Board claim is required'),{status:423});starterRow||=rawRow.id==='B0'?row:await readWorldBoard(record,'B0');const worldSeed=starterRow?.meta?.seed||0;const published=publicStateForWorld(incoming,row.state,playerRecord,nextRevision,rowRevision,row.meta,worldSeed);row.state=published.state;if(published.clearEvent){clearEvents.push(published.clearEvent);solved++;earnedScore=Math.min(Number.MAX_SAFE_INTEGER,earnedScore+published.clearEvent.scoreAwarded);playerRecord.earnedScore=Math.min(Number.MAX_SAFE_INTEGER,playerEarnedScore(playerRecord)+published.clearEvent.scoreAwarded);playerRecord.economyRevision=(playerRecord.economyRevision||0)+1;playerRecord.updatedAt=published.clearEvent.solvedAt;record.expansionGrants[player.playerId]={boardId:rawRow.id,expiresAt:published.clearEvent.solvedAt+EXPANSION_GRANT_TTL_MS,maxBoards:MAX_NEW_BOARDS_PER_GRANT}}}
for(const [id,row] of changedBoards){if(!row.meta)badRequest(`Board metadata is missing: ${id}`);if(!row.state)row.state=publicStateForWorld({},null,playerRecord,nextRevision,rowRevision,row.meta).state}
await fsp.mkdir(WORLD_BOARDS_DIR,{recursive:true,mode:0o700});for(const[id,row]of changedBoards){await atomicWriteJson(worldBoardVersionPath(id,nextRevision),row);record.boardVersions[id]=nextRevision}
record.occupancy=occupancy;record.global={...(record.global||{}),...sanitizeWorldGlobal(body.global,record)};const boardNumbers=Object.keys(record.boardVersions).map(id=>Number(id.slice(1))).filter(Number.isSafeInteger);record.global.nextId=Math.max(Number(record.global.nextId)||1,(boardNumbers.length?Math.max(...boardNumbers)+1:1));record.global.solved=solved;record.global.earnedScore=Math.max(0,earnedScore);record.revision=nextRevision;record.rowRevision=rowRevision;record.global.cloudRevision=nextRevision;record.updatedAt=serverTime();record.global.updatedAt=record.updatedAt;
record.changes.push({revision:nextRevision,metaIds:metas.map(meta=>meta.id),stateIds:states.map(row=>row.id),deleted});if(record.changes.length>CHANGE_HISTORY_LIMIT)record.changes.splice(0,record.changes.length-CHANGE_HISTORY_LIMIT);
record.clearEvents.push(...clearEvents);if(record.clearEvents.length>CLEAR_EVENT_LIMIT)record.clearEvents.splice(0,record.clearEvents.length-CLEAR_EVENT_LIMIT);
await commitWorldMutation(record,{nextPlayer:clearEvents.length?playerRecord:null,previousPlayer:clearEvents.length?previousPlayerRecord:null,changedBoardIds:changedBoards.keys()});
for(const event of clearEvents)realtimeHub?.releaseBoardClaim(event.id,'cleared');realtimeHub?.broadcastClearEvents(clearEvents);
return{status:200,body:{revision:nextRevision,clearEvents,latestEventRevision:record.clearEvents.at(-1)?.revision||0,player:{id:player.playerId,name:player.record.name},serverTime:record.updatedAt}};
for(const[id,row]of changedBoards)if(row.state?.solved===true)realtimeHub?.releaseBoardClaim(id,'cleared');realtimeHub?.broadcastClearEvents(clearEvents);
return{status:200,body:{revision:nextRevision,clearEvents,latestEventRevision:record.clearEvents.at(-1)?.revision||0,player:publicCloudPlayer(playerRecord),serverTime:record.updatedAt}};
}));
}
async function handleCloudPull(req,res,url){
@ -340,6 +406,24 @@ async function handleCloudPull(req,res,url){
async function handleCloudPush(req,res){
const player=await authenticatedPlayer(req),body=await readJsonBody(req),result=await pushCloudWorldService(player,body);return json(res,result.status,result.body);
}
async function pollingIdentity(req){
const player=await authenticatedPlayer(req);return{playerId:player.playerId,name:player.record.name};
}
async function handleRealtimeConnect(req,res){
const identity=await pollingIdentity(req),result=realtimeHub?.createPollingClient(identity);if(!result)throw Object.assign(new Error('Realtime service unavailable'),{status:503});return json(res,200,result);
}
async function handleRealtimeClaim(req,res){
const identity=await pollingIdentity(req),body=await readJsonBody(req),result=await realtimeHub?.claimBoard(identity,body.boardId,body.presenceId);if(!result)throw Object.assign(new Error('Realtime service unavailable'),{status:503});return json(res,200,result);
}
async function handleRealtimeSend(req,res){
const identity=await pollingIdentity(req),body=await readJsonBody(req),result=await realtimeHub?.handlePollingMessage(identity,String(body.presenceId||''),body.message,body.afterSequence);if(!result)throw Object.assign(new Error('Realtime session expired'),{status:410});return json(res,200,result);
}
async function handleRealtimePoll(req,res,url){
const identity=await pollingIdentity(req),result=realtimeHub?.pollPollingClient(identity,String(url.searchParams.get('presenceId')||''),url.searchParams.get('after'));if(!result)throw Object.assign(new Error('Realtime session expired'),{status:410});return json(res,200,result);
}
async function handleRealtimeDisconnect(req,res){
const identity=await pollingIdentity(req),body=await readJsonBody(req),disconnected=realtimeHub?.disconnectPollingClient(identity,String(body.presenceId||''))===true;return json(res,200,{disconnected,serverTime:serverTime()});
}
const apiRouter=createHttpRouter({notFound:(_req,res)=>json(res,404,{error:'API endpoint not found',serverTime:serverTime()})});
apiRouter
.add('GET','/api/cloud/status',handleCloudStatus)
@ -349,17 +433,23 @@ apiRouter
.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/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='/index.html';
const file=path.resolve(ROOT,`.${pathname}`);
if(!file.startsWith(`${ROOT}${path.sep}`)||path.basename(file).startsWith('.'))return json(res,403,{error:'Forbidden'});
let stat;try{stat=await fsp.stat(file)}catch(error){if(error.code==='ENOENT')return json(res,404,{error:'Not found'});throw error}
if(pathname==='/'||pathname.endsWith('/')||pathname.endsWith('/debug-items'))pathname='/index.html';
const candidates=[pathname];for(let offset=pathname.indexOf('/',1);offset>=0;offset=pathname.indexOf('/',offset+1))candidates.push(pathname.slice(offset));
let file=null,stat=null,resolvedPathname=pathname;
for(const candidate of candidates){const target=path.resolve(ROOT,`.${candidate}`);if(!target.startsWith(`${ROOT}${path.sep}`)||path.basename(target).startsWith('.'))continue;try{const targetStat=await fsp.stat(target);if(targetStat.isFile()){file=target;stat=targetStat;resolvedPathname=candidate;break}}catch(error){if(error.code!=='ENOENT')throw error}}
if(!file||!stat)return json(res,404,{error:'Not found'});
if(stat.isDirectory())return json(res,403,{error:'Directory listing is disabled'});
const ext=path.extname(file).toLowerCase(),body=pathname==='/runtime-config.js'?Buffer.from("'use strict';\nglobalThis.BendRuntimeConfig=Object.freeze({cloudApi:true});\n"):null;
const ext=path.extname(file).toLowerCase(),body=resolvedPathname==='/runtime-config.js'?Buffer.from("'use strict';\n(function(root){let appBaseUrl='';try{appBaseUrl=new URL('./',root.document?.currentScript?.src||root.location?.href||'').href}catch(_){}root.BendRuntimeConfig=Object.freeze({cloudApi:true,singleSharedWorld:true,worldId:'link-field-main',appBaseUrl,realtimeTransport:'http-poll'});})(globalThis);\n"):null;
res.writeHead(200,{
'content-type':MIME[ext]||'application/octet-stream',
'content-length':body?body.length:stat.size,
@ -374,6 +464,125 @@ async function serveStatic(req,res,url){
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);
}
async function main(){await fsp.mkdir(DATA_DIR,{recursive:true,mode:0o700});await recoverPendingWorldCommit();await collectRetiredBoardVersions();const server=http.createServer(async(req,res)=>{try{const url=new URL(req.url,`http://${req.headers.host||'localhost'}`);if(url.pathname.startsWith('/api/'))await handleApi(req,res,url);else await serveStatic(req,res,url)}catch(error){console.error(error);if(!res.headersSent)json(res,error.status||500,{error:error.status?error.message:'Internal server error',serverTime:serverTime()});else res.destroy()}});realtimeHub=createRealtimeHub({server,authenticate:authenticateRealtime,getBoardInfo:realtimeBoardInfo});server.listen(PORT,HOST,()=>{console.log(`BEND FIELD v${BuildMeta.APP_VERSION} shared world: http://${HOST}:${PORT}`);console.log(`Shared world data: ${DATA_DIR}`)})}
module.exports=Object.freeze({normalizePlayerPurchases,sanitizeWorldGlobal,storeItem,collectRetiredBoardVersions,recoverPendingWorldCommit,commitWorldMutation,main});
if(require.main===module)main().catch(error=>{console.error(error);process.exitCode=1});
function listenOnce(server, port, host) {
return new Promise((resolve, reject) => {
const cleanup = () => {
server.off('error', onError);
server.off('listening', onListening);
};
const onError = error => { cleanup(); reject(error); };
const onListening = () => { cleanup(); resolve(server.address()); };
server.once('error', onError);
server.once('listening', onListening);
server.listen({port, host});
});
}
async function listenWithPortFallback(server, {host=HOST, preferredPort=PORT, explicitPort=PORT_EXPLICIT, candidates=null}={}) {
const ports = explicitPort ? [preferredPort] : uniquePorts(candidates || defaultPortCandidates(preferredPort));
let lastError = null;
for (let index = 0; index < ports.length; index++) {
const requestedPort = ports[index];
try {
const address = await listenOnce(server, requestedPort, host);
const actualPort = typeof address === 'object' && address ? address.port : requestedPort;
return {address, port:actualPort, requestedPort, preferredPort, usedFallback:index > 0};
} catch (error) {
lastError = error;
if (error?.code !== 'EADDRINUSE') throw error;
if (explicitPort) {
const configuredBy = CLI_PORT != null ? '--port' : process.env.LINK_FIELD_PORT != null ? 'LINK_FIELD_PORT' : 'PORT';
throw Object.assign(new Error(`Port ${requestedPort} is already in use. Change ${configuredBy} or stop the process using that port.`), {code:'EADDRINUSE', cause:error});
}
console.warn(`Port ${requestedPort} is already in use; trying another port.`);
}
}
throw Object.assign(new Error(`No available port was found. Tried: ${ports.join(', ')}`), {code:lastError?.code || 'EADDRINUSE', cause:lastError});
}
function createApplicationServer() {
return http.createServer(async(req,res)=>{try{const url=new URL(req.url,`http://${req.headers.host||'localhost'}`),apiOffset=url.pathname.lastIndexOf('/api/');if(apiOffset>=0){url.pathname=url.pathname.slice(apiOffset);await handleApi(req,res,url)}else await serveStatic(req,res,url)}catch(error){console.error(error);if(!res.headersSent)json(res,error.status||500,{error:error.status?error.message:'Internal server error',serverTime:serverTime()});else res.destroy()}});
}
function displayServerUrl(host, port) {
const displayHost = host === '0.0.0.0' || host === '::' ? '127.0.0.1' : host;
return `http://${displayHost}:${port}`;
}
function processIsRunning(pid){
if(!Number.isSafeInteger(pid)||pid<=0)return false;
try{process.kill(pid,0);return true}catch(error){return error?.code==='EPERM'}
}
async function acquireInstanceLock(){
await fsp.mkdir(DATA_DIR,{recursive:true,mode:0o700});
for(let attempt=0;attempt<2;attempt++){
try{
const handle=await fsp.open(INSTANCE_LOCK_FILE,'wx',0o600);
await handle.writeFile(`${process.pid}
`,'utf8');
await handle.close();
return process.pid;
}catch(error){
if(error?.code!=='EEXIST')throw error;
let existing=0;try{existing=Number((await fsp.readFile(INSTANCE_LOCK_FILE,'utf8')).trim())}catch(_){}
if(processIsRunning(existing))throw Object.assign(new Error(`Another LinkField server is already running (PID ${existing}). Stop it before starting a second shared world.`),{code:'EALREADY'});
await fsp.unlink(INSTANCE_LOCK_FILE).catch(unlinkError=>{if(unlinkError?.code!=='ENOENT')throw unlinkError});
}
}
throw new Error('Could not acquire the LinkField single-world lock.');
}
async function releaseInstanceLock(){
try{const owner=Number((await fsp.readFile(INSTANCE_LOCK_FILE,'utf8')).trim());if(owner===process.pid)await fsp.unlink(INSTANCE_LOCK_FILE)}catch(error){if(error?.code!=='ENOENT')console.warn(`Instance lock cleanup warning: ${error.message}`)}
}
async function main(){
await acquireInstanceLock();
await fsp.mkdir(DATA_DIR,{recursive:true,mode:0o700});
await fsp.mkdir(PUBLIC_ROOT,{recursive:true,mode:0o755});
await recoverPendingWorldCommit();
await collectRetiredBoardVersions();
const server=createApplicationServer();
realtimeHub=createRealtimeHub({server,authenticate:authenticateRealtime,getBoardInfo:realtimeBoardInfo});
try {
const listening=await listenWithPortFallback(server,{explicitPort:PORT_STRICT});
console.log(`LinkField v${BuildMeta.APP_VERSION} shared world: ${displayServerUrl(HOST,listening.port)}`);
if(listening.usedFallback)console.log(`Default port ${listening.preferredPort} was unavailable; using port ${listening.port}.`);
await fsp.writeFile(PUBLIC_BRIDGE_PORT_FILE, `${listening.port}\n`, {encoding:'utf8', mode:0o644});
console.log(`Public PHP bridge: ${PUBLIC_BRIDGE_PORT_FILE} -> 127.0.0.1:${listening.port}`);
let apacheBridge=null;
try {
apacheBridge=await installApacheBridge({fsp,root:PUBLIC_ROOT,port:listening.port,enabled:APACHE_BRIDGE_ENABLED});
if(apacheBridge.enabled)console.log(`Apache bridge: ${apacheBridge.file} -> 127.0.0.1:${listening.port}`);
else console.log('Apache bridge: disabled by LINK_FIELD_APACHE_BRIDGE');
} catch (error) {
console.warn(`Apache bridge could not be installed: ${error.message}`);
console.warn('The public /api/ path must be forwarded to this Node.js port by the web server.');
}
console.log(`Shared world data: ${DATA_DIR}`);
return {server,realtimeHub,port:listening.port,host:HOST,dataDir:DATA_DIR,publicRoot:PUBLIC_ROOT,portFile:PUBLIC_BRIDGE_PORT_FILE,apacheBridge};
} catch (error) {
realtimeHub?.close();
realtimeHub=null;
await fsp.unlink(PUBLIC_BRIDGE_PORT_FILE).catch(()=>{});
await releaseInstanceLock();
throw error;
}
}
async function closeApplication(result){
if(!result)return;
try{result.realtimeHub?.close()}catch(error){console.warn(`Realtime shutdown warning: ${error.message}`)}
if(result.server?.listening)await new Promise(resolve=>result.server.close(()=>resolve()));
await fsp.unlink(result.portFile||PUBLIC_BRIDGE_PORT_FILE).catch(error=>{if(error?.code!=='ENOENT')console.warn(`Port file cleanup warning: ${error.message}`)});
await releaseInstanceLock();
}
module.exports=Object.freeze({normalizePlayerPurchases,sanitizeWorldGlobal,storeItem,collectRetiredBoardVersions,recoverPendingWorldCommit,commitWorldMutation,parsePort,defaultPortCandidates,listenWithPortFallback,createApplicationServer,closeApplication,main});
if(require.main===module){
let active=null,stopping=false;
const shutdown=async signal=>{
if(stopping)return;
stopping=true;
console.log(`LinkField received ${signal}; stopping.`);
await closeApplication(active);
process.exit(0);
};
main().then(result=>{
active=result;
process.once('SIGTERM',()=>shutdown('SIGTERM'));
process.once('SIGINT',()=>shutdown('SIGINT'));
}).catch(error=>{console.error(`LinkField server failed to start: ${error.message}`);if(process.env.LINK_FIELD_STARTUP_DEBUG==='1')console.error(error);process.exitCode=1});
}