const { startHand, applyAction, getLegalActions, getPrivateCards, nextSeatAfter, STREETS, PokerEngineError } = require("./pokerEngine"); /* * ============================================================ * CONFIG * ============================================================ */ const SPECIAL_GAME_COLLECTION = "items_special_games"; const POKER_GAME_TYPE = "poker"; const POKER_MEMBERSHIP_TYPE = "poker_membership"; const POKER_MIN_PLAYERS = 2; const POKER_MAX_PLAYERS = 6; const POKER_DEFAULT_SMALL_BLIND = 100; const POKER_DEFAULT_BIG_BLIND = 200; const POKER_DEFAULT_BUY_IN = 5000; const POKER_TURN_TIMEOUT = 30; const POKER_LOBBY_TIMEOUT = 15 * 60; const POKER_MEMBERSHIP_RECOVERY_AGE = 60; const POKER_ACTIVE_STATES = [ "PREFLOP", "FLOP", "TURN", "RIVER" ]; const POKER_OPEN_STATES = [ "WAITING", "NEXT_HAND" ]; /* * ============================================================ * ERROR TYPE * ============================================================ */ class PokerTableError extends Error { constructor( code, message ) { super(message); this.name = "PokerTableError"; this.code = code; } } /* * ============================================================ * GENERIC HELPERS * ============================================================ */ function clone( value ) { return value === undefined ? undefined : structuredClone( value ); } function nowSeconds() { return Math.floor( Date.now() / 1000 ); } function createId( prefix = "poker" ) { const random = Math.random() .toString(36) .slice(2, 10); return [ prefix, Date.now().toString(36), random ].join("_"); } function membershipGameId( guildId, userId ) { return `poker_membership:${guildId}:${userId}`; } function buyInOperationId( gameId, sessionId ) { return `poker_buyin:${gameId}:${sessionId}`; } function buyInRefundOperationId( gameId, sessionId ) { return `poker_buyin_refund:${gameId}:${sessionId}`; } function cashOutOperationId( gameId, sessionId ) { return `poker_cashout:${gameId}:${sessionId}`; } function validatePositiveInteger( value, code, message ) { if ( !Number.isSafeInteger( value ) || value <= 0 ) { throw new PokerTableError( code, message ); } } function validateTableConfig({ buyIn, smallBlind, bigBlind, maxPlayers }) { validatePositiveInteger( buyIn, "INVALID_BUY_IN", "Poker buy-in must be a positive integer." ); validatePositiveInteger( smallBlind, "INVALID_SMALL_BLIND", "Small blind must be a positive integer." ); validatePositiveInteger( bigBlind, "INVALID_BIG_BLIND", "Big blind must be a positive integer." ); if ( smallBlind >= bigBlind ) { throw new PokerTableError( "INVALID_BLINDS", "Small blind must be lower than the big blind." ); } if ( buyIn < bigBlind ) { throw new PokerTableError( "BUY_IN_TOO_SMALL", "Poker buy-in must be at least one big blind." ); } if ( !Number.isSafeInteger( maxPlayers ) || maxPlayers < POKER_MIN_PLAYERS || maxPlayers > POKER_MAX_PLAYERS ) { throw new PokerTableError( "INVALID_MAX_PLAYERS", "Poker tables support between 2 and 6 players." ); } } function isHandActive( game ) { return POKER_ACTIVE_STATES.includes( game?.state ); } function isTableOpenForJoin( game ) { return POKER_OPEN_STATES.includes( game?.state ); } function getPlayer( game, userId ) { return game?.data?.players?.find( player => player.userId === String(userId) ) ?? null; } function getPlayerBySession( game, sessionId ) { return game?.data?.players?.find( player => player.sessionId === sessionId ) ?? null; } function nextAvailableSeat( players, maxPlayers ) { const occupied = new Set( players.map( player => player.seat ) ); for ( let seat = 0; seat < maxPlayers; seat++ ) { if ( !occupied.has( seat ) ) { return seat; } } return null; } function nextDealerSeat( players, previousDealerSeat ) { if ( players.length === 0 ) { return null; } if ( previousDealerSeat === null || previousDealerSeat === undefined ) { return players .map( player => player.seat ) .sort( (a, b) => a - b )[0]; } return nextSeatAfter( players, previousDealerSeat, () => true ); } function sanitizeGameForCaller( game, userId = null ) { if (!game) { return null; } const result = clone(game); if ( result.data?.hand ) { for ( const player of result.data.hand.players ) { if ( String(player.userId) !== String(userId) ) { player.holeCards = []; } } result.data.hand.deck = []; } return result; } function gameHasUser( game, userId ) { return Boolean( getPlayer( game, userId ) ); } function activeEnginePlayers( game ) { return game.data.players .filter( player => player.status === "ACTIVE" && player.stack >= game.data.bigBlind ) .map( player => ({ userId: player.userId, seat: player.seat, stack: player.stack }) ); } function applyTurnDeadline( game, now, timeoutSeconds ) { const hand = game.data.hand; if ( hand && STREETS.includes( hand.state ) && hand.currentSeat !== null ) { game.data.turnStartedAt = now; game.data.turnExpiresAt = now + timeoutSeconds; } else { game.data.turnStartedAt = null; game.data.turnExpiresAt = null; } } function syncCompletedHand( game, now ) { const hand = game.data.hand; if ( !hand || hand.state !== "PAYOUT" ) { return false; } for ( const handPlayer of hand.players ) { const tablePlayer = game.data.players.find( player => player.userId === handPlayer.userId ); if (!tablePlayer) { continue; } tablePlayer.stack = handPlayer.stack; tablePlayer.status = tablePlayer.stack >= game.data.bigBlind ? "ACTIVE" : "BUSTED"; } game.data.lastHandResult = clone( hand.result ); game.data.handCompletedAt = now; game.state = "NEXT_HAND"; game.data.turnStartedAt = null; game.data.turnExpiresAt = null; return true; } function publicTableState( game ) { if (!game) { return null; } const result = { gameId: game.gameId, gameType: game.gameType, guildId: game.guildId, channelId: game.channelId, messageId: game.messageId, state: game.state, version: game.version, createdAt: game.createdAt, updatedAt: game.updatedAt, data: { ownerId: game.data.ownerId, buyIn: game.data.buyIn, smallBlind: game.data.smallBlind, bigBlind: game.data.bigBlind, maxPlayers: game.data.maxPlayers, lobbyExpiresAt: game.data.lobbyExpiresAt, dealerSeat: game.data.dealerSeat, handNumber: game.data.handNumber, turnStartedAt: game.data.turnStartedAt, turnExpiresAt: game.data.turnExpiresAt, players: game.data.players.map( player => ({ userId: player.userId, seat: player.seat, stack: player.stack, status: player.status, joinedAt: player.joinedAt }) ), lastHandResult: clone( game.data.lastHandResult ), hand: null } }; if ( game.data.hand ) { const hand = game.data.hand; result.data.hand = { state: hand.state, street: hand.street, dealerSeat: hand.dealerSeat, smallBlindSeat: hand.smallBlindSeat, bigBlindSeat: hand.bigBlindSeat, currentSeat: hand.currentSeat, smallBlind: hand.smallBlind, bigBlind: hand.bigBlind, handCap: hand.handCap, pot: hand.pot, currentBet: hand.currentBet, communityCards: clone( hand.communityCards ), players: hand.players.map( player => ({ userId: player.userId, seat: player.seat, stack: player.stack, folded: player.folded, allIn: player.allIn, streetBet: player.streetBet, totalBet: player.totalBet }) ), result: clone( hand.result ) }; } return result; } /* * ============================================================ * MONGO STORE * ============================================================ */ function getSpecialGameCollection() { const { getCollections } = require( "./cookieEconomy" ); const { db } = getCollections(); return db.collection( SPECIAL_GAME_COLLECTION ); } function createMongoPokerStore( collection = getSpecialGameCollection() ) { return { async getGame( gameId ) { return collection.findOne({ gameId, gameType: POKER_GAME_TYPE, recordType: "game" }); }, async insertGame( game ) { await collection.insertOne( clone(game) ); return clone(game); }, async updateGameVersion( gameId, expectedVersion, nextGame ) { const replacement = clone(nextGame); const result = await collection.replaceOne( { gameId, gameType: POKER_GAME_TYPE, recordType: "game", version: expectedVersion }, replacement ); return result.modifiedCount === 1; }, async findActiveGameByPlayer( guildId, userId ) { return collection.findOne({ gameType: POKER_GAME_TYPE, recordType: "game", guildId, state: { $nin: [ "CLOSED" ] }, "data.players": { $elemMatch: { userId: String(userId) } } }); }, async listJoinableGames( guildId, channelId = null, limit = 25 ) { const filter = { gameType: POKER_GAME_TYPE, recordType: "game", guildId, state: { $in: POKER_OPEN_STATES } }; if (channelId) { filter.channelId = channelId; } return collection .find(filter) .sort({ updatedAt: -1 }) .limit( Math.max( 1, Math.min( 25, Number(limit) || 25 ) ) ) .toArray(); }, async claimMembership({ guildId, userId, gameId, sessionId, buyIn, createdAt }) { const document = { gameId: membershipGameId( guildId, userId ), gameType: POKER_MEMBERSHIP_TYPE, recordType: "membership", guildId, channelId: null, messageId: null, state: "PENDING", version: 1, createdAt, updatedAt: createdAt, data: { userId: String(userId), tableGameId: gameId, sessionId, buyIn } }; try { await collection.insertOne( document ); return clone(document); } catch (error) { if ( error?.code === 11000 ) { throw new PokerTableError( "PLAYER_ALREADY_SEATED", "Player is already seated at a Poker table." ); } throw error; } }, async getMembership( guildId, userId ) { return collection.findOne({ gameId: membershipGameId( guildId, userId ), gameType: POKER_MEMBERSHIP_TYPE, recordType: "membership" }); }, async activateMembership( guildId, userId, gameId, sessionId, updatedAt ) { const result = await collection.updateOne( { gameId: membershipGameId( guildId, userId ), gameType: POKER_MEMBERSHIP_TYPE, recordType: "membership", "data.tableGameId": gameId, "data.sessionId": sessionId }, { $set: { state: "ACTIVE", updatedAt }, $inc: { version: 1 } } ); return result.modifiedCount === 1; }, async releaseMembership( guildId, userId, gameId, sessionId ) { const result = await collection.deleteOne({ gameId: membershipGameId( guildId, userId ), gameType: POKER_MEMBERSHIP_TYPE, recordType: "membership", "data.tableGameId": gameId, "data.sessionId": sessionId }); return result.deletedCount === 1; }, async listPendingMemberships( olderThan ) { return collection.find({ gameType: POKER_MEMBERSHIP_TYPE, recordType: "membership", state: "PENDING", updatedAt: { $lte: olderThan } }).toArray(); }, async listExpiredTurnGames( now ) { return collection.find({ gameType: POKER_GAME_TYPE, recordType: "game", state: { $in: POKER_ACTIVE_STATES }, "data.turnExpiresAt": { $lte: now, $ne: null } }).toArray(); }, async listExpiredLobbies( now ) { return collection.find({ gameType: POKER_GAME_TYPE, recordType: "game", state: "WAITING", "data.lobbyExpiresAt": { $lte: now } }).toArray(); }, async listPendingCashoutGames() { return collection.find({ gameType: POKER_GAME_TYPE, recordType: "game", "data.players": { $elemMatch: { cashOutState: "PENDING" } } }).toArray(); }, async listClosingGames() { return collection.find({ gameType: POKER_GAME_TYPE, recordType: "game", state: "CLOSING" }).toArray(); } }; } /* * ============================================================ * DEFAULT ECONOMY ADAPTER * ============================================================ */ function createDefaultPokerEconomy() { return { async changeOnce( userId, amount, operationId ) { const { changeCookiesOnce } = require( "./cookieEconomy" ); return changeCookiesOnce( userId, amount, operationId ); }, async hasOperation( userId, operationId ) { const { hasCookieOperation } = require( "./cookieEconomy" ); return hasCookieOperation( userId, operationId ); } }; } /* * ============================================================ * POKER SERVICE * ============================================================ */ function createPokerService({ store = createMongoPokerStore(), economy = createDefaultPokerEconomy(), now = nowSeconds, idFactory = createId, turnTimeout = POKER_TURN_TIMEOUT, lobbyTimeout = POKER_LOBBY_TIMEOUT } = {}) { async function readGame( gameId ) { const game = await store.getGame( gameId ); if (!game) { throw new PokerTableError( "TABLE_NOT_FOUND", "This Poker table is no longer active." ); } return game; } async function claimAndDebitSeat({ guildId, userId, gameId, buyIn }) { const sessionId = idFactory( "poker_session" ); const timestamp = now(); await store.claimMembership({ guildId, userId, gameId, sessionId, buyIn, createdAt: timestamp }); try { await economy.changeOnce( userId, -buyIn, buyInOperationId( gameId, sessionId ) ); } catch (error) { await store.releaseMembership( guildId, userId, gameId, sessionId ); throw error; } return { sessionId, timestamp }; } async function refundFailedSeat({ guildId, userId, gameId, sessionId, buyIn }) { try { await economy.changeOnce( userId, buyIn, buyInRefundOperationId( gameId, sessionId ) ); } finally { await store.releaseMembership( guildId, userId, gameId, sessionId ); } } async function createTable({ guildId, channelId, ownerId, buyIn = POKER_DEFAULT_BUY_IN, smallBlind = POKER_DEFAULT_SMALL_BLIND, bigBlind = POKER_DEFAULT_BIG_BLIND, maxPlayers = POKER_MAX_PLAYERS }) { validateTableConfig({ buyIn, smallBlind, bigBlind, maxPlayers }); if ( !guildId || !channelId || !ownerId ) { throw new PokerTableError( "INVALID_TABLE_CONTEXT", "Guild, channel, and owner are required." ); } const gameId = idFactory( "poker" ); const { sessionId, timestamp } = await claimAndDebitSeat({ guildId, userId: ownerId, gameId, buyIn }); const game = { gameId, gameType: POKER_GAME_TYPE, recordType: "game", guildId, channelId, messageId: null, state: "WAITING", version: 1, createdAt: timestamp, updatedAt: timestamp, data: { ownerId: String(ownerId), buyIn, smallBlind, bigBlind, maxPlayers, lobbyExpiresAt: timestamp + lobbyTimeout, dealerSeat: null, handNumber: 0, hand: null, lastHandResult: null, handCompletedAt: null, turnStartedAt: null, turnExpiresAt: null, players: [ { userId: String(ownerId), seat: 0, stack: buyIn, buyIn, sessionId, status: "ACTIVE", cashOutState: "NONE", joinedAt: timestamp } ] } }; try { await store.insertGame( game ); await store.activateMembership( guildId, ownerId, gameId, sessionId, now() ); } catch (error) { await refundFailedSeat({ guildId, userId: ownerId, gameId, sessionId, buyIn }); throw error; } return clone(game); } async function joinTable({ gameId, guildId, userId }) { const game = await readGame( gameId ); if ( game.guildId !== guildId ) { throw new PokerTableError( "WRONG_GUILD", "Poker table belongs to another guild." ); } if ( !isTableOpenForJoin( game ) ) { throw new PokerTableError( "TABLE_NOT_JOINABLE", "Players can only join while the table is waiting." ); } if ( gameHasUser( game, userId ) ) { throw new PokerTableError( "PLAYER_ALREADY_SEATED", "Player is already seated at this Poker table." ); } if ( game.data.players.length >= game.data.maxPlayers ) { throw new PokerTableError( "TABLE_FULL", "Poker table is full." ); } const seat = nextAvailableSeat( game.data.players, game.data.maxPlayers ); if ( seat === null ) { throw new PokerTableError( "TABLE_FULL", "Poker table is full." ); } const { sessionId, timestamp } = await claimAndDebitSeat({ guildId, userId, gameId, buyIn: game.data.buyIn }); const nextGame = clone(game); nextGame.data.players.push({ userId: String(userId), seat, stack: game.data.buyIn, buyIn: game.data.buyIn, sessionId, status: "ACTIVE", cashOutState: "NONE", joinedAt: timestamp }); nextGame.version = game.version + 1; nextGame.updatedAt = timestamp; const committed = await store.updateGameVersion( gameId, game.version, nextGame ); if (!committed) { await refundFailedSeat({ guildId, userId, gameId, sessionId, buyIn: game.data.buyIn }); throw new PokerTableError( "STATE_CONFLICT", "Poker table changed while you were joining. Please try again." ); } try { await store.activateMembership( guildId, userId, gameId, sessionId, now() ); } catch (error) { console.error( "[Poker] Failed to activate membership after successful join:", { gameId, userId, sessionId, error } ); } return clone(nextGame); } async function getTable({ gameId }) { return readGame( gameId ); } async function listJoinableTables({ guildId, channelId = null, limit = 25 }) { if (!guildId) { throw new PokerTableError( "INVALID_TABLE_CONTEXT", "Guild is required." ); } const games = await store.listJoinableGames( guildId, channelId, limit ); return games.map( game => publicTableState( game ) ); } async function getMyTable({ guildId, userId }) { const membership = await store.getMembership( guildId, userId ); if ( !membership || !membership.data ?.tableGameId ) { return null; } return store.getGame( membership.data .tableGameId ); } async function setMessageId({ gameId, messageId }) { const game = await readGame( gameId ); const nextGame = clone(game); nextGame.messageId = messageId; nextGame.version = game.version + 1; nextGame.updatedAt = now(); const committed = await store.updateGameVersion( gameId, game.version, nextGame ); if (!committed) { throw new PokerTableError( "STATE_CONFLICT", "Poker table changed while setting its Discord message." ); } return clone(nextGame); } async function startTableHand({ gameId, userId }) { const game = await readGame( gameId ); if ( String(game.data.ownerId) !== String(userId) ) { throw new PokerTableError( "NOT_TABLE_OWNER", "Only the Poker table owner can start the next hand." ); } if ( !POKER_OPEN_STATES.includes( game.state ) ) { throw new PokerTableError( "HAND_ALREADY_ACTIVE", "Poker hand is already active." ); } const players = activeEnginePlayers( game ); if ( players.length < POKER_MIN_PLAYERS ) { throw new PokerTableError( "NOT_ENOUGH_PLAYERS", "At least two players with one big blind are required." ); } const dealerSeat = nextDealerSeat( players, game.data.dealerSeat ); const hand = startHand({ players, dealerSeat, smallBlind: game.data.smallBlind, bigBlind: game.data.bigBlind }); const nextGame = clone(game); nextGame.state = hand.state; nextGame.data.hand = hand; nextGame.data.dealerSeat = dealerSeat; nextGame.data.handNumber = game.data.handNumber + 1; nextGame.data.lastHandResult = null; nextGame.data.handCompletedAt = null; applyTurnDeadline( nextGame, now(), turnTimeout ); nextGame.version = game.version + 1; nextGame.updatedAt = now(); const committed = await store.updateGameVersion( gameId, game.version, nextGame ); if (!committed) { throw new PokerTableError( "STATE_CONFLICT", "Poker table changed while the hand was starting." ); } return clone(nextGame); } async function commitAction({ game, userId, action, allowExpired = false }) { if ( !isHandActive( game ) ) { throw new PokerTableError( "HAND_NOT_ACTIVE", "Poker hand is not accepting actions." ); } const timestamp = now(); if ( !allowExpired && game.data.turnExpiresAt !== null && timestamp > game.data.turnExpiresAt ) { throw new PokerTableError( "TURN_EXPIRED", "Your Poker turn has already expired." ); } let hand; try { hand = applyAction( game.data.hand, userId, action ); } catch (error) { if ( error instanceof PokerEngineError ) { throw new PokerTableError( error.code, error.message ); } throw error; } const nextGame = clone(game); nextGame.data.hand = hand; nextGame.state = hand.state; if ( hand.state === "PAYOUT" ) { syncCompletedHand( nextGame, timestamp ); } else { applyTurnDeadline( nextGame, timestamp, turnTimeout ); } nextGame.version = game.version + 1; nextGame.updatedAt = timestamp; const committed = await store.updateGameVersion( game.gameId, game.version, nextGame ); if (!committed) { throw new PokerTableError( "STATE_CONFLICT", "Poker table changed before this action could be committed." ); } return clone(nextGame); } async function act({ gameId, userId, action }) { const game = await readGame( gameId ); return commitAction({ game, userId, action, allowExpired: false }); } async function getLegalPlayerActions({ gameId, userId }) { const game = await readGame( gameId ); if ( !game.data.hand ) { return { actions: [], reason: "HAND_NOT_ACTIVE" }; } return getLegalActions( game.data.hand, userId ); } async function viewCards({ gameId, userId }) { const game = await readGame( gameId ); if ( !gameHasUser( game, userId ) ) { throw new PokerTableError( "NOT_TABLE_PLAYER", "You are not seated at this Poker table." ); } if ( !game.data.hand ) { throw new PokerTableError( "HAND_NOT_ACTIVE", "There is no active Poker hand." ); } try { return getPrivateCards( game.data.hand, userId ); } catch (error) { if ( error instanceof PokerEngineError ) { throw new PokerTableError( error.code, error.message ); } throw error; } } async function processTurnTimeout( gameId ) { const game = await readGame( gameId ); if ( !isHandActive( game ) ) { return { processed: false, reason: "HAND_NOT_ACTIVE" }; } const timestamp = now(); if ( game.data.turnExpiresAt === null || timestamp < game.data.turnExpiresAt ) { return { processed: false, reason: "NOT_EXPIRED" }; } const currentSeat = game.data.hand.currentSeat; const player = game.data.hand.players.find( candidate => candidate.seat === currentSeat ); if (!player) { return { processed: false, reason: "NO_CURRENT_PLAYER" }; } const nextGame = await commitAction({ game, userId: player.userId, action: { type: "fold" }, allowExpired: true }); return { processed: true, userId: player.userId, game: nextGame }; } async function processExpiredTurns() { const timestamp = now(); const games = await store.listExpiredTurnGames( timestamp ); const results = []; for (const game of games) { try { results.push( await processTurnTimeout( game.gameId ) ); } catch (error) { if ( error?.code === "STATE_CONFLICT" ) { results.push({ processed: false, reason: "STATE_CONFLICT", gameId: game.gameId }); continue; } throw error; } } return results; } async function markCashOutPending({ game, userId }) { if ( isHandActive( game ) ) { throw new PokerTableError( "HAND_ACTIVE", "You cannot cash out during an active Poker hand." ); } const player = getPlayer( game, userId ); if (!player) { throw new PokerTableError( "NOT_TABLE_PLAYER", "You are not seated at this Poker table." ); } if ( player.cashOutState === "PENDING" ) { return game; } const nextGame = clone(game); const nextPlayer = getPlayer( nextGame, userId ); nextPlayer.cashOutState = "PENDING"; nextGame.version = game.version + 1; nextGame.updatedAt = now(); const committed = await store.updateGameVersion( game.gameId, game.version, nextGame ); if (!committed) { throw new PokerTableError( "STATE_CONFLICT", "Poker table changed while cash-out was starting." ); } return nextGame; } async function finalizeCashOut({ gameId, userId, sessionId }) { for ( let attempt = 0; attempt < 5; attempt++ ) { const game = await readGame( gameId ); const player = getPlayerBySession( game, sessionId ); if (!player) { return game; } if ( player.userId !== String(userId) ) { throw new PokerTableError( "CASHOUT_SESSION_MISMATCH", "Cash-out session does not belong to this player." ); } const nextGame = clone(game); nextGame.data.players = nextGame.data.players.filter( candidate => candidate.sessionId !== sessionId ); if ( nextGame.data.ownerId === String(userId) ) { nextGame.data.ownerId = nextGame.data.players .slice() .sort( (a, b) => a.seat - b.seat )[0] ?.userId ?? null; } if ( nextGame.data.players.length === 0 ) { nextGame.state = "CLOSED"; nextGame.data.turnStartedAt = null; nextGame.data.turnExpiresAt = null; } nextGame.version = game.version + 1; nextGame.updatedAt = now(); const committed = await store.updateGameVersion( gameId, game.version, nextGame ); if (committed) { return nextGame; } } throw new PokerTableError( "STATE_CONFLICT", "Poker table kept changing while cash-out was finalizing." ); } async function cashOut({ gameId, userId }) { let game = await readGame( gameId ); game = await markCashOutPending({ game, userId }); const player = getPlayer( game, userId ); if (!player) { throw new PokerTableError( "NOT_TABLE_PLAYER", "You are not seated at this Poker table." ); } const amount = player.stack; if ( amount > 0 ) { await economy.changeOnce( userId, amount, cashOutOperationId( gameId, player.sessionId ) ); } const nextGame = await finalizeCashOut({ gameId, userId, sessionId: player.sessionId }); await store.releaseMembership( game.guildId, userId, gameId, player.sessionId ); return { amount, game: clone(nextGame) }; } async function finishClosingTable( gameId ) { for ( let attempt = 0; attempt < 5; attempt++ ) { const current = await readGame( gameId ); if ( current.state === "CLOSED" ) { return current; } if ( current.state !== "CLOSING" ) { throw new PokerTableError( "TABLE_NOT_CLOSING", "Poker table is not in a closing state." ); } for ( const player of current.data.players .slice() ) { if ( player.stack > 0 ) { await economy.changeOnce( player.userId, player.stack, cashOutOperationId( gameId, player.sessionId ) ); } await store.releaseMembership( current.guildId, player.userId, gameId, player.sessionId ); } const closed = clone(current); closed.state = "CLOSED"; closed.data.players = []; closed.data.ownerId = null; closed.data.turnStartedAt = null; closed.data.turnExpiresAt = null; closed.version = current.version + 1; closed.updatedAt = now(); if ( await store.updateGameVersion( gameId, current.version, closed ) ) { return closed; } } throw new PokerTableError( "STATE_CONFLICT", "Poker table kept changing while it was closing." ); } async function expireLobby( gameId ) { const game = await readGame( gameId ); if ( game.state === "CLOSING" ) { return { expired: true, game: await finishClosingTable( gameId ) }; } if ( game.state !== "WAITING" ) { return { expired: false, reason: "NOT_WAITING" }; } if ( now() < game.data.lobbyExpiresAt ) { return { expired: false, reason: "NOT_EXPIRED" }; } const nextGame = clone(game); nextGame.state = "CLOSING"; nextGame.version = game.version + 1; nextGame.updatedAt = now(); const committed = await store.updateGameVersion( gameId, game.version, nextGame ); if (!committed) { return { expired: false, reason: "STATE_CONFLICT" }; } return { expired: true, game: await finishClosingTable( gameId ) }; } async function processExpiredLobbies() { const games = await store.listExpiredLobbies( now() ); const results = []; for (const game of games) { results.push( await expireLobby( game.gameId ) ); } return results; } async function recoverPendingCashouts() { const games = await store.listPendingCashoutGames(); const results = []; for ( const game of games ) { const pendingPlayers = game.data.players .filter( player => player.cashOutState === "PENDING" ) .map(clone); for ( const player of pendingPlayers ) { if ( player.stack > 0 ) { await economy.changeOnce( player.userId, player.stack, cashOutOperationId( game.gameId, player.sessionId ) ); } await finalizeCashOut({ gameId: game.gameId, userId: player.userId, sessionId: player.sessionId }); await store.releaseMembership( game.guildId, player.userId, game.gameId, player.sessionId ); results.push({ gameId: game.gameId, userId: player.userId, action: "cashout_finalized" }); } } return results; } async function recoverClosingTables() { const games = await store.listClosingGames(); const results = []; for ( const game of games ) { results.push({ gameId: game.gameId, game: await finishClosingTable( game.gameId ) }); } return results; } async function recoverPendingMemberships() { const olderThan = now() - POKER_MEMBERSHIP_RECOVERY_AGE; const memberships = await store.listPendingMemberships( olderThan ); const results = []; for ( const membership of memberships ) { const { userId, tableGameId, sessionId, buyIn } = membership.data; const game = await store.getGame( tableGameId ); const player = game ? getPlayerBySession( game, sessionId ) : null; if (player) { await store.activateMembership( membership.guildId, userId, tableGameId, sessionId, now() ); results.push({ userId, gameId: tableGameId, action: "activated" }); continue; } const debitOperationId = buyInOperationId( tableGameId, sessionId ); const debitApplied = await economy.hasOperation( userId, debitOperationId ); if (debitApplied) { await economy.changeOnce( userId, buyIn, buyInRefundOperationId( tableGameId, sessionId ) ); } await store.releaseMembership( membership.guildId, userId, tableGameId, sessionId ); results.push({ userId, gameId: tableGameId, action: debitApplied ? "refunded" : "released" }); } return results; } async function recover() { const [ memberships, cashouts, closingTables, turns, lobbies ] = await Promise.all([ recoverPendingMemberships(), recoverPendingCashouts(), recoverClosingTables(), processExpiredTurns(), processExpiredLobbies() ]); return { memberships, cashouts, closingTables, turns, lobbies }; } return { createTable, joinTable, getTable, listJoinableTables, getMyTable, setMessageId, startTableHand, act, getLegalPlayerActions, viewCards, processTurnTimeout, processExpiredTurns, cashOut, finishClosingTable, expireLobby, processExpiredLobbies, recoverPendingCashouts, recoverClosingTables, recoverPendingMemberships, recover, publicTableState, sanitizeGameForCaller }; } /* * ============================================================ * DEFAULT SERVICE * ============================================================ */ let defaultService = null; function getPokerService() { if (!defaultService) { defaultService = createPokerService(); } return defaultService; } /* * ============================================================ * EXPORTS * ============================================================ */ module.exports = { SPECIAL_GAME_COLLECTION, POKER_GAME_TYPE, POKER_MEMBERSHIP_TYPE, POKER_MIN_PLAYERS, POKER_MAX_PLAYERS, POKER_DEFAULT_SMALL_BLIND, POKER_DEFAULT_BIG_BLIND, POKER_DEFAULT_BUY_IN, POKER_TURN_TIMEOUT, POKER_LOBBY_TIMEOUT, PokerTableError, createMongoPokerStore, createDefaultPokerEconomy, createPokerService, getPokerService, publicTableState, sanitizeGameForCaller, membershipGameId, buyInOperationId, buyInRefundOperationId, cashOutOperationId };