This commit is contained in:
33333-33333 2026-07-31 12:54:46 +09:00
commit c3a6f5ff37
80 changed files with 2512 additions and 1625 deletions

249
server.js
View file

@ -4,16 +4,25 @@ 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 { 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 ROOT = __dirname;
const DATA_DIR = path.resolve(process.env.BEND_FIELD_DATA_DIR || path.join(ROOT, 'cloud-data'));
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 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);
@ -29,7 +38,7 @@ 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 BOARD_RE = /^B(?:0|[1-9][0-9]*)$/;
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',
@ -38,6 +47,7 @@ const MIME = {
let worldQueue = Promise.resolve();
const playerQueues = new Map();
let realtimeHub = null;
const jsonRepository = createJsonRepository({fsp,crypto});
function json(res, status, value) {
const body = JSON.stringify(value);
@ -50,16 +60,13 @@ function safeEqualHex(a,b){if(typeof a!=='string'||typeof b!=='string'||a.length
function badRequest(message){throw Object.assign(new Error(message),{status:400})}
function finiteNumber(value,fallback=0){return Number.isFinite(Number(value))?Number(value):fallback}
function cleanPlayerName(value,fallback=''){
const name=String(value||'').replace(/[\u0000-\u001f\u007f]/g,'').replace(/\s+/g,' ').trim().slice(0,24);
return name||fallback;
return SharedContracts.cleanPlayerName(value,fallback);
}
function defaultPlayerName(playerId){return `旅人-${String(playerId).slice(0,4).toUpperCase()}`}
function cleanId(value,maxLength=64){const text=String(value||'').trim();if(!text||text.length>maxLength||!/^[A-Za-z0-9:_-]+$/.test(text))return'';return text}
function cleanId(value,maxLength=64){return SharedContracts.cleanContractId(value,maxLength)}
function storeItem(itemId){return STORE_CATALOG.get(String(itemId||''))||null}
function normalizePlayerPurchases(raw){
const purchases=[],seenIds=new Set(),seenStoreItems=new Set();
for(const source of Array.isArray(raw)?raw:[]){const purchaseId=cleanId(source?.purchaseId,64),boardId=cleanId(source?.boardId,32),item=storeItem(source?.itemId),storeKey=`${boardId}:${item?.id||''}`;if(!purchaseId||!boardId||!item||seenIds.has(purchaseId)||seenStoreItems.has(storeKey))continue;const boughtAt=finiteNumber(source.boughtAt,0);seenIds.add(purchaseId);seenStoreItems.add(storeKey);purchases.push({purchaseId,boardId,itemId:item.id,buyer:cleanPlayerName(source.buyer),boughtAt:boughtAt>0?boughtAt:0,paidCost:Number.isSafeInteger(source.paidCost)&&source.paidCost>0?source.paidCost:item.cost})}
return purchases;
return SharedContracts.normalizePlayerPurchases(raw,{resolveItem:storeItem,maxPurchases:MAX_PLAYER_PURCHASES});
}
function normalizeGenerationBonuses(raw){return[...new Set((Array.isArray(raw)?raw:[]).map(value=>String(value||'')).filter(value=>BOARD_RE.test(value)))].slice(-10000)}
function playerSpentScore(record){return normalizePlayerPurchases(record?.purchases).reduce((sum,purchase)=>sum+Math.max(0,Number(purchase.paidCost)||0),0)}
@ -68,19 +75,64 @@ function publicPlayerState(record){const earnedScore=playerEarnedScore(record),s
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){const tmp=`${file}.${process.pid}.${crypto.randomBytes(6).toString('hex')}.tmp`;await fsp.writeFile(tmp,JSON.stringify(value),{encoding:'utf8',mode:0o600});await fsp.rename(tmp,file)}
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}
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:31,appVersion:'47.77',generatorVersion:5,worldGeneration:'v47-field-reset-20260728-interaction-fix',nextId:1,solved:0,earnedScore:0,specialMechanicsSeen:[],updatedAt:now,cloudRevision:0},boardVersions:{},changes:[],clearEvents:[],createdAt:now,updatedAt:now}}
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}
}
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}}
function authenticate(req){const raw=String(req.headers.authorization||''),match=/^Bearer\s+([^.]*)\.([^.]*)$/i.exec(raw);if(!match||!PLAYER_RE.test(match[1])||!TOKEN_RE.test(match[2]))throw Object.assign(new Error('Unauthorized'),{status:401});return{playerId:match[1].toLowerCase(),token:match[2].toLowerCase()}}
async function authenticatedPlayer(req){const auth=authenticate(req),record=await readPlayer(auth.playerId);if(!safeEqualHex(record.tokenHash,tokenHash(auth.token)))throw Object.assign(new Error('Invalid cloud sync code'),{status:403});return{...auth,record}}
async function ensureWorldIndexes(record){
if(record.occupancy&&typeof record.occupancy==='object'&&!Array.isArray(record.occupancy)&&Number.isSafeInteger(record.global?.solved)&&Number.isSafeInteger(record.global?.earnedScore))return record;
const occupancy={};let solved=0,earnedScore=0;
for(const id of Object.keys(record.boardVersions)){const row=await readWorldBoard(record,id);if(!row?.meta)continue;for(const[dx,dy]of row.meta.chunks||[]){const key=`${row.meta.x+dx},${row.meta.y+dy}`;if(occupancy[key]&&occupancy[key]!==id)throw new Error(`Stored board overlap: ${occupancy[key]} / ${id}`);occupancy[key]=id}if(row.state?.solved){solved++;const award=Number(row.state.scoreAwarded)||0;if(Number.isSafeInteger(award)&&award>0)earnedScore+=award}}
record.occupancy=occupancy;record.global={...(record.global||{}),solved,earnedScore:Math.max(0,earnedScore)};return record;
}
function addMetaToWorldOccupancy(meta,occupancy,{requireTouch=false}={}){
let touches=!requireTouch;const keys=[];
for(const[dx,dy]of meta.chunks||[]){const x=meta.x+dx,y=meta.y+dy,key=`${x},${y}`;if(occupancy[key]&&occupancy[key]!==meta.id)badRequest(`Board overlap: ${occupancy[key]} / ${meta.id}`);keys.push(key);for(const[ox,oy]of[[1,0],[-1,0],[0,1],[0,-1]])if(occupancy[`${x+ox},${y+oy}`]&&occupancy[`${x+ox},${y+oy}`]!==meta.id)touches=true}
if(!touches)badRequest(`Generated board is not adjacent: ${meta.id}`);for(const key of keys)occupancy[key]=meta.id;return true;
}
async function collectRetiredBoardVersions(){
const world=await readWorld();let names=[];try{names=await fsp.readdir(WORLD_BOARDS_DIR)}catch(error){if(error.code==='ENOENT')return 0;throw error}
let removed=0;
for(const name of names){
const match=/^(B(?:0|[1-9][0-9]*))\.([0-9]+)\.json$/.exec(name);if(!match)continue;
const revision=Number(match[2]);if(world.boardVersions[match[1]]===revision)continue;
await fsp.unlink(path.join(WORLD_BOARDS_DIR,name)).catch(error=>{if(error.code!=='ENOENT')throw error});removed++;
}
return removed;
}
async function recoverPendingWorldCommit(){
let commit;try{commit=JSON.parse(await fsp.readFile(WORLD_COMMIT_FILE,'utf8'))}catch(error){if(error.code==='ENOENT')return false;throw error}
if(!commit||!Number.isSafeInteger(commit.revision)||!commit.world||commit.world.revision!==commit.revision)throw new Error('Invalid pending shared-world commit');
const current=await readWorld();
if(current.revision<commit.revision){
let prepared=true;
for(const id of commit.changedBoardIds||[]){const revision=commit.world.boardVersions?.[id];if(!Number.isSafeInteger(revision)){prepared=false;break}try{await fsp.access(worldBoardVersionPath(id,revision))}catch{prepared=false;break}}
if(!prepared){
if(commit.previousPlayer?.playerId)await writePlayerRecord(commit.previousPlayer);
await fsp.unlink(WORLD_COMMIT_FILE).catch(error=>{if(error.code!=='ENOENT')throw error});return false;
}
if(commit.nextPlayer?.playerId)await writePlayerRecord(commit.nextPlayer);
await atomicWriteJson(WORLD_FILE,commit.world);
}else if(current.revision===commit.revision&&commit.nextPlayer?.playerId)await writePlayerRecord(commit.nextPlayer);
await fsp.unlink(WORLD_COMMIT_FILE).catch(error=>{if(error.code!=='ENOENT')throw error});return true;
}
async function commitWorldMutation(world,{nextPlayer=null,previousPlayer=null,changedBoardIds=[]}={}){
const commit={revision:world.revision,world,nextPlayer,previousPlayer,changedBoardIds:[...changedBoardIds],preparedAt:serverTime()};
await atomicWriteJson(WORLD_COMMIT_FILE,commit);
if(nextPlayer)await writePlayerRecord(nextPlayer);
await atomicWriteJson(WORLD_FILE,world);
await fsp.unlink(WORLD_COMMIT_FILE).catch(error=>{if(error.code!=='ENOENT')throw error});
}
const authenticator=createAuthenticator({playerPattern:PLAYER_RE,tokenPattern:TOKEN_RE,readPlayer,hashToken:tokenHash,safeEqual:safeEqualHex});
function authenticate(req){return authenticator.parse(req)}
async function authenticatedPlayer(req){return authenticator.player(req)}
async function readJsonBody(req){const chunks=[];let bytes=0;for await(const chunk of req){bytes+=chunk.length;if(bytes>MAX_BODY_BYTES)throw Object.assign(new Error('Request body is too large'),{status:413});chunks.push(chunk)}if(!chunks.length)return{};try{const parsed=JSON.parse(Buffer.concat(chunks).toString('utf8'));if(!parsed||typeof parsed!=='object'||Array.isArray(parsed))throw new Error();return parsed}catch{throw Object.assign(new Error('Invalid JSON body'),{status:400})}}
function cellKey(cell){return `${cell[0]},${cell[1]}`}
function normalizedRouteKey(cells){const forward=cells.map(cellKey).join('|'),reverse=[...cells].reverse().map(cellKey).join('|');return forward<reverse?forward:reverse}
@ -108,7 +160,16 @@ function validateMeta(meta){
if(!Number.isInteger(meta.level)||meta.level<1||meta.level>10||!Number.isInteger(meta.targetLevel)||meta.targetLevel<1||meta.targetLevel>10)badRequest(`Invalid level for ${meta.id}`);
if(meta.entrySide!=null&&!['N','S','W','E'].includes(meta.entrySide))badRequest(`Invalid entry side for ${meta.id}`);
if(meta.sealedSides!=null&&(!Array.isArray(meta.sealedSides)||meta.sealedSides.some(side=>!['N','S','W','E'].includes(side))))badRequest(`Invalid sealed sides for ${meta.id}`);
const clean=JSON.parse(JSON.stringify(meta));validatePuzzle(clean);return clean;
const clean=JSON.parse(JSON.stringify(meta));validatePuzzle(clean);
const derivedLevel=PuzzleCore.solverDifficulty(clean.puzzle,clean.targetLevel);if(clean.level!==derivedLevel)badRequest(`Unverified difficulty for ${clean.id}`);
clean.level=derivedLevel;clean.puzzle.level=derivedLevel;clean.puzzle.difficulty=derivedLevel;clean.puzzle.complexity=PuzzleCore.solutionComplexity(clean.puzzle);clean.puzzle.interactionBurden=AppLogic.interactionBurden(clean.puzzle);
delete clean.puzzle.solutionQuality;delete clean.puzzle.uniqueness;
if(clean.targetLevel>=6){
const verification=PuzzleCore.verifyPuzzleUniqueness(clean.puzzle,{maxMs:2000,nodeCap:150000,analyzeQuality:false});
if(verification.status!=='unique'||verification.signature!==PuzzleCore.puzzleSignature(clean.puzzle))badRequest(`Puzzle is not server-verified: ${clean.id}`);
clean.puzzle.uniqueness={status:'unique',signature:verification.signature,ruleVersion:verification.ruleVersion,nodes:verification.nodes};
}
return clean;
}
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}`);
@ -121,11 +182,11 @@ function worldMetaFingerprint(meta){const clean=JSON.parse(JSON.stringify(meta))
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]));
clean.specialMechanicsSeen=Array.isArray(clean.specialMechanicsSeen)?[...new Set(clean.specialMechanicsSeen.filter(type=>['warp','lock','crossing'].includes(type)))]:Array.isArray(prior.specialMechanicsSeen)?prior.specialMechanicsSeen:[];
clean.specialMechanicsSeen=Array.isArray(clean.specialMechanicsSeen)?SharedContracts.normalizeSpecialMechanics(clean.specialMechanicsSeen):SharedContracts.normalizeSpecialMechanics(prior.specialMechanicsSeen);
clean.updatedAt=serverTime();return clean;
}
const STORE_CHANCE=1/30,STORE_PRICE_VERSION=1,EXPANSION_GRANT_TTL_MS=10*60*1000,MAX_NEW_BOARDS_PER_GRANT=8;
const STORE_CHANCE=1/10,STORE_PRICE_VERSION=1,EXPANSION_GRANT_TTL_MS=10*60*1000,MAX_NEW_BOARDS_PER_GRANT=8;
function authoritativeStoreItemIds(seed){
const all=[...STORE_CATALOG.values()],cursor=all.filter(item=>item.cursorStyle),other=all.filter(item=>!item.cursorStyle),
cursorPool=PuzzleCore.shuffle([...cursor],PuzzleCore.rngFrom(PuzzleCore.hash32((seed>>>0)^0x5f356495))),
@ -141,9 +202,8 @@ function authoritativeReward(meta,state,worldSeed=0){
return{award:Math.max(13,Math.min(Number.MAX_SAFE_INTEGER,reward.award)),identity:reward.identity,coefficient:reward.coefficient};
}
function authoritativeStore(meta,state,playerName,worldSeed=0){
const roll=(PuzzleCore.hash32((meta.seed>>>0)^0x7f4a7c15)>>>0)/4294967296;if(roll>=STORE_CHANCE||!state.paths?.length)return null;
const pathIndex=state.paths.reduce((best,path,index)=>(path.cells?.length||0)>(state.paths[best]?.cells?.length||0)?index:best,0),path=state.paths[pathIndex],cellIndex=Math.max(0,Math.min(path.cells.length-1,Math.floor(path.cells.length*.55))),
store={owner:playerName,pathIndex,cellIndex,openedAt:serverTime(),priceVersion:STORE_PRICE_VERSION,priceCoefficient:null,bonus:0,bonusVersion:6,itemIds:authoritativeStoreItemIds(meta.seed),purchases:[]},
const roll=(PuzzleCore.hash32((meta.seed>>>0)^0x7f4a7c15)>>>0)/4294967296,obstacles=meta?.puzzle?.obstacles||[];if(roll>=STORE_CHANCE||!obstacles.length)return null;
const cell=[...obstacles[(PuzzleCore.hash32((meta.seed>>>0)^0x2fd51a37)>>>0)%obstacles.length]],store={owner:playerName,pathIndex:-1,cellIndex:-1,cell,openedAt:serverTime(),priceVersion:STORE_PRICE_VERSION,priceCoefficient:null,bonus:0,bonusVersion:6,itemIds:authoritativeStoreItemIds(meta.seed),purchases:[]},
[x,y]=storeItemPriceLocation(meta,state,store);store.priceCoefficient=AppLogic.deterministicStorePrice(1,worldSeed,x,y,STORE_PRICE_VERSION).coefficient;return store;
}
function boardTouchesWorld(meta,rows){const occupied=new Set();for(const row of rows)for(const[dx,dy]of row?.meta?.chunks||[])occupied.add(`${row.meta.x+dx},${row.meta.y+dy}`);for(const[dx,dy]of meta.chunks||[]){const x=meta.x+dx,y=meta.y+dy;for(const[ox,oy]of[[1,0],[-1,0],[0,1],[0,-1]])if(occupied.has(`${x+ox},${y+oy}`))return true}return false}
@ -174,7 +234,7 @@ async function realtimeBoardInfo(boardId){
function assertNoOverlaps(rows){const occupied=new Map();for(const row of rows){const meta=row?.meta;if(!meta)continue;for(const[dx,dy]of meta.chunks){const key=`${meta.x+dx},${meta.y+dy}`,prior=occupied.get(key);if(prior&&prior!==meta.id)badRequest(`Board overlap: ${prior} / ${meta.id}`);occupied.set(key,meta.id)}}}
function storeItemPriceLocation(meta,state,store){
const path=store&&state?.paths?.[store.pathIndex],cell=path?.cells?.[store.cellIndex];
const direct=Array.isArray(store?.cell)?store.cell:null,path=store&&state?.paths?.[store.pathIndex],cell=direct||path?.cells?.[store.cellIndex];
if(Array.isArray(cell))return[meta.x+(cell[1]+.5)/5,meta.y+(cell[0]+.5)/5];
const count=Math.max(1,meta.chunks?.length||1),x=meta.x+.5+(meta.chunks||[]).reduce((sum,chunk)=>sum+chunk[0],0)/count,y=meta.y+.5+(meta.chunks||[]).reduce((sum,chunk)=>sum+chunk[1],0)/count;return[x,y];
}
@ -202,61 +262,118 @@ function createPlayerPurchase(record,boardId,item,price,timestamp=serverTime()){
return record.purchases.find(row=>row.purchaseId===purchase.purchaseId)||purchase;
}
async function writePlayerRecord(record){record.purchases=normalizePlayerPurchases(record.purchases);record.generationBonuses=normalizeGenerationBonuses(record.generationBonuses);await atomicWriteJson(playerPath(record.playerId),record)}
const playerService=createPlayerService({
randomHex:bytes=>crypto.randomBytes(bytes).toString('hex'),
now:serverTime,
cleanName:cleanPlayerName,
defaultName:defaultPlayerName,
hashToken:tokenHash,
writePlayer:writePlayerRecord,
readPlayer,
withPlayerQueue,
withWorldQueue,
readWorld,
readWorldBoard,
normalizeBonuses:normalizeGenerationBonuses,
earnedScore:playerEarnedScore,
publicState:publicPlayerState,
bonusAmount:GENERATION_FAILURE_BONUS,
bonusDelayMs:GENERATION_FAILURE_MIN_DELAY_MS,
notifyProfile:(playerId,name)=>realtimeHub?.notifyProfileChange(playerId,name),
purchaseContext:storePurchaseContext,
findPurchase:purchaseForStoreItem,
assertAffordable:assertPlayerCanAfford,
createPurchase:createPlayerPurchase,
boardPattern:BOARD_RE
});
async function handleApi(req,res,url){
if(req.method==='GET'&&url.pathname==='/api/cloud/status')return json(res,200,{available:true,sharedWorld:true,realtime:true,reactions:true,playerEconomy:true,sharedItems:false,claimTtlMs:realtimeHub?.claimTtlMs||300000,serverTime:serverTime()});
if(req.method==='POST'&&url.pathname==='/api/cloud/session'){
const body=await readJsonBody(req),playerId=crypto.randomBytes(12).toString('hex'),token=crypto.randomBytes(32).toString('hex'),now=serverTime(),name=cleanPlayerName(body.name,defaultPlayerName(playerId));
const record={playerId,name,tokenHash:tokenHash(token),purchases:[],generationBonuses:[],earnedScore:0,economyRevision:0,createdAt:now,updatedAt:now};await atomicWriteJson(playerPath(playerId),record);return json(res,201,{playerId,token,name,revision:0,serverTime:now});
}
if(req.method==='POST'&&url.pathname==='/api/cloud/profile'){
const auth=await authenticatedPlayer(req),body=await readJsonBody(req),name=cleanPlayerName(body.name);if(!name)badRequest('Player name is required');auth.record.name=name;auth.record.updatedAt=serverTime();await atomicWriteJson(playerPath(auth.playerId),auth.record);realtimeHub?.notifyProfileChange(auth.playerId,name);return json(res,200,{playerId:auth.playerId,name,serverTime:auth.record.updatedAt});
}
if(req.method==='GET'&&url.pathname==='/api/player/state'){
const player=await authenticatedPlayer(req);return json(res,200,{player:publicPlayerState(player.record),serverTime:serverTime()});
}
if(req.method==='POST'&&url.pathname==='/api/player/generation-bonus'){
const auth=await authenticatedPlayer(req),body=await readJsonBody(req),boardId=String(body.boardId||'');if(!BOARD_RE.test(boardId))badRequest('Invalid board id');
return withPlayerQueue(auth.playerId,()=>withWorldQueue(async()=>{const record=await readPlayer(auth.playerId),world=await readWorld(),row=await readWorldBoard(world,boardId),now=serverTime();if(!row?.state?.solved||row.state.solvedById!==auth.playerId)throw Object.assign(new Error('Only the solver can receive this bonus'),{status:403});if(row.state.expanded===true)throw Object.assign(new Error('Expansion already succeeded'),{status:409});if(now-(Number(row.state.solvedAt)||0)<GENERATION_FAILURE_MIN_DELAY_MS)throw Object.assign(new Error('Generation attempt is still in progress'),{status:409});const grant=world.expansionGrants?.[auth.playerId];if(!grant||grant.boardId!==boardId||grant.expiresAt<now||!(grant.maxBoards>0))throw Object.assign(new Error('No failed expansion is eligible'),{status:409});record.generationBonuses=normalizeGenerationBonuses(record.generationBonuses);if(!record.generationBonuses.includes(boardId)){record.generationBonuses.push(boardId);record.earnedScore=Math.min(Number.MAX_SAFE_INTEGER,playerEarnedScore(record)+GENERATION_FAILURE_BONUS);record.economyRevision=(record.economyRevision||0)+1;record.updatedAt=now;await writePlayerRecord(record)}return json(res,200,{bonus:GENERATION_FAILURE_BONUS,player:publicPlayerState(record),serverTime:now})}));
}
if(req.method==='POST'&&url.pathname==='/api/player/purchase'){
const auth=await authenticatedPlayer(req),body=await readJsonBody(req),boardId=String(body.boardId||''),itemId=String(body.itemId||'');
return withPlayerQueue(auth.playerId,async()=>{const record=await readPlayer(auth.playerId),world=await readWorld(),context=await storePurchaseContext(world,boardId,itemId),existing=purchaseForStoreItem(record,boardId,itemId);if(existing)return json(res,200,{purchase:existing,player:publicPlayerState(record),serverTime:serverTime()});await assertPlayerCanAfford(world,record,context.price);const purchase=createPlayerPurchase(record,boardId,context.item,context.price);await writePlayerRecord(record);return json(res,201,{purchase,player:publicPlayerState(record),serverTime:record.updatedAt})});
}
if(req.method==='GET'&&url.pathname==='/api/cloud/pull'){
const player=await authenticatedPlayer(req),record=await readWorld(),since=Math.max(0,Math.floor(finiteNumber(url.searchParams.get('since'),0))),eventsSince=Math.max(0,Math.floor(finiteNumber(url.searchParams.get('eventsSince'),0))),changed=since!==record.revision,
clearEvents=record.clearEvents.filter(event=>(event.revision||0)>eventsSince);
if(!changed)return json(res,200,{changed:false,revision:record.revision,latestEventRevision:record.clearEvents.at(-1)?.revision||0,clearEvents,player:{id:player.playerId,name:player.record.name},serverTime:serverTime()});
const cursor=Math.max(0,Math.floor(finiteNumber(url.searchParams.get('cursor'),0))),at=Math.max(0,Math.floor(finiteNumber(url.searchParams.get('at'),record.revision)));if(cursor&&at!==record.revision)return json(res,409,{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 json(res,200,{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});
}
if(req.method==='POST'&&url.pathname==='/api/cloud/push'){
const player=await authenticatedPlayer(req),body=await readJsonBody(req);
return withPlayerQueue(player.playerId,()=>withWorldQueue(async()=>{
const playerRecord=await readPlayer(player.playerId),record=await readWorld(),baseRevision=Math.max(0,Math.floor(finiteNumber(body.baseRevision,0)));if(baseRevision!==record.revision)return json(res,409,{error:'Revision conflict',revision:record.revision,serverTime:serverTime()});
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()});
}
async function handleCloudSession(req,res){
const body=await readJsonBody(req),result=await playerService.createSession(body.name);return json(res,result.status,result.body);
}
async function handleCloudProfile(req,res){
const auth=await authenticatedPlayer(req),body=await readJsonBody(req),result=await playerService.updateProfile(auth.playerId,body.name);return json(res,result.status,result.body);
}
async function handlePlayerState(req,res){
const player=await authenticatedPlayer(req),result=playerService.getState(player.record);return json(res,result.status,result.body);
}
async function handleGenerationBonus(req,res){
const auth=await authenticatedPlayer(req),body=await readJsonBody(req),result=await playerService.awardGenerationBonus(auth.playerId,String(body.boardId||''));return json(res,result.status,result.body);
}
async function handlePurchase(req,res){
const auth=await authenticatedPlayer(req),body=await readJsonBody(req),result=await playerService.purchase(auth.playerId,String(body.boardId||''),String(body.itemId||''));return json(res,result.status,result.body);
}
async function pullCloudWorldService(player,parameters){
const record=await readWorld(),since=Math.max(0,Math.floor(finiteNumber(parameters.get('since'),0))),eventsSince=Math.max(0,Math.floor(finiteNumber(parameters.get('eventsSince'),0))),changed=since!==record.revision,
clearEvents=record.clearEvents.filter(event=>(event.revision||0)>eventsSince);
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()}};
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}};
}
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(),previousVersions=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);previousVersions.set(id,record.boardVersions[id]);return current};
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));
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');const existingRows=[];for(const id of existingIds){const row=await readWorldBoard(record,id);if(row)existingRows.push(row)}const accepted=[];for(const id of newMetaIds.sort((a,b)=>Number(a.slice(1))-Number(b.slice(1)))){const row=changedBoards.get(id);if(!boardTouchesWorld(row.meta,[...existingRows,...accepted]))badRequest(`Generated board is not adjacent: ${id}`);accepted.push(row)}grant.maxBoards-=newMetaIds.length;if(grant.maxBoards<=0)delete record.expansionGrants[player.playerId]}
const clearEvents=[];
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});const starter=await readWorldBoard(record,'B0'),worldSeed=starter?.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);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}}}
const existingIds=new Set(Object.keys(record.boardVersions)),newMetaIds=[...changedBoards.keys()].filter(id=>!existingIds.has(id)),occupancy={...record.occupancy};
if(record.revision>0&&newMetaIds.length){const grant=record.expansionGrants?.[player.playerId];if(!grant||grant.expiresAt<serverTime())throw Object.assign(new Error('Expansion grant is required'),{status:403});if(newMetaIds.length>Math.min(MAX_NEW_BOARDS_PER_GRANT,grant.maxBoards||0))badRequest('Too many generated boards');const expectedStart=Math.max(1,Number(record.global?.nextId)||1),numbers=newMetaIds.map(id=>Number(id.slice(1))).sort((a,b)=>a-b);for(let i=0;i<numbers.length;i++)if(numbers[i]!==expectedStart+i)badRequest('Generated board ids are not contiguous');for(const id of newMetaIds.sort((a,b)=>Number(a.slice(1))-Number(b.slice(1))))addMetaToWorldOccupancy(changedBoards.get(id).meta,occupancy,{requireTouch:true});grant.maxBoards-=newMetaIds.length;if(grant.maxBoards<=0)delete record.expansionGrants[player.playerId]}
else for(const id of newMetaIds.sort((a,b)=>Number(a.slice(1))-Number(b.slice(1))))addMetaToWorldOccupancy(changedBoards.get(id).meta,occupancy);
const clearEvents=[];let solved=Math.max(0,Number(record.global?.solved)||0),earnedScore=Math.max(0,Number(record.global?.earnedScore)||0),starterRow=null;
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 [id,row] of changedBoards){if(!row.meta)badRequest(`Board metadata is missing: ${id}`);if(!row.state)row.state=publicStateForWorld({},null,playerRecord,nextRevision,rowRevision,row.meta).state}
const allIds=new Set([...Object.keys(record.boardVersions),...changedBoards.keys()]),allRows=[];for(const id of allIds){const row=changedBoards.get(id)||await readWorldBoard(record,id);if(row)allRows.push(row)}assertNoOverlaps(allRows);
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.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));let solved=0,earnedScore=0;for(const row of allRows)if(row.state?.solved){solved++;const award=Number(row.state.scoreAwarded)||0;if(Number.isSafeInteger(award)&&award>0)earnedScore+=award}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.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);
if(clearEvents.length)await writePlayerRecord(playerRecord);await atomicWriteJson(WORLD_FILE,record);await Promise.all([...previousVersions].map(([id,revision])=>Number.isSafeInteger(revision)&&revision!==nextRevision?fsp.unlink(worldBoardVersionPath(id,revision)).catch(error=>{if(error.code!=='ENOENT')console.warn(error)}):null));
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 json(res,200,{revision:nextRevision,clearEvents,latestEventRevision:record.clearEvents.at(-1)?.revision||0,player:{id:player.playerId,name:player.record.name},serverTime:record.updatedAt});
}));
}
return json(res,404,{error:'API endpoint not found',serverTime:serverTime()});
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}};
}));
}
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 apiRouter=createHttpRouter({notFound:(_req,res)=>json(res,404,{error:'API endpoint not found',serverTime:serverTime()})});
apiRouter
.add('GET','/api/cloud/status',handleCloudStatus)
.add('POST','/api/cloud/session',handleCloudSession)
.add('POST','/api/cloud/profile',handleCloudProfile)
.add('GET','/api/player/state',handlePlayerState)
.add('POST','/api/player/generation-bonus',handleGenerationBonus)
.add('POST','/api/player/purchase',handlePurchase)
.add('GET','/api/cloud/pull',handleCloudPull)
.add('POST','/api/cloud/push',handleCloudPush);
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}`)||file.startsWith(`${DATA_DIR}${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(stat.isDirectory())return json(res,403,{error:'Directory listing is disabled'});const ext=path.extname(file).toLowerCase();let body=null;if(pathname==='/index.html')body=Buffer.from((await fsp.readFile(file,'utf8')).replace('name="bend-field-cloud-api" content="off"','name="bend-field-cloud-api" content="on"'));res.writeHead(200,{'content-type':MIME[ext]||'application/octet-stream','content-length':body?body.length:stat.size,'cache-control':ext==='.html'||ext==='.js'||ext==='.css'?'no-store':'public, max-age=86400','x-content-type-options':'nosniff','cross-origin-resource-policy':'same-origin','content-security-policy':"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; worker-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'self'"});if(req.method==='HEAD')return res.end();if(body)return res.end(body);const stream=fs.createReadStream(file);stream.on('error',error=>{console.error(error);if(!res.headersSent)json(res,error.code==='ENOENT'?404:500,{error:error.code==='ENOENT'?'Not found':'Internal server error'});else res.destroy(error)});stream.pipe(res);
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(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;
res.writeHead(200,{
'content-type':MIME[ext]||'application/octet-stream',
'content-length':body?body.length:stat.size,
'cache-control':ext==='.html'||ext==='.js'||ext==='.css'?'no-store':'public, max-age=86400',
'x-content-type-options':'nosniff',
'cross-origin-resource-policy':'same-origin',
'content-security-policy':"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; worker-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'self'"
});
if(req.method==='HEAD')return res.end();
if(body)return res.end(body);
const stream=fs.createReadStream(file);
stream.on('error',error=>{console.error(error);if(!res.headersSent)json(res,error.code==='ENOENT'?404:500,{error:error.code==='ENOENT'?'Not found':'Internal server error'});else res.destroy(error)});
stream.pipe(res);
}
async function main(){await fsp.mkdir(DATA_DIR,{recursive:true,mode:0o700});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 v47.77 shared world: http://${HOST}:${PORT}`);console.log(`Shared world data: ${DATA_DIR}`)})}
main().catch(error=>{console.error(error);process.exitCode=1});
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});