try to fix this spaghetti of a code
This commit is contained in:
+121
@@ -0,0 +1,121 @@
|
||||
const crypto = require('node:crypto');
|
||||
const {EventEmitter} = require('node:events');
|
||||
const {KeyedQueue, AppError, hash} = require('./core');
|
||||
const engine = require('./engine');
|
||||
|
||||
function createLobbyService(repository, config) {
|
||||
const lobbies = new Map();
|
||||
const queue = new KeyedQueue();
|
||||
const events = new EventEmitter();
|
||||
const timers = new Map();
|
||||
let closing = false;
|
||||
function membership(username) { return [...lobbies.values()].find(lobby => lobby.players.includes(username)); }
|
||||
function reserved(username) { return [...lobbies.values()].find(lobby => lobby.game?.phase === 'betting' && lobby.game.players.includes(username)); }
|
||||
async function balances(players) { return Object.fromEntries(await Promise.all(players.map(async player => [player, (await repository.user(player)).money]))); }
|
||||
function schedule(lobby) {
|
||||
clearTimeout(timers.get(lobby.id)); timers.delete(lobby.id);
|
||||
if (closing || !lobby.players.length || !lobby.game) return;
|
||||
const deadline = lobby.game.phase === 'betting' ? lobby.game.turnEndsAt : lobby.game.nextRoundStartsAt;
|
||||
if (!deadline) return;
|
||||
const id = lobby.game.id;
|
||||
const expectedPhase = lobby.game.phase;
|
||||
const revision = lobby.revision;
|
||||
const timer = setTimeout(() => {
|
||||
command(null, {type: 'timer', lobbyId: lobby.id, handId: id, deadline, phase: expectedPhase, revision, requestId: `timer-${id}-${revision}`})
|
||||
.catch(error => {
|
||||
// Failed transactions leave state untouched. Retry the same deadline/idempotency key.
|
||||
events.emit('failure', error);
|
||||
if (!closing && lobbies.get(lobby.id) === lobby) {
|
||||
const retry = setTimeout(() => schedule(lobby), 1000); retry.unref(); timers.set(lobby.id, retry);
|
||||
}
|
||||
});
|
||||
}, Math.max(1, deadline - config.now()));
|
||||
timer.unref(); timers.set(lobby.id, timer);
|
||||
}
|
||||
async function command(username, message) {
|
||||
if (closing) throw new AppError('SHUTTING_DOWN', 'Server is shutting down', 503);
|
||||
const membershipMutation = ['lobby:create', 'lobby:join', 'lobby:leave'].includes(message.type);
|
||||
return queue.run([...(membershipMutation ? ['membership'] : []), ...(username ? [`account:${username}`] : []),
|
||||
...(message.lobbyId ? [`lobby:${message.lobbyId}`] : [])], async () => {
|
||||
const id = `${username || 'system'}:${message.requestId}`;
|
||||
const fingerprint = hash(JSON.stringify(message));
|
||||
const prior = await repository.command(id);
|
||||
if (prior) {
|
||||
if (prior.fingerprint !== fingerprint) throw new AppError('IDEMPOTENCY_CONFLICT', 'Request ID was reused', 409);
|
||||
return JSON.parse(prior.result);
|
||||
}
|
||||
let old = lobbies.get(message.lobbyId);
|
||||
if (message.type === 'lobby:create') {
|
||||
if (membership(username) || reserved(username)) throw new AppError('ALREADY_SEATED', 'Leave your table and wait for its hand to settle first', 409);
|
||||
old = {id: crypto.randomBytes(8).toString('hex'), host: username, players: [], private: message.private || false, game: null};
|
||||
}
|
||||
if (!old) throw new AppError('LOBBY_NOT_FOUND', 'Lobby not found', 404);
|
||||
const lobby = structuredClone(old);
|
||||
const baseline = Object.assign(Object.create(null), old.game?.stacks || {});
|
||||
if (lobby.game) {
|
||||
for (const key of ['stacks', 'hands', 'folded', 'contributions', 'playerBets', 'actedAt', 'payouts']) lobby.game[key] = Object.assign(Object.create(null), lobby.game[key]);
|
||||
}
|
||||
if (message.type === 'lobby:create' || message.type === 'lobby:join') {
|
||||
const current = membership(username) || reserved(username);
|
||||
if (current && current.id !== lobby.id) throw new AppError('ALREADY_SEATED', 'Leave your table and wait for its hand to settle first', 409);
|
||||
if (!lobby.players.includes(username)) {
|
||||
if (lobby.game?.phase === 'betting' && lobby.game.players.includes(username)) throw new AppError('HAND_PENDING', 'Rejoin after your previous hand settles', 409);
|
||||
if (lobby.players.length >= 10) throw new AppError('LOBBY_FULL', 'Lobby is full', 409);
|
||||
lobby.players.push(username);
|
||||
if (lobby.game) {
|
||||
const balance = (await repository.user(username)).money;
|
||||
lobby.game.stacks[username] = balance; baseline[username] = balance;
|
||||
}
|
||||
}
|
||||
if (lobby.game?.phase === 'waiting' && (Object.values(await balances(lobby.players)).filter(value => value >= config.ante).length >= 2)) {
|
||||
lobby.game = engine.intermission(lobby.players, await balances(lobby.players), config, lobby.game);
|
||||
}
|
||||
} else if (message.type === 'lobby:leave') {
|
||||
if (!lobby.players.includes(username)) throw new AppError('NOT_SEATED', 'You are not in this lobby', 409);
|
||||
lobby.players = lobby.players.filter(player => player !== username);
|
||||
engine.leave(lobby.game, username, config);
|
||||
if (lobby.host === username) lobby.host = lobby.players[0] || null;
|
||||
} else if (message.type === 'poker:start') {
|
||||
if (lobby.host !== username) throw new AppError('HOST_REQUIRED', 'Only the host can start', 403);
|
||||
if (lobby.game) throw new AppError('GAME_STARTED', 'Game already started', 409);
|
||||
if (lobby.players.length < 2) throw new AppError('NOT_ENOUGH_PLAYERS', 'At least two players are required', 409);
|
||||
lobby.game = engine.intermission(lobby.players, await balances(lobby.players), config);
|
||||
Object.assign(baseline, lobby.game.stacks);
|
||||
} else if (message.type === 'poker:action') {
|
||||
if (!lobby.players.includes(username)) throw new AppError('NOT_SEATED', 'Join this lobby first', 403);
|
||||
if (!lobby.game) throw new AppError('WRONG_PHASE', 'Host must start the game', 409);
|
||||
engine.action(lobby.game, username, message.action, message.payload?.amount, config);
|
||||
} else if (message.type === 'timer') {
|
||||
if (message.revision !== undefined && message.revision !== lobby.revision) return {ignored: true};
|
||||
if (!lobby.game || lobby.game.id !== message.handId || lobby.game.phase !== message.phase) return {ignored: true};
|
||||
const deadline = lobby.game.phase === 'betting' ? lobby.game.turnEndsAt : lobby.game.nextRoundStartsAt;
|
||||
if (deadline !== message.deadline || deadline > config.now()) return {ignored: true};
|
||||
if (lobby.game.phase === 'betting') engine.action(lobby.game, engine.turn(lobby.game), 'timeout', undefined, config);
|
||||
else {
|
||||
const fresh = await balances(lobby.players);
|
||||
Object.assign(baseline, fresh);
|
||||
lobby.game = engine.deal(lobby.players, fresh, config, lobby.game);
|
||||
}
|
||||
} else throw new AppError('INVALID_MESSAGE', 'Unsupported command');
|
||||
const deltas = Object.fromEntries(Object.entries(lobby.game?.stacks || {}).map(([player, stack]) => [player, stack - (baseline[player] ?? stack)]));
|
||||
const difference = Object.values(deltas).reduce((sum, amount) => sum + amount, 0) + (lobby.game?.pot || 0) - (old.game?.pot || 0);
|
||||
if (difference !== 0) throw new Error('Command violates chip conservation');
|
||||
lobby.revision = (old.revision || 0) + 1;
|
||||
const result = {lobbyId: lobby.id, username, type: message.type, requestId: message.requestId};
|
||||
await repository.commit({id, fingerprint, lobby, deltas, result, reason: message.type});
|
||||
if (lobby.players.length) lobbies.set(lobby.id, lobby); else lobbies.delete(lobby.id);
|
||||
schedule(lobby);
|
||||
events.emit('changed', lobby, username, message.type);
|
||||
return result;
|
||||
});
|
||||
}
|
||||
return {command, membership, events,
|
||||
get: id => lobbies.get(id),
|
||||
publicList: () => [...lobbies.values()].filter(lobby => !lobby.private).map(lobby => ({lobbyId: lobby.id, host: lobby.host, players: lobby.players.length, gameStarted: Boolean(lobby.game)})),
|
||||
state: (lobby, username) => lobby.game ? engine.state(lobby.game, username, lobby.id, config) : null,
|
||||
lobbyState: lobby => ({type: 'lobby:state', lobbyId: lobby.id, host: lobby.host, players: lobby.players, isPublic: !lobby.private,
|
||||
canStart: lobby.players.length >= 2 && !lobby.game, gameStarted: Boolean(lobby.game), gameUrl: `/poker.html?lobbyId=${lobby.id}`}),
|
||||
async close() { closing = true; for (const timer of timers.values()) clearTimeout(timer); await queue.drain(); }
|
||||
};
|
||||
}
|
||||
module.exports = {createLobbyService};
|
||||
Reference in New Issue
Block a user