t
This commit is contained in:
parent
4c4e767ec6
commit
9d70afb4cc
42 changed files with 1266 additions and 630 deletions
211
server.js
211
server.js
|
|
@ -12,6 +12,7 @@ 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');
|
||||
|
|
@ -21,9 +22,11 @@ const STARTER_LINE_COLOR_IDS=Object.freeze([...STORE_CATALOG.values()].filter(it
|
|||
|
||||
const ROOT = __dirname;
|
||||
const PUBLIC_ROOT = path.resolve(process.env.LINK_FIELD_PUBLIC_DIR || ROOT);
|
||||
const PRODUCTION_DATA_DIR = path.resolve('/link-field/world');
|
||||
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');
|
||||
|
|
@ -57,30 +60,63 @@ 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;
|
||||
const MAX_CELLS_PER_PATH = 5000;
|
||||
const CLOUD_PAGE_LIMIT = 512;
|
||||
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);
|
||||
|
|
@ -116,13 +152,14 @@ 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:{},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}}
|
||||
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)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}
|
||||
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){
|
||||
|
|
@ -137,7 +174,9 @@ function addMetaToWorldOccupancy(meta,occupancy,{requireTouch=false}={}){
|
|||
if(!touches)badRequest(`Generated board is not adjacent: ${meta.id}`);for(const key of keys)occupancy[key]=meta.id;return true;
|
||||
}
|
||||
async function collectRetiredBoardVersions(){
|
||||
const world=await readWorld();let names=[];try{names=await fsp.readdir(WORLD_BOARDS_DIR)}catch(error){if(error.code==='ENOENT')return 0;throw error}
|
||||
let 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;
|
||||
|
|
@ -146,6 +185,12 @@ async function collectRetiredBoardVersions(){
|
|||
}
|
||||
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');
|
||||
|
|
@ -158,7 +203,7 @@ async function recoverPendingWorldCommit(){
|
|||
await fsp.unlink(WORLD_COMMIT_FILE).catch(error=>{if(error.code!=='ENOENT')throw error});return false;
|
||||
}
|
||||
if(commit.nextPlayer?.playerId)await writePlayerRecord(commit.nextPlayer);
|
||||
await atomicWriteJson(WORLD_FILE,commit.world);
|
||||
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;
|
||||
}
|
||||
|
|
@ -166,13 +211,13 @@ async function commitWorldMutation(world,{nextPlayer=null,previousPlayer=null,ch
|
|||
const commit={revision:world.revision,world,nextPlayer,previousPlayer,changedBoardIds:[...changedBoardIds],preparedAt:serverTime()};
|
||||
await atomicWriteJson(WORLD_COMMIT_FILE,commit);
|
||||
if(nextPlayer)await writePlayerRecord(nextPlayer);
|
||||
await atomicWriteJson(WORLD_FILE,world);
|
||||
await 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 chunks=[];let bytes=0;for await(const chunk of req){bytes+=chunk.length;if(bytes>MAX_BODY_BYTES)throw Object.assign(new Error('Request body is too large'),{status:413});chunks.push(chunk)}if(!chunks.length)return{};try{const parsed=JSON.parse(Buffer.concat(chunks).toString('utf8'));if(!parsed||typeof parsed!=='object'||Array.isArray(parsed))throw new Error();return parsed}catch{throw Object.assign(new Error('Invalid JSON body'),{status:400})}}
|
||||
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 forward<reverse?forward:reverse}
|
||||
function validatePuzzle(meta){
|
||||
|
|
@ -213,10 +258,11 @@ function solvedStateMatchesPuzzle(state,puzzle){
|
|||
}
|
||||
function validateMeta(meta){
|
||||
if(!meta||typeof meta!=='object'||!BOARD_RE.test(meta.id))badRequest('Invalid board metadata');const chunks=Array.isArray(meta.chunks)?meta.chunks:[];
|
||||
if(!chunks.length||chunks.length>50)badRequest(`Invalid chunks for ${meta.id}`);const seen=new Set();for(const cell of chunks){if(!Array.isArray(cell)||cell.length!==2||!Number.isSafeInteger(cell[0])||!Number.isSafeInteger(cell[1])||Math.abs(cell[0])>1000000||Math.abs(cell[1])>1000000||seen.has(cellKey(cell)))badRequest(`Invalid chunk coordinate for ${meta.id}`);seen.add(cellKey(cell))}
|
||||
if(!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}`);
|
||||
if(!Number.isInteger(meta.level)||meta.level<1||meta.level>10||!Number.isInteger(meta.targetLevel)||meta.targetLevel<1||meta.targetLevel>10)badRequest(`Invalid level for ${meta.id}`);const sectionRange=AppLogic.sectionCountRange(meta.targetLevel);if(chunks.length<sectionRange.min||chunks.length>sectionRange.max||meta.level<=3&&chunks.length!==1)badRequest(`Invalid section count for ${meta.id}`);
|
||||
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);
|
||||
|
|
@ -224,24 +270,30 @@ function validateMeta(meta){
|
|||
clean.level=derivedLevel;clean.puzzle.level=derivedLevel;clean.puzzle.difficulty=derivedLevel;clean.puzzle.complexity=PuzzleCore.solutionComplexity(clean.puzzle);clean.puzzle.interactionBurden=AppLogic.interactionBurden(clean.puzzle);
|
||||
delete clean.puzzle.solutionQuality;delete clean.puzzle.uniqueness;
|
||||
if(clean.targetLevel>=6){
|
||||
const verification=PuzzleCore.verifyPuzzleUniqueness(clean.puzzle,{maxMs:2000,nodeCap:150000,analyzeQuality:false});
|
||||
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};
|
||||
}
|
||||
return clean;
|
||||
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<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;
|
||||
const cleanPaths=paths.map(item=>({startGate:item.startGate,endGate:item.endGate??null,openGate:Number.isInteger(item.openGate)?item.openGate:null,detachedStart:item.detachedStart===true&&item.endGate==null,cells:item.cells.map(cell=>[...cell]),colorIndex:Number.isInteger(item.colorIndex)&&item.colorIndex>=0&&item.colorIndex<64?item.colorIndex:0,startColorIndex:Number.isInteger(item.startColorIndex)&&item.startColorIndex>=0&&item.startColorIndex<64?item.startColorIndex:0,endColorIndex:Number.isInteger(item.endColorIndex)&&item.endColorIndex>=0&&item.endColorIndex<64?item.endColorIndex:null,lineEffect:typeof item.lineEffect==='string'&&item.lineEffect.length<=64?item.lineEffect:null,ownerId:PLAYER_RE.test(String(item.ownerId||''))?String(item.ownerId).toLowerCase():null}));
|
||||
const crossings=[...new Set((Array.isArray(state.specialProgress?.crossings)?state.specialProgress.crossings:[]).filter(value=>typeof value==='string'&&/^-?\d+,-?\d+$/.test(value)))].slice(0,64);
|
||||
return{paths:cleanPaths,specialProgress:{crossings},solved:state.solved===true,expanded:state.expanded===true,expansionRetryRound:Number.isInteger(state.expansionRetryRound)&&state.expansionRetryRound>=0?Math.min(state.expansionRetryRound,1000000):0,scoreVersion:Number.isInteger(state.scoreVersion)&&state.scoreVersion>=0?Math.min(state.scoreVersion,1000):0};
|
||||
}
|
||||
function worldMetaFingerprint(meta){const clean=JSON.parse(JSON.stringify(meta));delete clean.rev;delete clean.revAuthor;delete clean.sealedSides;return JSON.stringify(clean)}
|
||||
function sanitizeWorldGlobal(value,record){
|
||||
const source=value&&typeof value==='object'&&!Array.isArray(value)?value:{},prior=record?.global||{},clean={};
|
||||
for(const key of['schema','gameplayVersion','worldGeneration','worldEpoch','appVersion','generatorVersion','nextId','solved','lastSolveAt','specialMechanicsSeen','quarantine'])if(Object.prototype.hasOwnProperty.call(source,key))clean[key]=JSON.parse(JSON.stringify(source[key]));
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
@ -281,8 +333,20 @@ function publicStateForWorld(incoming,current,player,worldRevision,rowRevision,m
|
|||
return{state,clearEvent:{id:meta.id,playerId:player.playerId,playerName:player.name,x:meta.x,y:meta.y,level:meta.level,scoreAwarded:state.scoreAwarded,solvedAt:now,revision:worldRevision}};
|
||||
}
|
||||
function cloudDeltaSince(record,since){if(!(since>0)||since>=record.revision)return null;const changes=record.changes.filter(change=>Number.isSafeInteger(change?.revision)&&change.revision>since).sort((a,b)=>a.revision-b.revision);if(!changes.length||changes[0].revision!==since+1||changes[changes.length-1].revision!==record.revision)return null;for(let index=1;index<changes.length;index++)if(changes[index].revision!==changes[index-1].revision+1)return null;const metaIds=new Set(),stateIds=new Set(),deleted=new Set();for(const change of changes){for(const id of change.deleted||[]){metaIds.delete(id);stateIds.delete(id);deleted.add(id)}for(const id of change.metaIds||[]){deleted.delete(id);metaIds.add(id)}for(const id of change.stateIds||[]){deleted.delete(id);stateIds.add(id)}}return{metaIds,stateIds,deleted}}
|
||||
async function publicWorldPage(record,ids,metaIds=null,stateIds=null){const rows=await Promise.all(ids.map(id=>readWorldBoard(record,id))),metas={},states={};for(let index=0;index<ids.length;index++){const id=ids[index],row=rows[index];if(!row)continue;if((!metaIds||metaIds.has(id))&&row.meta)metas[id]=row.meta;if((!stateIds||stateIds.has(id))&&row.state)states[id]=row.state}return{global:{...(record.global||{}),cloudProfile:null,cloudRevision:record.revision},metas,states}}
|
||||
async function readWorldBoardsBounded(record,ids){const rows=[];for(let offset=0;offset<ids.length;offset+=CLOUD_READ_CONCURRENCY)rows.push(...await Promise.all(ids.slice(offset,offset+CLOUD_READ_CONCURRENCY).map(id=>readWorldBoard(record,id))));return rows}
|
||||
function completedProgressState(state){if(!state||state.solved===true)return state;const visible=JSON.parse(JSON.stringify(state));visible.paths=(visible.paths||[]).filter(path=>Number.isInteger(path?.endGate));visible.specialProgress={crossings:[]};return visible}
|
||||
function completedProgressFingerprint(state){const visible=completedProgressState(state)||{};return JSON.stringify({paths:visible.paths||[],solved:visible.solved===true,expanded:visible.expanded===true})}
|
||||
async function publicWorldPage(record,ids,metaIds=null,stateIds=null,viewerPlayerId=''){const rows=await readWorldBoardsBounded(record,ids),metas={},states={};for(let index=0;index<ids.length;index++){const id=ids[index],row=rows[index];if(!row)continue;if((!metaIds||metaIds.has(id))&&row.meta)metas[id]=row.meta;if((!stateIds||stateIds.has(id))&&row.state)states[id]=realtimeHub?.hasClaim(viewerPlayerId,id)?row.state:completedProgressState(row.state)}return{global:{...(record.global||{}),cloudProfile:null,cloudRevision:record.revision},metas,states}}
|
||||
function withWorldQueue(task){const run=worldQueue.then(task,task);worldQueue=run.then(()=>undefined,()=>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);
|
||||
|
|
@ -357,7 +421,7 @@ async function handleCloudStatus(_req,res){
|
|||
}
|
||||
|
||||
async function handleCloudSession(req,res){
|
||||
const body=await readJsonBody(req),result=await playerService.createSession(body.name);return json(res,result.status,result.body);
|
||||
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);
|
||||
|
|
@ -373,53 +437,61 @@ 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:publicCloudPlayer(player.record),serverTime:serverTime()}};
|
||||
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.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:publicCloudPlayer(player.record),serverTime:serverTime(),fullSnapshot,page:{...page,deleted:delta?[...delta.deleted]:[]},nextCursor}};
|
||||
const delta=cloudDeltaSince(record,since),fullSnapshot=!delta,metaIds=delta?.metaIds||new Set(Object.keys(record.boardVersions)),stateIds=delta?.stateIds||new Set(Object.keys(record.boardVersions)),ids=[...new Set([...metaIds,...stateIds])].filter(id=>Number.isSafeInteger(record.boardVersions[id])).sort((a,b)=>Number(a.slice(1))-Number(b.slice(1))),pageIds=ids.slice(cursor,cursor+CLOUD_PAGE_LIMIT),nextCursor=cursor+pageIds.length<ids.length?cursor+pageIds.length:null,page=await publicWorldPage(record,pageIds,metaIds,stateIds,player.playerId);
|
||||
return{status:200,body:{changed:true,revision:record.revision,latestEventRevision:record.clearEvents.at(-1)?.revision||record.clearEventsDroppedThrough||0,clearEvents:cursor?[]:clearEvents,clearEventsGap:cursor?false:clearEventsGap,player:publicCloudPlayer(player.record),serverTime:serverTime(),fullSnapshot,page:{...page,deleted:delta?[...delta.deleted]:[]},nextCursor}};
|
||||
}
|
||||
async function pushCloudWorldService(player,body){
|
||||
return withPlayerQueue(player.playerId,()=>withWorldQueue(async()=>{
|
||||
const playerRecord=await readPlayer(player.playerId),previousPlayerRecord=JSON.parse(JSON.stringify(playerRecord)),record=await ensureWorldIndexes(await readWorld()),baseRevision=Math.max(0,Math.floor(finiteNumber(body.baseRevision,0)));if(baseRevision!==record.revision)return{status:409,body:{error:'Revision conflict',revision:record.revision,serverTime:serverTime()}};
|
||||
const metas=Array.isArray(body.metas)?body.metas:[],states=Array.isArray(body.states)?body.states:[],deleted=[];if(metas.length+states.length>MAX_BOARDS_PER_PUSH*2)throw Object.assign(new Error('Too many board changes'),{status:413});
|
||||
const nextRevision=record.revision+1,rowRevision=Math.max((Number(record.rowRevision)||0)+1,serverTime()*1000),changedBoards=new Map(),loadChangedBoard=async id=>{if(changedBoards.has(id))return changedBoards.get(id);const current=await readWorldBoard(record,id)||{meta:null,state:null};changedBoards.set(id,current);return current};
|
||||
for(const rawMeta of metas){const clean=validateMeta(rawMeta),row=await loadChangedBoard(clean.id);if(row.meta&&worldMetaFingerprint(row.meta)!==worldMetaFingerprint(clean))badRequest(`Existing board is immutable: ${clean.id}`);clean.rev=rowRevision;clean.revAuthor='shared-world';row.meta=clean}
|
||||
const existingIds=new Set(Object.keys(record.boardVersions)),newMetaIds=[...changedBoards.keys()].filter(id=>!existingIds.has(id)),occupancy={...record.occupancy};
|
||||
if(record.revision>0&&newMetaIds.length){const grant=record.expansionGrants?.[player.playerId];if(!grant||grant.expiresAt<serverTime())throw Object.assign(new Error('Expansion grant is required'),{status:403});if(newMetaIds.length>Math.min(MAX_NEW_BOARDS_PER_GRANT,grant.maxBoards||0))badRequest('Too many generated boards');const expectedStart=Math.max(1,Number(record.global?.nextId)||1),numbers=newMetaIds.map(id=>Number(id.slice(1))).sort((a,b)=>a-b);for(let i=0;i<numbers.length;i++)if(numbers[i]!==expectedStart+i)badRequest('Generated board ids are not contiguous');for(const id of newMetaIds.sort((a,b)=>Number(a.slice(1))-Number(b.slice(1))))addMetaToWorldOccupancy(changedBoards.get(id).meta,occupancy,{requireTouch:true});grant.maxBoards-=newMetaIds.length;if(grant.maxBoards<=0)delete record.expansionGrants[player.playerId]}
|
||||
const playerRecord=await readPlayer(player.playerId),previousPlayerRecord=JSON.parse(JSON.stringify(playerRecord)),record=await ensureWorldIndexes(JSON.parse(JSON.stringify(await readWorld()))),baseRevision=Math.max(0,Math.floor(finiteNumber(body.baseRevision,0))),requestedMutationId=String(body.mutationId||''),mutationId=requestedMutationId||`legacy-${crypto.randomBytes(16).toString('hex')}`,metas=Array.isArray(body.metas)?body.metas:[],states=Array.isArray(body.states)?body.states:[],deleted=[];
|
||||
if(requestedMutationId&&!MUTATION_RE.test(requestedMutationId))badRequest('Invalid mutation id');const priorMutation=requestedMutationId&&(record.recentMutations||[]).find(row=>row?.playerId===player.playerId&&row?.mutationId===mutationId);if(priorMutation){const clearEvents=record.clearEvents.filter(event=>event.revision===priorMutation.revision&&event.playerId===player.playerId);return{status:200,body:{revision:priorMutation.revision,duplicate:true,clearEvents,latestEventRevision:record.clearEvents.at(-1)?.revision||record.clearEventsDroppedThrough||0,player:publicCloudPlayer(playerRecord),serverTime:serverTime()}}}
|
||||
if(metas.length>MAX_METAS_PER_PUSH||states.length>MAX_BOARDS_PER_PUSH||metas.length+states.length>MAX_BOARDS_PER_PUSH*2)throw Object.assign(new Error('Too many board changes'),{status:413});
|
||||
const metaIds=metas.map(meta=>String(meta?.id||'')),stateIds=states.map(row=>String(row?.id||''));if(new Set(metaIds).size!==metaIds.length||new Set(stateIds).size!==stateIds.length)badRequest('Duplicate board changes');
|
||||
if(!canRebaseWorldPush(record,baseRevision,metas,states))return{status:409,body:{error:'Revision conflict',revision:record.revision,serverTime:serverTime()}};
|
||||
if(metaIds.some(id=>!BOARD_RE.test(id)))badRequest('Invalid board metadata');const existingIds=new Set(Object.keys(record.boardVersions)),rawNewMetaIds=metaIds.filter(id=>!existingIds.has(id));if(rawNewMetaIds.length>MAX_NEW_BOARDS_PER_GRANT)badRequest('Too many generated boards');
|
||||
let expansionGrant=null;if(record.revision>0&&rawNewMetaIds.length){expansionGrant=record.expansionGrants?.[player.playerId];if(!expansionGrant||expansionGrant.expiresAt<serverTime())throw Object.assign(new Error('Expansion grant is required'),{status:403});if(rawNewMetaIds.length>Math.min(MAX_NEW_BOARDS_PER_GRANT,expansionGrant.maxBoards||0))badRequest('Too many generated boards');const expectedStart=Math.max(1,Number(record.global?.nextId)||1),numbers=rawNewMetaIds.map(id=>Number(id.slice(1))).sort((a,b)=>a-b);for(let i=0;i<numbers.length;i++)if(numbers[i]!==expectedStart+i)badRequest('Generated board ids are not contiguous')}
|
||||
const nextRevision=record.revision+1,rowRevision=Math.max((Number(record.rowRevision)||0)+1,serverTime()*1000),changedBoards=new Map(),visibleBoardIds=new Set(metaIds),loadChangedBoard=async id=>{if(changedBoards.has(id))return changedBoards.get(id);const current=await readWorldBoard(record,id)||{meta:null,state:null};changedBoards.set(id,current);return current};
|
||||
for(const rawMeta of metas){const row=await loadChangedBoard(rawMeta.id),unchanged=row.meta&&rawMeta&&typeof rawMeta==='object'&&worldMetaFingerprint(row.meta)===worldMetaFingerprint(rawMeta),clean=unchanged?JSON.parse(JSON.stringify(row.meta)):validateMeta(rawMeta);if(row.meta&&worldMetaFingerprint(row.meta)!==worldMetaFingerprint(clean))badRequest(`Existing board is immutable: ${clean.id}`);clean.rev=rowRevision;clean.revAuthor='shared-world';row.meta=clean}
|
||||
const newMetaIds=[...changedBoards.keys()].filter(id=>!existingIds.has(id)),occupancy={...record.occupancy};
|
||||
if(record.revision>0&&newMetaIds.length){for(const id of newMetaIds.sort((a,b)=>Number(a.slice(1))-Number(b.slice(1))))addMetaToWorldOccupancy(changedBoards.get(id).meta,occupancy,{requireTouch:true});expansionGrant.maxBoards-=newMetaIds.length;if(expansionGrant.maxBoards<=0)delete record.expansionGrants[player.playerId]}
|
||||
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);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 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}
|
||||
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;
|
||||
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.clearEvents.push(...clearEvents);if(record.clearEvents.length>CLEAR_EVENT_LIMIT)record.clearEvents.splice(0,record.clearEvents.length-CLEAR_EVENT_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()});
|
||||
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}};
|
||||
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),body=await readJsonBody(req),result=await pushCloudWorldService(player,body);return json(res,result.status,result.body);
|
||||
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),result=realtimeHub?.createPollingClient(identity);if(!result)throw Object.assign(new Error('Realtime service unavailable'),{status:503});return json(res,200,result);
|
||||
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),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);
|
||||
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),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);
|
||||
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()});
|
||||
|
|
@ -445,15 +517,15 @@ async function serveStatic(req,res,url){
|
|||
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;
|
||||
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}}
|
||||
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:'http-poll'});})(globalThis);\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:'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'||ext==='.js'||ext==='.css'?'no-store':'public, max-age=86400',
|
||||
'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'"
|
||||
|
|
@ -499,7 +571,9 @@ async function listenWithPortFallback(server, {host=HOST, preferredPort=PORT, ex
|
|||
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()}});
|
||||
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;
|
||||
|
|
@ -509,36 +583,46 @@ 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);
|
||||
await handle.writeFile(`${process.pid}
|
||||
`,'utf8');
|
||||
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;
|
||||
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'});
|
||||
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(){
|
||||
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}`)}
|
||||
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();
|
||||
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});
|
||||
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}.`);
|
||||
|
|
@ -554,10 +638,14 @@ async function main(){
|
|||
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};
|
||||
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;
|
||||
|
|
@ -565,6 +653,7 @@ async function main(){
|
|||
}
|
||||
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}`)});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue