Files
ShaiBot/utils/crumbPoker.js
T
2026-09-08 21:49:43 +02:00

3149 lines
67 KiB
JavaScript

const {
startHand,
applyAction,
getLegalActions,
getPrivateCards,
nextSeatAfter,
STREETS,
PokerEngineError
} = require("./pokerEngine");
const {
recordPokerActionProfile,
recordPokerHandProfiles
} = require("./pokerProfile");
/*
* ============================================================
* 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 =
10;
const POKER_DEFAULT_BIG_BLIND =
20;
const POKER_DEFAULT_BUY_IN =
1000;
const POKER_TURN_TIMEOUT =
90;
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;
/*
* A completed hand starts a fresh between-hand lobby
* window. Do not inherit the original table-creation
* expiry, otherwise a healthy table can close shortly
* after a long hand finishes.
*/
game.data.lobbyExpiresAt =
now +
POKER_LOBBY_TIMEOUT;
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 stripMongoId(
document
) {
if (!document) {
return null;
}
const {
_id,
...rest
} = document;
return rest;
}
function createMongoPokerStore(
collection =
getSpecialGameCollection()
) {
return {
async getGame(
gameId
) {
const document =
await collection.findOne({
gameId,
gameType:
POKER_GAME_TYPE,
recordType:
"game"
});
return stripMongoId(
document
);
},
async insertGame(
game
) {
await collection.insertOne(
clone(game)
);
return clone(game);
},
async updateGameVersion(
gameId,
expectedVersion,
nextGame
) {
const replacement =
clone(nextGame);
/*
* MongoDB owns _id. Domain game objects must never
* replace it, especially after structuredClone()
* has converted a BSON ObjectId into a plain object.
*/
delete replacement._id;
const result =
await collection.replaceOne(
{
gameId,
gameType:
POKER_GAME_TYPE,
recordType:
"game",
version:
expectedVersion
},
replacement
);
return result.modifiedCount ===
1;
},
async findActiveGameByPlayer(
guildId,
userId
) {
const document =
await collection.findOne({
gameType:
POKER_GAME_TYPE,
recordType:
"game",
guildId,
state: {
$nin: [
"CLOSED"
]
},
"data.players": {
$elemMatch: {
userId:
String(userId)
}
}
});
return stripMongoId(
document
);
},
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;
}
const documents =
await collection
.find(filter)
.sort({
updatedAt: -1
})
.limit(
Math.max(
1,
Math.min(
25,
Number(limit) || 25
)
)
)
.toArray();
return documents.map(
stripMongoId
);
},
async deleteGame(
gameId
) {
const result =
await collection.deleteOne({
gameId,
gameType:
POKER_GAME_TYPE,
recordType:
"game"
});
return result.deletedCount ===
1;
},
async listActiveGames() {
const documents =
await collection
.find({
gameType:
POKER_GAME_TYPE,
recordType:
"game",
state: {
$nin: [
"CLOSED"
]
}
})
.sort({
updatedAt: -1
})
.toArray();
return documents.map(
stripMongoId
);
},
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
);
},
async clearOperation(
userId,
operationId
) {
const {
clearCookieOperation
} = require(
"./cookieEconomy"
);
return clearCookieOperation(
userId,
operationId
);
}
};
}
/*
* ============================================================
* POKER SERVICE
* ============================================================
*/
function createPokerService({
store =
createMongoPokerStore(),
economy =
createDefaultPokerEconomy(),
now =
nowSeconds,
idFactory =
createId,
turnTimeout =
POKER_TURN_TIMEOUT,
lobbyTimeout =
POKER_LOBBY_TIMEOUT
} = {}) {
async function clearOperationQuietly(
userId,
operationId
) {
try {
await economy.clearOperation(
userId,
operationId
);
}
catch (error) {
console.error(
`[Poker] Failed to clear completed Cookie operation ${operationId} for ${userId}:`,
error
);
}
}
async function deleteGameQuietly(
gameId
) {
try {
await store.deleteGame(
gameId
);
}
catch (error) {
console.error(
`[Poker] Failed to delete settled table ${gameId}:`,
error
);
}
}
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
);
await clearOperationQuietly(
userId,
buyInOperationId(
gameId,
sessionId
)
);
await clearOperationQuietly(
userId,
buyInRefundOperationId(
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()
);
await clearOperationQuietly(
ownerId,
buyInOperationId(
gameId,
sessionId
)
);
}
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()
);
await clearOperationQuietly(
userId,
buyInOperationId(
gameId,
sessionId
)
);
}
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;
/*
* Lobby expiry only applies while waiting between
* hands. Active hands are governed by turn timers.
*/
nextGame.data.lobbyExpiresAt =
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."
);
}
/*
* Profile/stat history is secondary to the authoritative
* Poker state. A profile failure must never invalidate a
* successfully committed table action or payout.
*/
try {
await recordPokerActionProfile(
nextGame,
userId,
action
);
if (
nextGame.data.hand
?.state ===
"PAYOUT"
) {
await recordPokerHandProfiles(
nextGame
);
}
}
catch (error) {
console.error(
`[PokerProfile] Failed to persist profile history for ${nextGame.gameId}:`,
error
);
}
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
);
await clearOperationQuietly(
userId,
cashOutOperationId(
gameId,
player.sessionId
)
);
/*
* Defensive cleanup for sessions created before
* buy-in markers were cleared immediately after
* durable table membership was committed.
*/
await clearOperationQuietly(
userId,
buyInOperationId(
gameId,
player.sessionId
)
);
if (
nextGame.state ===
"CLOSED"
) {
await deleteGameQuietly(
gameId
);
}
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
)
) {
for (
const player
of current.data.players
) {
await clearOperationQuietly(
player.userId,
cashOutOperationId(
gameId,
player.sessionId
)
);
await clearOperationQuietly(
player.userId,
buyInOperationId(
gameId,
player.sessionId
)
);
}
await deleteGameQuietly(
gameId
);
return closed;
}
}
throw new PokerTableError(
"STATE_CONFLICT",
"Poker table kept changing while it was closing."
);
}
async function cancelTable({
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 cancel this table."
);
}
if (
isHandActive(
game
)
) {
throw new PokerTableError(
"CANCEL_HAND_ACTIVE",
"Finish the current hand before cancelling the table."
);
}
if (
game.state ===
"CLOSING"
) {
return finishClosingTable(
gameId
);
}
if (
!POKER_OPEN_STATES.includes(
game.state
)
) {
throw new PokerTableError(
"TABLE_NOT_CANCELLABLE",
"This Poker table cannot be cancelled in its current state."
);
}
const closing =
clone(game);
closing.state =
"CLOSING";
closing.version =
game.version + 1;
closing.updatedAt =
now();
const committed =
await store.updateGameVersion(
gameId,
game.version,
closing
);
if (!committed) {
throw new PokerTableError(
"STATE_CONFLICT",
"Poker table changed while cancellation was starting."
);
}
return finishClosingTable(
gameId
);
}
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
);
await clearOperationQuietly(
player.userId,
cashOutOperationId(
game.gameId,
player.sessionId
)
);
await clearOperationQuietly(
player.userId,
buyInOperationId(
game.gameId,
player.sessionId
)
);
const remainingGame =
await store.getGame(
game.gameId
);
if (
remainingGame?.state ===
"CLOSED"
) {
await deleteGameQuietly(
game.gameId
);
}
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()
);
await clearOperationQuietly(
userId,
buyInOperationId(
tableGameId,
sessionId
)
);
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
);
await clearOperationQuietly(
userId,
buyInOperationId(
tableGameId,
sessionId
)
);
await clearOperationQuietly(
userId,
buyInRefundOperationId(
tableGameId,
sessionId
)
);
results.push({
userId,
gameId:
tableGameId,
action:
debitApplied
? "refunded"
: "released"
});
}
return results;
}
async function listActiveTables() {
return store.listActiveGames();
}
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,
cancelTable,
finishClosingTable,
expireLobby,
processExpiredLobbies,
recoverPendingCashouts,
recoverClosingTables,
recoverPendingMemberships,
recover,
listActiveTables,
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
};