ddd
This commit is contained in:
parent
0a343ecbc5
commit
c3a6f5ff37
80 changed files with 2512 additions and 1625 deletions
20
server/auth.js
Normal file
20
server/auth.js
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
'use strict';
|
||||
|
||||
function createAuthenticator(options){
|
||||
const {playerPattern,tokenPattern,readPlayer,hashToken,safeEqual}=options||{};
|
||||
if(!(playerPattern instanceof RegExp)||!(tokenPattern instanceof RegExp))throw new TypeError('Authentication patterns are required');
|
||||
if(typeof readPlayer!=='function'||typeof hashToken!=='function'||typeof safeEqual!=='function')throw new TypeError('Authentication ports are required');
|
||||
const parse=req=>{
|
||||
const raw=String(req?.headers?.authorization||''),match=/^Bearer\s+([^.]*)\.([^.]*)$/i.exec(raw);
|
||||
if(!match||!playerPattern.test(match[1])||!tokenPattern.test(match[2]))throw Object.assign(new Error('Unauthorized'),{status:401});
|
||||
return{playerId:match[1].toLowerCase(),token:match[2].toLowerCase()};
|
||||
};
|
||||
const player=async req=>{
|
||||
const auth=parse(req),record=await readPlayer(auth.playerId);
|
||||
if(!safeEqual(record.tokenHash,hashToken(auth.token)))throw Object.assign(new Error('Invalid cloud sync code'),{status:403});
|
||||
return{...auth,record};
|
||||
};
|
||||
return Object.freeze({parse,player});
|
||||
}
|
||||
|
||||
module.exports=Object.freeze({createAuthenticator});
|
||||
23
server/http-router.js
Normal file
23
server/http-router.js
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
'use strict';
|
||||
|
||||
function routeKey(method,pathname){return `${String(method||'GET').toUpperCase()} ${pathname}`}
|
||||
|
||||
function createHttpRouter({notFound}={}){
|
||||
const routes=new Map();
|
||||
const add=(method,pathname,handler)=>{
|
||||
if(typeof handler!=='function')throw new TypeError('Route handler must be a function');
|
||||
const key=routeKey(method,pathname);
|
||||
if(routes.has(key))throw new Error(`Duplicate route: ${key}`);
|
||||
routes.set(key,handler);return api;
|
||||
};
|
||||
const dispatch=async(req,res,url)=>{
|
||||
const handler=routes.get(routeKey(req.method,url.pathname));
|
||||
if(handler)return handler(req,res,url);
|
||||
if(typeof notFound==='function')return notFound(req,res,url);
|
||||
return false;
|
||||
};
|
||||
const api=Object.freeze({add,dispatch,has:(method,pathname)=>routes.has(routeKey(method,pathname)),routes:()=>Object.freeze([...routes.keys()])});
|
||||
return api;
|
||||
}
|
||||
|
||||
module.exports=Object.freeze({routeKey,createHttpRouter});
|
||||
18
server/json-repository.js
Normal file
18
server/json-repository.js
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
'use strict';
|
||||
|
||||
function createJsonRepository({fsp,crypto,processId=process.pid}={}){
|
||||
if(!fsp?.readFile||!fsp?.writeFile||!fsp?.rename||!crypto?.randomBytes)throw new TypeError('Filesystem and crypto adapters are required');
|
||||
const read=async(file,{missing=null}={})=>{
|
||||
try{return JSON.parse(await fsp.readFile(file,'utf8'))}
|
||||
catch(error){if(error.code==='ENOENT'&&missing!==undefined)return typeof missing==='function'?missing():missing;throw error}
|
||||
};
|
||||
const write=async(file,value)=>{
|
||||
const temporary=`${file}.${processId}.${crypto.randomBytes(6).toString('hex')}.tmp`;
|
||||
await fsp.writeFile(temporary,JSON.stringify(value),{encoding:'utf8',mode:0o600});
|
||||
await fsp.rename(temporary,file);
|
||||
};
|
||||
const remove=async file=>fsp.unlink(file).catch(error=>{if(error.code!=='ENOENT')throw error});
|
||||
return Object.freeze({read,write,remove});
|
||||
}
|
||||
|
||||
module.exports=Object.freeze({createJsonRepository});
|
||||
54
server/player-service.js
Normal file
54
server/player-service.js
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
'use strict';
|
||||
|
||||
function createPlayerService(deps){
|
||||
const {
|
||||
randomHex,now,cleanName,defaultName,hashToken,writePlayer,readPlayer,
|
||||
withPlayerQueue,withWorldQueue,readWorld,readWorldBoard,normalizeBonuses,
|
||||
earnedScore,publicState,bonusAmount,bonusDelayMs,notifyProfile,
|
||||
purchaseContext,findPurchase,assertAffordable,createPurchase,boardPattern
|
||||
}=deps||{};
|
||||
|
||||
const createSession=async nameInput=>{
|
||||
const playerId=randomHex(12),token=randomHex(32),timestamp=now(),name=cleanName(nameInput,defaultName(playerId));
|
||||
const record={playerId,name,tokenHash:hashToken(token),purchases:[],generationBonuses:[],earnedScore:0,economyRevision:0,createdAt:timestamp,updatedAt:timestamp};
|
||||
await writePlayer(record);
|
||||
return{status:201,body:{playerId,token,name,revision:0,serverTime:timestamp}};
|
||||
};
|
||||
const updateProfile=async(playerId,nameInput)=>{
|
||||
const name=cleanName(nameInput);if(!name)throw Object.assign(new Error('Player name is required'),{status:400});
|
||||
return withPlayerQueue(playerId,async()=>{
|
||||
const record=await readPlayer(playerId);record.name=name;record.updatedAt=now();await writePlayer(record);notifyProfile?.(playerId,name);
|
||||
return{status:200,body:{playerId,name,serverTime:record.updatedAt}};
|
||||
});
|
||||
};
|
||||
const getState=record=>({status:200,body:{player:publicState(record),serverTime:now()}});
|
||||
const awardGenerationBonus=async(playerId,boardId)=>{
|
||||
if(!boardPattern.test(boardId))throw Object.assign(new Error('Invalid board id'),{status:400});
|
||||
return withPlayerQueue(playerId,()=>withWorldQueue(async()=>{
|
||||
const record=await readPlayer(playerId),world=await readWorld(),row=await readWorldBoard(world,boardId),timestamp=now();
|
||||
if(!row?.state?.solved||row.state.solvedById!==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(timestamp-(Number(row.state.solvedAt)||0)<bonusDelayMs)throw Object.assign(new Error('Generation attempt is still in progress'),{status:409});
|
||||
const grant=world.expansionGrants?.[playerId];
|
||||
if(!grant||grant.boardId!==boardId||grant.expiresAt<timestamp||!(grant.maxBoards>0))throw Object.assign(new Error('No failed expansion is eligible'),{status:409});
|
||||
record.generationBonuses=normalizeBonuses(record.generationBonuses);
|
||||
if(!record.generationBonuses.includes(boardId)){
|
||||
record.generationBonuses.push(boardId);
|
||||
record.earnedScore=Math.min(Number.MAX_SAFE_INTEGER,earnedScore(record)+bonusAmount);
|
||||
record.economyRevision=(record.economyRevision||0)+1;record.updatedAt=timestamp;await writePlayer(record);
|
||||
}
|
||||
return{status:200,body:{bonus:bonusAmount,player:publicState(record),serverTime:timestamp}};
|
||||
}));
|
||||
};
|
||||
const purchase=async(playerId,boardId,itemId)=>withPlayerQueue(playerId,async()=>{
|
||||
const record=await readPlayer(playerId),world=await readWorld(),context=await purchaseContext(world,boardId,itemId),existing=findPurchase(record,boardId,itemId);
|
||||
if(existing)return{status:200,body:{purchase:existing,player:publicState(record),serverTime:now()}};
|
||||
await assertAffordable(world,record,context.price);
|
||||
const row=createPurchase(record,boardId,context.item,context.price);await writePlayer(record);
|
||||
return{status:201,body:{purchase:row,player:publicState(record),serverTime:record.updatedAt}};
|
||||
});
|
||||
|
||||
return Object.freeze({createSession,updateProfile,getState,awardGenerationBonus,purchase});
|
||||
}
|
||||
|
||||
module.exports=Object.freeze({createPlayerService});
|
||||
Loading…
Add table
Add a link
Reference in a new issue