From 903a2f3081dbe455228c27042625e3da121b1766 Mon Sep 17 00:00:00 2001 From: DeSqBlocki Date: Tue, 8 Sep 2026 19:37:02 +0200 Subject: [PATCH] snapshot_poker_phase3 --- .gitignore | 3 +- buttons/crumbSaucer/poker.js | 27 + buttons/poker/allIn.js | 17 + buttons/poker/browse.js | 47 + buttons/poker/call.js | 17 + buttons/poker/cards.js | 77 + buttons/poker/cashOut.js | 67 + buttons/poker/check.js | 17 + buttons/poker/create.js | 81 + buttons/poker/fold.js | 17 + buttons/poker/joinTable.js | 73 + buttons/poker/myTable.js | 66 + buttons/poker/raise.js | 13 + buttons/poker/refresh.js | 65 + buttons/poker/start.js | 67 + events/InteractionCreate.js | 8 +- handlers/poker.js | 225 +++ package.json | 3 +- selectMenus/poker/join.js | 70 + utils/cookieEconomy.js | 247 +++ utils/crumbPoker.js | 2730 ++++++++++++++++++++++++++++++++++ utils/crumbSaucer.js | 252 +++- utils/pokerDiscord.js | 1011 +++++++++++++ utils/pokerEngine.js | 2149 ++++++++++++++++++++++++++ utils/pokerInteractions.js | 168 +++ 25 files changed, 7457 insertions(+), 60 deletions(-) create mode 100644 buttons/crumbSaucer/poker.js create mode 100644 buttons/poker/allIn.js create mode 100644 buttons/poker/browse.js create mode 100644 buttons/poker/call.js create mode 100644 buttons/poker/cards.js create mode 100644 buttons/poker/cashOut.js create mode 100644 buttons/poker/check.js create mode 100644 buttons/poker/create.js create mode 100644 buttons/poker/fold.js create mode 100644 buttons/poker/joinTable.js create mode 100644 buttons/poker/myTable.js create mode 100644 buttons/poker/raise.js create mode 100644 buttons/poker/refresh.js create mode 100644 buttons/poker/start.js create mode 100644 handlers/poker.js create mode 100644 selectMenus/poker/join.js create mode 100644 utils/crumbPoker.js create mode 100644 utils/pokerDiscord.js create mode 100644 utils/pokerEngine.js create mode 100644 utils/pokerInteractions.js diff --git a/.gitignore b/.gitignore index 4b818c1..2305141 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ node_modules .env backup/ -*.bak \ No newline at end of file +*.bak +tests/ \ No newline at end of file diff --git a/buttons/crumbSaucer/poker.js b/buttons/crumbSaucer/poker.js new file mode 100644 index 0000000..43394ae --- /dev/null +++ b/buttons/crumbSaucer/poker.js @@ -0,0 +1,27 @@ +const { + buildPokerHubEmbed, + buildPokerHubComponents, + deferPokerReply +} = require("../../utils/pokerDiscord"); + + +module.exports = { + name: + "crumbSaucerPoker", + + async execute( + interaction + ) { + await deferPokerReply( + interaction + ); + + return interaction.editReply({ + embeds: [ + buildPokerHubEmbed() + ], + components: + buildPokerHubComponents() + }); + } +}; diff --git a/buttons/poker/allIn.js b/buttons/poker/allIn.js new file mode 100644 index 0000000..ff854e8 --- /dev/null +++ b/buttons/poker/allIn.js @@ -0,0 +1,17 @@ +const { + executePokerAction +} = require("../../utils/pokerInteractions"); + +module.exports = { + name: "pokerAllIn", + + async execute(interaction) { + return executePokerAction( + interaction, + { + type: "all_in" + }, + "✅ All-In." + ); + } +}; diff --git a/buttons/poker/browse.js b/buttons/poker/browse.js new file mode 100644 index 0000000..a32391e --- /dev/null +++ b/buttons/poker/browse.js @@ -0,0 +1,47 @@ +const { + getPokerService +} = require("../../utils/crumbPoker"); + +const { + deferPokerReply, + editPokerError, + buildJoinPicker +} = require("../../utils/pokerDiscord"); + + +module.exports = { + name: + "pokerBrowse", + + async execute( + interaction + ) { + await deferPokerReply( + interaction + ); + + try { + const tables = + await getPokerService() + .listJoinableTables({ + guildId: + interaction.guildId, + channelId: + interaction.channelId + }); + + return interaction.editReply( + buildJoinPicker( + tables + ) + ); + } + + catch (error) { + return editPokerError( + interaction, + error + ); + } + } +}; diff --git a/buttons/poker/call.js b/buttons/poker/call.js new file mode 100644 index 0000000..83630dc --- /dev/null +++ b/buttons/poker/call.js @@ -0,0 +1,17 @@ +const { + executePokerAction +} = require("../../utils/pokerInteractions"); + +module.exports = { + name: "pokerCall", + + async execute(interaction) { + return executePokerAction( + interaction, + { + type: "call" + }, + "✅ Called." + ); + } +}; diff --git a/buttons/poker/cards.js b/buttons/poker/cards.js new file mode 100644 index 0000000..b66ed69 --- /dev/null +++ b/buttons/poker/cards.js @@ -0,0 +1,77 @@ +const { + cardToString +} = require("../../utils/pokerEngine"); + +const { + getPokerService +} = require("../../utils/crumbPoker"); + +const { + POKER_CARD_RESPONSE_LIFETIME, + deferPokerReply, + editPokerError, + getPokerGameIdFromInteraction +} = require("../../utils/pokerDiscord"); + + +module.exports = { + name: + "pokerCards", + + async execute( + interaction + ) { + await deferPokerReply( + interaction + ); + + const gameId = + getPokerGameIdFromInteraction( + interaction + ); + + if (!gameId) { + return interaction.editReply({ + content: + "❌ This Poker table is no longer active.", + embeds: [], + components: [] + }); + } + + try { + const cards = + await getPokerService() + .viewCards({ + gameId, + userId: + interaction.user.id + }); + + await interaction.editReply({ + content: + `🃏 Your cards: **${cards.map(cardToString).join(" ")}**`, + embeds: [], + components: [] + }); + + setTimeout( + () => { + interaction + .deleteReply() + .catch(() => {}); + }, + POKER_CARD_RESPONSE_LIFETIME + ); + + return; + } + + catch (error) { + return editPokerError( + interaction, + error + ); + } + } +}; diff --git a/buttons/poker/cashOut.js b/buttons/poker/cashOut.js new file mode 100644 index 0000000..5c23d54 --- /dev/null +++ b/buttons/poker/cashOut.js @@ -0,0 +1,67 @@ +const { + getPokerService +} = require("../../utils/crumbPoker"); + +const { + deferPokerReply, + editPokerError, + getPokerGameIdFromInteraction, + refreshPokerTableMessage +} = require("../../utils/pokerDiscord"); + + +module.exports = { + name: + "pokerCashOut", + + async execute( + interaction + ) { + await deferPokerReply( + interaction + ); + + const gameId = + getPokerGameIdFromInteraction( + interaction + ); + + if (!gameId) { + return interaction.editReply({ + content: + "❌ This Poker table is no longer active.", + embeds: [], + components: [] + }); + } + + try { + const result = + await getPokerService() + .cashOut({ + gameId, + userId: + interaction.user.id + }); + + await refreshPokerTableMessage( + interaction.client, + result.game + ); + + return interaction.editReply({ + content: + `✅ Cashed out **${result.amount.toLocaleString()} Cookies** back to your vault.`, + embeds: [], + components: [] + }); + } + + catch (error) { + return editPokerError( + interaction, + error + ); + } + } +}; diff --git a/buttons/poker/check.js b/buttons/poker/check.js new file mode 100644 index 0000000..bd25083 --- /dev/null +++ b/buttons/poker/check.js @@ -0,0 +1,17 @@ +const { + executePokerAction +} = require("../../utils/pokerInteractions"); + +module.exports = { + name: "pokerCheck", + + async execute(interaction) { + return executePokerAction( + interaction, + { + type: "check" + }, + "✅ Checked." + ); + } +}; diff --git a/buttons/poker/create.js b/buttons/poker/create.js new file mode 100644 index 0000000..a58c00c --- /dev/null +++ b/buttons/poker/create.js @@ -0,0 +1,81 @@ +const { + getPokerService +} = require("../../utils/crumbPoker"); + +const { + deferPokerReply, + editPokerError, + publishPokerTable, + buildOpenTableLink +} = require("../../utils/pokerDiscord"); + + +module.exports = { + name: + "pokerCreate", + + async execute( + interaction + ) { + await deferPokerReply( + interaction + ); + + const service = + getPokerService(); + + let game = null; + + try { + game = + await service.createTable({ + guildId: + interaction.guildId, + channelId: + interaction.channelId, + ownerId: + interaction.user.id + }); + + const published = + await publishPokerTable( + interaction, + game + ); + + return interaction.editReply({ + content: + `✅ Poker table created. **${published.game.data.buyIn.toLocaleString()} Cookies** were moved into your Poker stack.`, + embeds: [], + components: + buildOpenTableLink( + published.game + ) + }); + } + + catch (error) { + if (game) { + try { + await service.cashOut({ + gameId: + game.gameId, + userId: + interaction.user.id + }); + } + catch (refundError) { + console.error( + `[Poker] Failed to refund unpublished table ${game.gameId}:`, + refundError + ); + } + } + + return editPokerError( + interaction, + error + ); + } + } +}; diff --git a/buttons/poker/fold.js b/buttons/poker/fold.js new file mode 100644 index 0000000..8942cbd --- /dev/null +++ b/buttons/poker/fold.js @@ -0,0 +1,17 @@ +const { + executePokerAction +} = require("../../utils/pokerInteractions"); + +module.exports = { + name: "pokerFold", + + async execute(interaction) { + return executePokerAction( + interaction, + { + type: "fold" + }, + "✅ Folded." + ); + } +}; diff --git a/buttons/poker/joinTable.js b/buttons/poker/joinTable.js new file mode 100644 index 0000000..5e74e59 --- /dev/null +++ b/buttons/poker/joinTable.js @@ -0,0 +1,73 @@ +const { + getPokerService +} = require("../../utils/crumbPoker"); + +const { + deferPokerReply, + editPokerError, + getPokerGameIdFromInteraction, + refreshPokerTableMessage, + buildOpenTableLink +} = require("../../utils/pokerDiscord"); + + +module.exports = { + name: + "pokerJoinTable", + + async execute( + interaction + ) { + await deferPokerReply( + interaction + ); + + const gameId = + getPokerGameIdFromInteraction( + interaction + ); + + if (!gameId) { + return interaction.editReply({ + content: + "❌ This Poker table is no longer active.", + embeds: [], + components: [] + }); + } + + try { + const game = + await getPokerService() + .joinTable({ + gameId, + guildId: + interaction.guildId, + userId: + interaction.user.id + }); + + await refreshPokerTableMessage( + interaction.client, + game + ); + + return interaction.editReply({ + content: + `✅ Joined the Poker table for **${game.data.buyIn.toLocaleString()} Cookies**.`, + embeds: [], + components: + buildOpenTableLink( + game + ) + }); + } + + catch (error) { + return editPokerError( + interaction, + error + ); + } + } +}; diff --git a/buttons/poker/myTable.js b/buttons/poker/myTable.js new file mode 100644 index 0000000..2610619 --- /dev/null +++ b/buttons/poker/myTable.js @@ -0,0 +1,66 @@ +const { + getPokerService +} = require("../../utils/crumbPoker"); + +const { + deferPokerReply, + editPokerError, + refreshPokerTableMessage, + buildOpenTableLink +} = require("../../utils/pokerDiscord"); + + +module.exports = { + name: + "pokerMyTable", + + async execute( + interaction + ) { + await deferPokerReply( + interaction + ); + + try { + const game = + await getPokerService() + .getMyTable({ + guildId: + interaction.guildId, + userId: + interaction.user.id + }); + + if (!game) { + return interaction.editReply({ + content: + "❌ You are not currently seated at a Poker table.", + embeds: [], + components: [] + }); + } + + await refreshPokerTableMessage( + interaction.client, + game + ); + + return interaction.editReply({ + content: + "♠️ Your active Poker table:", + embeds: [], + components: + buildOpenTableLink( + game + ) + }); + } + + catch (error) { + return editPokerError( + interaction, + error + ); + } + } +}; diff --git a/buttons/poker/raise.js b/buttons/poker/raise.js new file mode 100644 index 0000000..2ebed76 --- /dev/null +++ b/buttons/poker/raise.js @@ -0,0 +1,13 @@ +const { + executeMinimumRaise +} = require("../../utils/pokerInteractions"); + +module.exports = { + name: "pokerRaise", + + async execute(interaction) { + return executeMinimumRaise( + interaction + ); + } +}; diff --git a/buttons/poker/refresh.js b/buttons/poker/refresh.js new file mode 100644 index 0000000..a27d663 --- /dev/null +++ b/buttons/poker/refresh.js @@ -0,0 +1,65 @@ +const { + getPokerService +} = require("../../utils/crumbPoker"); + +const { + deferPokerReply, + editPokerError, + getPokerGameIdFromInteraction, + refreshPokerTableMessage +} = require("../../utils/pokerDiscord"); + + +module.exports = { + name: + "pokerRefresh", + + async execute( + interaction + ) { + await deferPokerReply( + interaction + ); + + const gameId = + getPokerGameIdFromInteraction( + interaction + ); + + if (!gameId) { + return interaction.editReply({ + content: + "❌ This Poker table is no longer active.", + embeds: [], + components: [] + }); + } + + try { + const game = + await getPokerService() + .getTable({ + gameId + }); + + await refreshPokerTableMessage( + interaction.client, + game + ); + + return interaction.editReply({ + content: + "✅ Poker table refreshed.", + embeds: [], + components: [] + }); + } + + catch (error) { + return editPokerError( + interaction, + error + ); + } + } +}; diff --git a/buttons/poker/start.js b/buttons/poker/start.js new file mode 100644 index 0000000..bf3b714 --- /dev/null +++ b/buttons/poker/start.js @@ -0,0 +1,67 @@ +const { + getPokerService +} = require("../../utils/crumbPoker"); + +const { + deferPokerReply, + editPokerError, + getPokerGameIdFromInteraction, + refreshPokerTableMessage +} = require("../../utils/pokerDiscord"); + + +module.exports = { + name: + "pokerStart", + + async execute( + interaction + ) { + await deferPokerReply( + interaction + ); + + const gameId = + getPokerGameIdFromInteraction( + interaction + ); + + if (!gameId) { + return interaction.editReply({ + content: + "❌ This Poker table is no longer active.", + embeds: [], + components: [] + }); + } + + try { + const game = + await getPokerService() + .startTableHand({ + gameId, + userId: + interaction.user.id + }); + + await refreshPokerTableMessage( + interaction.client, + game + ); + + return interaction.editReply({ + content: + `✅ Hand **#${game.data.handNumber}** started.`, + embeds: [], + components: [] + }); + } + + catch (error) { + return editPokerError( + interaction, + error + ); + } + } +}; diff --git a/events/InteractionCreate.js b/events/InteractionCreate.js index 8718179..4240678 100644 --- a/events/InteractionCreate.js +++ b/events/InteractionCreate.js @@ -28,7 +28,8 @@ module.exports = { if ( interaction.isMentionableSelectMenu() || - interaction.isUserSelectMenu() + interaction.isUserSelectMenu() || + interaction.isStringSelectMenu() ) { return selectMenuHandler(interaction); } @@ -174,6 +175,9 @@ async function sendInteractionError(interaction, content) { await interaction.reply(payload); } } catch (error) { - console.error("Failed to send interaction error response:", error); + console.error( + "Failed to send interaction error response:", + error + ); } } diff --git a/handlers/poker.js b/handlers/poker.js new file mode 100644 index 0000000..a0d7dd1 --- /dev/null +++ b/handlers/poker.js @@ -0,0 +1,225 @@ +const { + getPokerService +} = require("../utils/crumbPoker"); + +const { + refreshPokerTableMessage +} = require("../utils/pokerDiscord"); + + +const TURN_CHECK_INTERVAL = + 5 * 1000; + +const LOBBY_CHECK_INTERVAL = + 30 * 1000; + +const STARTUP_RECOVERY_DELAY = + 10 * 1000; + + +module.exports = client => { + setTimeout( + () => { + recoverPoker( + client + ); + }, + STARTUP_RECOVERY_DELAY + ); + + setInterval( + () => { + processPokerTurns( + client + ); + }, + TURN_CHECK_INTERVAL + ); + + setInterval( + () => { + processPokerLobbies( + client + ); + }, + LOBBY_CHECK_INTERVAL + ); + + console.log( + "[Poker] Recovery and timeout watcher initialized" + ); +}; + + +async function refreshResultGame( + client, + result +) { + const game = + result?.game; + + if (!game) { + return; + } + + await refreshPokerTableMessage( + client, + game + ); +} + + +async function processPokerTurns( + client +) { + if (!client.isReady()) { + return; + } + + try { + const results = + await getPokerService() + .processExpiredTurns(); + + for (const result of results) { + if (result.processed) { + console.log( + `[Poker] Auto-folded ${result.userId} after turn timeout.` + ); + + await refreshResultGame( + client, + result + ); + } + } + } + + catch (error) { + console.error( + "[Poker] Turn watcher error:", + error + ); + } +} + + +async function processPokerLobbies( + client +) { + if (!client.isReady()) { + return; + } + + try { + const results = + await getPokerService() + .processExpiredLobbies(); + + for (const result of results) { + if (result.expired) { + console.log( + `[Poker] Expired waiting Poker table ${result.game?.gameId ?? "unknown"}.` + ); + + await refreshResultGame( + client, + result + ); + } + } + } + + catch (error) { + console.error( + "[Poker] Lobby watcher error:", + error + ); + } +} + + +async function recoverPoker( + client +) { + if (!client.isReady()) { + return; + } + + try { + const service = + getPokerService(); + + const result = + await service.recover(); + + for ( + const turn + of result.turns ?? [] + ) { + if (turn.processed) { + await refreshResultGame( + client, + turn + ); + } + } + + for ( + const lobby + of result.lobbies ?? [] + ) { + if (lobby.expired) { + await refreshResultGame( + client, + lobby + ); + } + } + + for ( + const closing + of result.closingTables ?? [] + ) { + await refreshResultGame( + client, + closing + ); + } + + for ( + const cashout + of result.cashouts ?? [] + ) { + try { + const game = + await service.getTable({ + gameId: + cashout.gameId + }); + + await refreshPokerTableMessage( + client, + game + ); + } + catch {} + } + + console.log( + `[Poker] Recovery complete: ` + + `${result.memberships.length} membership(s), ` + + `${result.cashouts.length} cash-out(s), ` + + `${result.closingTables.length} closing table(s), ` + + `${result.turns.length} expired turn(s), ` + + `${result.lobbies.length} expired lobby/lobbies.` + ); + } + + catch (error) { + console.error( + "[Poker] Startup recovery error:", + error + ); + } +} diff --git a/package.json b/package.json index 63b7c10..b6fa36e 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,8 @@ "description": "", "main": "index.js", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "test": "node --test", + "test:poker": "node --test tests/pokerEngine.test.js tests/crumbPoker.test.js" }, "repository": { "type": "git", diff --git a/selectMenus/poker/join.js b/selectMenus/poker/join.js new file mode 100644 index 0000000..054d34b --- /dev/null +++ b/selectMenus/poker/join.js @@ -0,0 +1,70 @@ +const { + getPokerService +} = require("../../utils/crumbPoker"); + +const { + deferPokerReply, + editPokerError, + refreshPokerTableMessage, + buildOpenTableLink +} = require("../../utils/pokerDiscord"); + + +module.exports = { + name: + "pokerJoinSelect", + + async execute( + interaction + ) { + await deferPokerReply( + interaction + ); + + const gameId = + interaction.values?.[0]; + + if (!gameId) { + return interaction.editReply({ + content: + "❌ No Poker table was selected.", + embeds: [], + components: [] + }); + } + + try { + const game = + await getPokerService() + .joinTable({ + gameId, + guildId: + interaction.guildId, + userId: + interaction.user.id + }); + + await refreshPokerTableMessage( + interaction.client, + game + ); + + return interaction.editReply({ + content: + `✅ Joined the Poker table for **${game.data.buyIn.toLocaleString()} Cookies**.`, + embeds: [], + components: + buildOpenTableLink( + game + ) + }); + } + + catch (error) { + return editPokerError( + interaction, + error + ); + } + } +}; diff --git a/utils/cookieEconomy.js b/utils/cookieEconomy.js index d98307f..e6524b1 100644 --- a/utils/cookieEconomy.js +++ b/utils/cookieEconomy.js @@ -268,6 +268,251 @@ async function changeCookies( } +/* + * ============================================================ + * IDEMPOTENT COOKIE CHANGE + * ============================================================ + * + * Applies a Cookie mutation at most once for a user-scoped + * operationId. This is useful for persistent game buy-ins and + * cash-outs that may be retried after a crash or restart. + * + * The Cookie balance mutation and operation marker are written + * atomically to the same user document. + */ + +async function changeCookiesOnce( + userId, + amount, + operationId +) { + if ( + !Number.isSafeInteger( + amount + ) + ) { + throw new Error( + "INVALID_COOKIE_AMOUNT" + ); + } + + if ( + typeof operationId !== + "string" + || + !/^[A-Za-z0-9:_-]{1,160}$/.test( + operationId + ) + ) { + throw new Error( + "INVALID_COOKIE_OPERATION_ID" + ); + } + + if ( + amount === + 0 + ) { + return { + applied: + false, + balance: + await getCookieBalance( + userId + ) + }; + } + + const { + cookies + } = + getCollections(); + + const filter = { + userId, + appliedCookieOperations: { + $ne: + operationId + } + }; + + if ( + amount < + 0 + ) { + filter.cookies = { + $gte: + Math.abs( + amount + ) + }; + } + + const update = { + $inc: { + cookies: + amount + }, + $addToSet: { + appliedCookieOperations: + operationId + } + }; + + if ( + amount > + 0 + ) { + update.$setOnInsert = { + userId + }; + } + + try { + const result = + await cookies.updateOne( + filter, + update, + { + upsert: + amount > 0 + } + ); + + if ( + result.modifiedCount === + 1 + || + result.upsertedCount === + 1 + ) { + return { + applied: + true, + balance: + await getCookieBalance( + userId + ) + }; + } + } + + catch (error) { + /* + * A concurrent positive retry can race an upsert. + * The unique userId index may reject the losing insert. + * Re-read below and treat an already-recorded operation + * as a successful idempotent retry. + */ + if ( + error?.code !== + 11000 + ) { + throw error; + } + } + + const document = + await cookies.findOne( + { + userId + }, + { + projection: { + cookies: + 1, + appliedCookieOperations: + 1 + } + } + ); + + const operations = + Array.isArray( + document + ?.appliedCookieOperations + ) + ? document + .appliedCookieOperations + : []; + + if ( + operations.includes( + operationId + ) + ) { + return { + applied: + false, + balance: + Math.max( + 0, + Number( + document?.cookies ?? + 0 + ) + ) + }; + } + + if ( + amount < + 0 + ) { + throw new Error( + "INSUFFICIENT_COOKIES" + ); + } + + throw new Error( + "COOKIE_OPERATION_FAILED" + ); +} + + +async function hasCookieOperation( + userId, + operationId +) { + if ( + typeof operationId !== + "string" + || + !/^[A-Za-z0-9:_-]{1,160}$/.test( + operationId + ) + ) { + throw new Error( + "INVALID_COOKIE_OPERATION_ID" + ); + } + + const { + cookies + } = + getCollections(); + + const document = + await cookies.findOne( + { + userId, + appliedCookieOperations: + operationId + }, + { + projection: { + _id: + 1 + } + } + ); + + return Boolean( + document + ); +} + + /* * ============================================================ * COOKIE TRANSFER @@ -1188,6 +1433,8 @@ module.exports = { getCookieBalance, changeCookies, + changeCookiesOnce, + hasCookieOperation, transferCookies, getCooldown, diff --git a/utils/crumbPoker.js b/utils/crumbPoker.js new file mode 100644 index 0000000..9258229 --- /dev/null +++ b/utils/crumbPoker.js @@ -0,0 +1,2730 @@ +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 +}; diff --git a/utils/crumbSaucer.js b/utils/crumbSaucer.js index 5c89275..6b665d6 100644 --- a/utils/crumbSaucer.js +++ b/utils/crumbSaucer.js @@ -14,48 +14,88 @@ const { requireInteractionSession } = require("./interactionSession"); -async function showCrumbSaucer(interaction) { - if (interaction.isButton()) { - const session = await requireInteractionSession(interaction); + +async function showCrumbSaucer( + interaction +) { + if ( + interaction.isButton() + ) { + const session = + await requireInteractionSession( + interaction + ); if (!session) { return; } - const balance = await getCookieBalance(interaction.user.id); - const embed = buildMainMenuEmbed(interaction.user, balance); + const balance = + await getCookieBalance( + interaction.user.id + ); + + const embed = + buildMainMenuEmbed( + interaction.user, + balance + ); return interaction.update({ - embeds: [embed], - components: buildMainMenuButtons() + embeds: [ + embed + ], + components: + buildMainMenuButtons() }); } - const balance = await getCookieBalance(interaction.user.id); - const embed = buildMainMenuEmbed(interaction.user, balance); + const balance = + await getCookieBalance( + interaction.user.id + ); + + const embed = + buildMainMenuEmbed( + interaction.user, + balance + ); await interaction.reply({ - embeds: [embed], - components: buildMainMenuButtons() + embeds: [ + embed + ], + components: + buildMainMenuButtons() }); - const message = await interaction.fetchReply(); + const message = + await interaction.fetchReply(); await createInteractionSession( interaction, { - messageId: message.id, - ownerId: interaction.user.id, - kind: "crumbSaucer" + messageId: + message.id, + ownerId: + interaction.user.id, + kind: + "crumbSaucer" } ); return message; } -function buildMainMenuEmbed(user, balance) { + +function buildMainMenuEmbed( + user, + balance +) { return new EmbedBuilder() - .setTitle("🎰 The Crumb Saucer") + .setTitle( + "🎰 The Crumb Saucer" + ) .setDescription( [ `Welcome, ${user}!`, @@ -78,79 +118,178 @@ function buildMainMenuEmbed(user, balance) { "### 🏦 Vault Heist", "Attempt to breach ShaiBot's Cookie Vault.", "", + "### ♠️ Poker", + "Create or join a 2–6 player Cookie Poker table.", + "", "### 🛟 Bankruptcy", "At exactly 0 Cookies, claim a 1,000 Cookie starter kit." ].join("\n") ) .addFields({ - name: "Your Vault", - value: `**${balance.toLocaleString()} 🍪**` + name: + "Your Vault", + value: + `**${balance.toLocaleString()} 🍪**` }) .setFooter({ - text: `crumbSaucer:${user.id}` + text: + `crumbSaucer:${user.id}` }); } + function buildMainMenuButtons() { return [ new ActionRowBuilder() .addComponents( new ButtonBuilder() - .setCustomId("crumbSaucerSlots") - .setLabel("Slots") - .setEmoji("🎰") - .setStyle(ButtonStyle.Primary), + .setCustomId( + "crumbSaucerSlots" + ) + .setLabel( + "Slots" + ) + .setEmoji( + "🎰" + ) + .setStyle( + ButtonStyle.Primary + ), new ButtonBuilder() - .setCustomId("crumbSaucerBlackjack") - .setLabel("Blackjack") - .setEmoji("🃏") - .setStyle(ButtonStyle.Primary), + .setCustomId( + "crumbSaucerBlackjack" + ) + .setLabel( + "Blackjack" + ) + .setEmoji( + "🃏" + ) + .setStyle( + ButtonStyle.Primary + ), new ButtonBuilder() - .setCustomId("crumbSaucerRoulette") - .setLabel("Roulette") - .setEmoji("🎡") - .setStyle(ButtonStyle.Primary), + .setCustomId( + "crumbSaucerRoulette" + ) + .setLabel( + "Roulette" + ) + .setEmoji( + "🎡" + ) + .setStyle( + ButtonStyle.Primary + ), new ButtonBuilder() - .setCustomId("crumbSaucerHeist") - .setLabel("Vault Heist") - .setEmoji("🏦") - .setStyle(ButtonStyle.Danger) + .setCustomId( + "crumbSaucerHeist" + ) + .setLabel( + "Vault Heist" + ) + .setEmoji( + "🏦" + ) + .setStyle( + ButtonStyle.Danger + ), + + new ButtonBuilder() + .setCustomId( + "crumbSaucerPoker" + ) + .setLabel( + "Poker" + ) + .setEmoji( + "♠️" + ) + .setStyle( + ButtonStyle.Success + ) ), new ActionRowBuilder() .addComponents( new ButtonBuilder() - .setCustomId("crumbSaucerCollectMenu") - .setLabel("Cookie Bakery") - .setEmoji("🍪") - .setStyle(ButtonStyle.Secondary), + .setCustomId( + "crumbSaucerCollectMenu" + ) + .setLabel( + "Cookie Bakery" + ) + .setEmoji( + "🍪" + ) + .setStyle( + ButtonStyle.Secondary + ), + new ButtonBuilder() - .setCustomId("crumbSaucerBankruptcy") - .setLabel("Bankruptcy") - .setEmoji("🛟") - .setStyle(ButtonStyle.Secondary), + .setCustomId( + "crumbSaucerBankruptcy" + ) + .setLabel( + "Bankruptcy" + ) + .setEmoji( + "🛟" + ) + .setStyle( + ButtonStyle.Secondary + ), + new ButtonBuilder() - .setCustomId("crumbSaucerProfile") - .setLabel("Profile") - .setEmoji("👤") - .setStyle(ButtonStyle.Secondary), + .setCustomId( + "crumbSaucerProfile" + ) + .setLabel( + "Profile" + ) + .setEmoji( + "👤" + ) + .setStyle( + ButtonStyle.Secondary + ), + new ButtonBuilder() - .setCustomId("sessionClose") - .setLabel("Close") - .setEmoji("✖️") - .setStyle(ButtonStyle.Danger) + .setCustomId( + "sessionClose" + ) + .setLabel( + "Close" + ) + .setEmoji( + "✖️" + ) + .setStyle( + ButtonStyle.Danger + ) ) ]; } -function getCrumbSaucerSessionOwner(interaction) { - const footer = interaction.message?.embeds?.[0]?.footer?.text; - const parts = footer?.split(":") ?? []; - switch (parts[0]) { +function getCrumbSaucerSessionOwner( + interaction +) { + const footer = + interaction.message + ?.embeds?.[0] + ?.footer + ?.text; + + const parts = + footer?.split(":") ?? []; + + switch ( + parts[0] + ) { case "crumbSaucer": case "crumbSaucerCollect": case "crumbSaucerSlots": @@ -171,6 +310,7 @@ function getCrumbSaucerSessionOwner(interaction) { } } + module.exports = { showCrumbSaucer, buildMainMenuEmbed, diff --git a/utils/pokerDiscord.js b/utils/pokerDiscord.js new file mode 100644 index 0000000..fa7be41 --- /dev/null +++ b/utils/pokerDiscord.js @@ -0,0 +1,1011 @@ +const { + EmbedBuilder, + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + StringSelectMenuBuilder, + MessageFlags +} = require("discord.js"); + +const { + cardToString, + getLegalActions +} = require("./pokerEngine"); + +const { + getPokerService, + publicTableState, + POKER_DEFAULT_BUY_IN, + POKER_DEFAULT_SMALL_BLIND, + POKER_DEFAULT_BIG_BLIND, + POKER_TURN_TIMEOUT +} = require("./crumbPoker"); + +const POKER_TABLE_FOOTER_PREFIX = + "pokerTable"; + +const POKER_CARD_RESPONSE_LIFETIME = + 5000; + + +function getPokerGameIdFromInteraction( + interaction +) { + const footer = + interaction.message + ?.embeds?.[0] + ?.footer + ?.text; + + const [ + prefix, + gameId + ] = + footer?.split(":") ?? []; + + if ( + prefix !== + POKER_TABLE_FOOTER_PREFIX + || + !gameId + ) { + return null; + } + + return gameId; +} + + +function pokerTableUrl( + game +) { + if ( + !game?.guildId + || + !game?.channelId + || + !game?.messageId + ) { + return null; + } + + return ( + `https://discord.com/channels/` + + `${game.guildId}/` + + `${game.channelId}/` + + `${game.messageId}` + ); +} + + +function formatPokerError( + error +) { + const known = { + TABLE_NOT_FOUND: + "This Poker table is no longer active.", + PLAYER_ALREADY_SEATED: + "You are already seated at a Poker table.", + TABLE_NOT_JOINABLE: + "That Poker table is not accepting new players right now.", + TABLE_FULL: + "That Poker table is full.", + NOT_TABLE_PLAYER: + "You are not seated at this Poker table.", + NOT_TABLE_OWNER: + "Only the table owner can start the next hand.", + NOT_ENOUGH_PLAYERS: + "At least two eligible players are required to start.", + HAND_ALREADY_ACTIVE: + "A Poker hand is already active.", + HAND_NOT_ACTIVE: + "There is no active Poker hand.", + HAND_ACTIVE: + "You cannot cash out during an active Poker hand.", + OUT_OF_TURN: + "It isn't your turn.", + TURN_EXPIRED: + "Your Poker turn has already expired.", + STATE_CONFLICT: + "The table changed at the same time. Please try again.", + INSUFFICIENT_COOKIES: + "You do not have enough Cookies for this Poker buy-in." + }; + + return ( + known[error?.code] + ?? + error?.message + ?? + "The Poker action could not be completed." + ); +} + + +async function deferPokerReply( + interaction +) { + if ( + interaction.deferred + || + interaction.replied + ) { + return; + } + + await interaction.deferReply({ + flags: + MessageFlags.Ephemeral + }); +} + + +async function editPokerError( + interaction, + error +) { + return interaction.editReply({ + content: + `❌ ${formatPokerError(error)}`, + embeds: [], + components: [] + }); +} + + +function buildPokerHubEmbed() { + return new EmbedBuilder() + .setTitle( + "♠️ Shai Poker" + ) + .setDescription( + [ + "Multiplayer Poker inside the Crumb Saucer.", + "", + `**Default Buy-In:** ${POKER_DEFAULT_BUY_IN.toLocaleString()} 🍪`, + `**Blinds:** ${POKER_DEFAULT_SMALL_BLIND.toLocaleString()} / ${POKER_DEFAULT_BIG_BLIND.toLocaleString()} 🍪`, + "**Players:** 2–6", + `**Turn Timer:** ${POKER_TURN_TIMEOUT} seconds`, + "", + "Create a public table, join an available table, or return to the table you are already seated at.", + "", + "Poker uses an internal chip stack. Cookies only move when you buy in or cash out." + ].join("\n") + ); +} + + +function buildPokerHubComponents() { + return [ + new ActionRowBuilder() + .addComponents( + new ButtonBuilder() + .setCustomId( + "pokerCreate" + ) + .setLabel( + "Create Table" + ) + .setEmoji( + "➕" + ) + .setStyle( + ButtonStyle.Success + ), + + new ButtonBuilder() + .setCustomId( + "pokerBrowse" + ) + .setLabel( + "Join Table" + ) + .setEmoji( + "🪑" + ) + .setStyle( + ButtonStyle.Primary + ), + + new ButtonBuilder() + .setCustomId( + "pokerMyTable" + ) + .setLabel( + "My Table" + ) + .setEmoji( + "♠️" + ) + .setStyle( + ButtonStyle.Secondary + ) + ) + ]; +} + + +function buildJoinPicker( + tables +) { + const embed = + new EmbedBuilder() + .setTitle( + "🪑 Join a Poker Table" + ); + + if (!tables.length) { + embed.setDescription( + "There are no joinable Poker tables in this channel right now." + ); + + return { + embeds: [embed], + components: [] + }; + } + + embed.setDescription( + "Choose a table below. The buy-in is debited only after your seat is successfully reserved." + ); + + const menu = + new StringSelectMenuBuilder() + .setCustomId( + "pokerJoinSelect" + ) + .setPlaceholder( + "Choose a Poker table" + ) + .addOptions( + tables.map( + table => { + const seats = + table.data.players.length; + + return { + label: + `Table ${table.gameId.slice(-8)}`, + description: + `${seats}/${table.data.maxPlayers} players • ${table.data.buyIn.toLocaleString()} Cookie buy-in`, + value: + table.gameId, + emoji: + "♠️" + }; + } + ) + ); + + return { + embeds: [embed], + components: [ + new ActionRowBuilder() + .addComponents( + menu + ) + ] + }; +} + + +function playerDisplayStack( + publicGame, + player +) { + const handPlayer = + publicGame.data.hand + ?.players + ?.find( + candidate => + candidate.userId === + player.userId + ); + + return ( + handPlayer?.stack + ?? + player.stack + ?? + 0 + ); +} + + +function buildPlayerLine( + publicGame, + player +) { + const hand = + publicGame.data.hand; + + const handPlayer = + hand?.players?.find( + candidate => + candidate.userId === + player.userId + ); + + let status = "🟢"; + + if ( + player.status === + "BUSTED" + ) { + status = "⚪"; + } + + else if ( + handPlayer?.folded + ) { + status = "⚫"; + } + + else if ( + handPlayer?.allIn + ) { + status = "🔒"; + } + + if ( + hand?.currentSeat === + player.seat + ) { + status = "▶️"; + } + + const markers = []; + + if ( + publicGame.data.dealerSeat === + player.seat + ) { + markers.push("D"); + } + + if ( + hand?.smallBlindSeat === + player.seat + ) { + markers.push("SB"); + } + + if ( + hand?.bigBlindSeat === + player.seat + ) { + markers.push("BB"); + } + + const markerText = + markers.length + ? ` **[${markers.join("/")}]**` + : ""; + + const stack = + playerDisplayStack( + publicGame, + player + ); + + const streetBet = + handPlayer?.streetBet ?? 0; + + const betText = + streetBet > 0 + ? ` • bet ${streetBet.toLocaleString()}` + : ""; + + return ( + `${status} <@${player.userId}>${markerText} — ` + + `**${stack.toLocaleString()}**${betText}` + ); +} + + +function buildLastHandText( + publicGame +) { + const result = + publicGame.data.lastHandResult; + + if (!result) { + return null; + } + + const winners = + (result.winnerSeats ?? []) + .map( + seat => + publicGame.data.players.find( + player => + player.seat === + seat + ) + ) + .filter(Boolean); + + if (!winners.length) { + return null; + } + + const winnerText = + winners + .map( + player => + `<@${player.userId}>` + ) + .join(", "); + + const payout = + Object.values( + result.payouts ?? {} + ) + .reduce( + (sum, value) => + sum + Number(value ?? 0), + 0 + ); + + const handName = + result.handRank?.name + ?? + null; + + return [ + `${winnerText} won **${payout.toLocaleString()} 🍪**.`, + handName + ? `Winning hand: **${handName}**` + : null + ] + .filter(Boolean) + .join("\n"); +} + + +function buildPokerTableEmbed( + game +) { + const publicGame = + publicTableState( + game + ); + + const hand = + publicGame.data.hand; + + const cards = + hand?.communityCards ?? []; + + const community = []; + + for ( + let index = 0; + index < 5; + index++ + ) { + community.push( + cards[index] + ? cardToString( + cards[index] + ) + : "🂠" + ); + } + + const playerLines = + publicGame.data.players + .slice() + .sort( + (a, b) => + a.seat - b.seat + ) + .map( + player => + buildPlayerLine( + publicGame, + player + ) + ); + + const stateLabel = + publicGame.state + .replaceAll("_", " "); + + const pot = + hand?.pot ?? 0; + + const description = [ + `**State:** ${stateLabel}`, + `**Blinds:** ${publicGame.data.smallBlind.toLocaleString()} / ${publicGame.data.bigBlind.toLocaleString()} 🍪`, + `**Buy-In:** ${publicGame.data.buyIn.toLocaleString()} 🍪`, + `**Pot:** ${pot.toLocaleString()} 🍪`, + "", + "### Community Cards", + community.join(" "), + "", + "### Players", + playerLines.length + ? playerLines.join("\n") + : "No players seated." + ]; + + if ( + hand?.currentSeat !== + null + && + hand?.currentSeat !== + undefined + ) { + const current = + hand.players.find( + player => + player.seat === + hand.currentSeat + ); + + if (current) { + description.push( + "", + `### <@${current.userId}>'s turn` + ); + + if ( + publicGame.data.turnExpiresAt + ) { + description.push( + `⏱️ Auto-folds ` + ); + } + } + } + + const lastHand = + buildLastHandText( + publicGame + ); + + if (lastHand) { + description.push( + "", + "### Last Hand", + lastHand + ); + } + + if ( + publicGame.state === + "WAITING" + ) { + description.push( + "", + `Lobby expires .` + ); + } + + if ( + publicGame.state === + "CLOSED" + ) { + description.push( + "", + "This Poker table is closed." + ); + } + + return new EmbedBuilder() + .setTitle( + "♠️ SHAI POKER" + ) + .setDescription( + description.join("\n") + ) + .setFooter({ + text: + `${POKER_TABLE_FOOTER_PREFIX}:${publicGame.gameId}` + }) + .setTimestamp(); +} + + +function buildPokerTableComponents( + game +) { + if ( + !game + || + game.state === + "CLOSED" + ) { + return []; + } + + if ( + game.state === + "WAITING" + || + game.state === + "NEXT_HAND" + ) { + return [ + new ActionRowBuilder() + .addComponents( + new ButtonBuilder() + .setCustomId( + "pokerJoinTable" + ) + .setLabel( + "Join Table" + ) + .setEmoji( + "🪑" + ) + .setStyle( + ButtonStyle.Success + ), + + new ButtonBuilder() + .setCustomId( + "pokerStart" + ) + .setLabel( + game.state === + "NEXT_HAND" + ? "Next Hand" + : "Start Hand" + ) + .setEmoji( + "▶️" + ) + .setStyle( + ButtonStyle.Primary + ), + + new ButtonBuilder() + .setCustomId( + "pokerCashOut" + ) + .setLabel( + "Cash Out" + ) + .setEmoji( + "🍪" + ) + .setStyle( + ButtonStyle.Secondary + ), + + new ButtonBuilder() + .setCustomId( + "pokerRefresh" + ) + .setLabel( + "Refresh" + ) + .setEmoji( + "🔄" + ) + .setStyle( + ButtonStyle.Secondary + ) + ) + ]; + } + + const hand = + game.data.hand; + + if (!hand) { + return []; + } + + const currentPlayer = + hand.players.find( + player => + player.seat === + hand.currentSeat + ); + + const legal = + currentPlayer + ? getLegalActions( + hand, + currentPlayer.userId + ) + : { + actions: [] + }; + + const can = + action => + legal.actions?.includes( + action + ); + + const callLabel = + legal.toCall > 0 + ? `Call ${legal.toCall.toLocaleString()}` + : "Call"; + + const raiseType = + can("bet") + ? "bet" + : "raise"; + + const raiseLabel = + legal.minRaiseTo + ? `${raiseType === "bet" ? "Bet" : "Raise to"} ${legal.minRaiseTo.toLocaleString()}` + : "Raise"; + + return [ + new ActionRowBuilder() + .addComponents( + new ButtonBuilder() + .setCustomId( + "pokerCards" + ) + .setLabel( + "Cards" + ) + .setEmoji( + "🃏" + ) + .setStyle( + ButtonStyle.Secondary + ), + + new ButtonBuilder() + .setCustomId( + "pokerFold" + ) + .setLabel( + "Fold" + ) + .setStyle( + ButtonStyle.Danger + ) + .setDisabled( + !can("fold") + ), + + new ButtonBuilder() + .setCustomId( + "pokerCheck" + ) + .setLabel( + "Check" + ) + .setStyle( + ButtonStyle.Secondary + ) + .setDisabled( + !can("check") + ), + + new ButtonBuilder() + .setCustomId( + "pokerCall" + ) + .setLabel( + callLabel + ) + .setStyle( + ButtonStyle.Success + ) + .setDisabled( + !can("call") + ) + ), + + new ActionRowBuilder() + .addComponents( + new ButtonBuilder() + .setCustomId( + "pokerRaise" + ) + .setLabel( + raiseLabel + ) + .setStyle( + ButtonStyle.Primary + ) + .setDisabled( + !can("bet") + && + !can("raise") + ), + + new ButtonBuilder() + .setCustomId( + "pokerAllIn" + ) + .setLabel( + "All-In" + ) + .setEmoji( + "🔥" + ) + .setStyle( + ButtonStyle.Danger + ) + .setDisabled( + !can("all_in") + ), + + new ButtonBuilder() + .setCustomId( + "pokerRefresh" + ) + .setLabel( + "Refresh" + ) + .setEmoji( + "🔄" + ) + .setStyle( + ButtonStyle.Secondary + ) + ) + ]; +} + + +async function refreshPokerTableMessage( + client, + game +) { + if ( + !game?.channelId + || + !game?.messageId + ) { + return null; + } + + try { + const channel = + await client.channels.fetch( + game.channelId + ); + + if ( + !channel + || + !channel.messages + ) { + return null; + } + + const message = + await channel.messages.fetch( + game.messageId + ); + + await message.edit({ + embeds: [ + buildPokerTableEmbed( + game + ) + ], + components: + buildPokerTableComponents( + game + ) + }); + + return message; + } + + catch (error) { + console.error( + `[Poker] Failed to refresh table ${game.gameId}:`, + error + ); + + return null; + } +} + + +async function publishPokerTable( + interaction, + game +) { + const message = + await interaction.channel.send({ + embeds: [ + buildPokerTableEmbed( + game + ) + ], + components: + buildPokerTableComponents( + game + ) + }); + + const service = + getPokerService(); + + const updated = + await service.setMessageId({ + gameId: + game.gameId, + messageId: + message.id + }); + + await message.edit({ + embeds: [ + buildPokerTableEmbed( + updated + ) + ], + components: + buildPokerTableComponents( + updated + ) + }); + + return { + game: + updated, + message + }; +} + + +function buildOpenTableLink( + game, + label = "Open Poker Table" +) { + const url = + pokerTableUrl( + game + ); + + if (!url) { + return []; + } + + return [ + new ActionRowBuilder() + .addComponents( + new ButtonBuilder() + .setLabel( + label + ) + .setEmoji( + "♠️" + ) + .setStyle( + ButtonStyle.Link + ) + .setURL( + url + ) + ) + ]; +} + + +module.exports = { + POKER_TABLE_FOOTER_PREFIX, + POKER_CARD_RESPONSE_LIFETIME, + + getPokerGameIdFromInteraction, + pokerTableUrl, + formatPokerError, + deferPokerReply, + editPokerError, + + buildPokerHubEmbed, + buildPokerHubComponents, + buildJoinPicker, + buildPokerTableEmbed, + buildPokerTableComponents, + buildOpenTableLink, + + refreshPokerTableMessage, + publishPokerTable +}; diff --git a/utils/pokerEngine.js b/utils/pokerEngine.js new file mode 100644 index 0000000..1f0598f --- /dev/null +++ b/utils/pokerEngine.js @@ -0,0 +1,2149 @@ +const SUITS = [ + "spades", + "hearts", + "diamonds", + "clubs" +]; + +const RANKS = [ + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9", + "10", + "J", + "Q", + "K", + "A" +]; + +const RANK_VALUES = { + "2": 2, + "3": 3, + "4": 4, + "5": 5, + "6": 6, + "7": 7, + "8": 8, + "9": 9, + "10": 10, + J: 11, + Q: 12, + K: 13, + A: 14 +}; + +const HAND_CATEGORIES = { + HIGH_CARD: 0, + PAIR: 1, + TWO_PAIR: 2, + THREE_OF_A_KIND: 3, + STRAIGHT: 4, + FLUSH: 5, + FULL_HOUSE: 6, + FOUR_OF_A_KIND: 7, + STRAIGHT_FLUSH: 8, + ROYAL_FLUSH: 9 +}; + +const HAND_CATEGORY_NAMES = { + [HAND_CATEGORIES.HIGH_CARD]: + "High Card", + [HAND_CATEGORIES.PAIR]: + "Pair", + [HAND_CATEGORIES.TWO_PAIR]: + "Two Pair", + [HAND_CATEGORIES.THREE_OF_A_KIND]: + "Three of a Kind", + [HAND_CATEGORIES.STRAIGHT]: + "Straight", + [HAND_CATEGORIES.FLUSH]: + "Flush", + [HAND_CATEGORIES.FULL_HOUSE]: + "Full House", + [HAND_CATEGORIES.FOUR_OF_A_KIND]: + "Four of a Kind", + [HAND_CATEGORIES.STRAIGHT_FLUSH]: + "Straight Flush", + [HAND_CATEGORIES.ROYAL_FLUSH]: + "Royal Flush" +}; + +const STREETS = [ + "PREFLOP", + "FLOP", + "TURN", + "RIVER" +]; + +class PokerEngineError extends Error { + constructor( + code, + message + ) { + super(message); + this.name = + "PokerEngineError"; + this.code = code; + } +} + +function createDeck() { + const deck = []; + + for (const suit of SUITS) { + for (const rank of RANKS) { + deck.push({ + rank, + suit + }); + } + } + + return deck; +} + +function shuffleDeck( + deck, + rng = Math.random +) { + const shuffled = + deck.map(card => ({ + ...card + })); + + for ( + let index = + shuffled.length - 1; + index > 0; + index-- + ) { + const swapIndex = + Math.floor( + rng() * + (index + 1) + ); + + [ + shuffled[index], + shuffled[swapIndex] + ] = [ + shuffled[swapIndex], + shuffled[index] + ]; + } + + return shuffled; +} + +function cardToString( + card +) { + if (!card) { + return ""; + } + + const symbols = { + spades: "♠", + hearts: "♥", + diamonds: "♦", + clubs: "♣" + }; + + return ( + `${card.rank}${symbols[card.suit] ?? "?"}` + ); +} + +function cloneState( + state +) { + if ( + typeof structuredClone === + "function" + ) { + return structuredClone( + state + ); + } + + return JSON.parse( + JSON.stringify(state) + ); +} + +function combinations( + items, + choose +) { + const output = []; + + function walk( + start, + current + ) { + if ( + current.length === + choose + ) { + output.push( + current.slice() + ); + return; + } + + for ( + let index = start; + index < items.length; + index++ + ) { + current.push( + items[index] + ); + + walk( + index + 1, + current + ); + + current.pop(); + } + } + + walk( + 0, + [] + ); + + return output; +} + +function evaluateFiveCardHand( + cards +) { + if ( + !Array.isArray(cards) || + cards.length !== 5 + ) { + throw new PokerEngineError( + "INVALID_HAND", + "Exactly five cards are required." + ); + } + + const values = + cards + .map( + card => + RANK_VALUES[card.rank] + ) + .sort( + (a, b) => + b - a + ); + + if ( + values.some( + value => + !Number.isInteger(value) + ) + ) { + throw new PokerEngineError( + "INVALID_CARD", + "A card has an invalid rank." + ); + } + + const flush = + cards.every( + card => + card.suit === + cards[0].suit + ); + + const uniqueValues = + [...new Set(values)]; + + let straightHigh = null; + + if ( + uniqueValues.length === 5 + ) { + if ( + uniqueValues[0] - + uniqueValues[4] === + 4 + ) { + straightHigh = + uniqueValues[0]; + } + + else if ( + uniqueValues.join(",") === + "14,5,4,3,2" + ) { + straightHigh = 5; + } + } + + const counts = + new Map(); + + for (const value of values) { + counts.set( + value, + (counts.get(value) ?? 0) + 1 + ); + } + + const groups = + [...counts.entries()] + .map( + ([value, count]) => ({ + value, + count + }) + ) + .sort( + (a, b) => + b.count - a.count || + b.value - a.value + ); + + if ( + flush && + straightHigh !== null + ) { + if ( + straightHigh === 14 + ) { + return buildRank( + HAND_CATEGORIES.ROYAL_FLUSH, + [14], + cards + ); + } + + return buildRank( + HAND_CATEGORIES.STRAIGHT_FLUSH, + [straightHigh], + cards + ); + } + + if ( + groups[0].count === 4 + ) { + return buildRank( + HAND_CATEGORIES.FOUR_OF_A_KIND, + [ + groups[0].value, + groups[1].value + ], + cards + ); + } + + if ( + groups[0].count === 3 && + groups[1].count === 2 + ) { + return buildRank( + HAND_CATEGORIES.FULL_HOUSE, + [ + groups[0].value, + groups[1].value + ], + cards + ); + } + + if (flush) { + return buildRank( + HAND_CATEGORIES.FLUSH, + values, + cards + ); + } + + if ( + straightHigh !== null + ) { + return buildRank( + HAND_CATEGORIES.STRAIGHT, + [straightHigh], + cards + ); + } + + if ( + groups[0].count === 3 + ) { + const kickers = + groups + .slice(1) + .map( + group => + group.value + ) + .sort( + (a, b) => + b - a + ); + + return buildRank( + HAND_CATEGORIES.THREE_OF_A_KIND, + [ + groups[0].value, + ...kickers + ], + cards + ); + } + + if ( + groups[0].count === 2 && + groups[1].count === 2 + ) { + const pairs = + [ + groups[0].value, + groups[1].value + ].sort( + (a, b) => + b - a + ); + + const kicker = + groups.find( + group => + group.count === 1 + ).value; + + return buildRank( + HAND_CATEGORIES.TWO_PAIR, + [ + ...pairs, + kicker + ], + cards + ); + } + + if ( + groups[0].count === 2 + ) { + const kickers = + groups + .slice(1) + .map( + group => + group.value + ) + .sort( + (a, b) => + b - a + ); + + return buildRank( + HAND_CATEGORIES.PAIR, + [ + groups[0].value, + ...kickers + ], + cards + ); + } + + return buildRank( + HAND_CATEGORIES.HIGH_CARD, + values, + cards + ); +} + +function buildRank( + category, + tiebreak, + cards +) { + return { + category, + name: + HAND_CATEGORY_NAMES[ + category + ], + tiebreak, + cards: + cards.map( + card => ({ + ...card + }) + ) + }; +} + +function compareHandRanks( + left, + right +) { + if ( + left.category !== + right.category + ) { + return ( + left.category > + right.category + ? 1 + : -1 + ); + } + + const length = + Math.max( + left.tiebreak.length, + right.tiebreak.length + ); + + for ( + let index = 0; + index < length; + index++ + ) { + const leftValue = + left.tiebreak[index] ?? 0; + + const rightValue = + right.tiebreak[index] ?? 0; + + if ( + leftValue !== + rightValue + ) { + return ( + leftValue > rightValue + ? 1 + : -1 + ); + } + } + + return 0; +} + +function evaluateHand( + cards +) { + if ( + !Array.isArray(cards) || + cards.length < 5 || + cards.length > 7 + ) { + throw new PokerEngineError( + "INVALID_HAND", + "A poker hand must contain between five and seven cards." + ); + } + + let best = null; + + for ( + const candidate + of combinations( + cards, + 5 + ) + ) { + const rank = + evaluateFiveCardHand( + candidate + ); + + if ( + !best || + compareHandRanks( + rank, + best + ) > 0 + ) { + best = rank; + } + } + + return best; +} + +function splitPot( + pot, + winnerSeats, + dealerSeat = -1 +) { + if ( + !Number.isSafeInteger(pot) || + pot < 0 + ) { + throw new PokerEngineError( + "INVALID_POT", + "Pot must be a non-negative safe integer." + ); + } + + if ( + !Array.isArray(winnerSeats) || + winnerSeats.length === 0 + ) { + throw new PokerEngineError( + "NO_WINNERS", + "At least one winner is required." + ); + } + + const uniqueSeats = + [...new Set(winnerSeats)] + .sort( + (a, b) => + a - b + ); + + const base = + Math.floor( + pot / + uniqueSeats.length + ); + + let remainder = + pot % uniqueSeats.length; + + const ordered = + orderSeatsAfter( + uniqueSeats, + dealerSeat + ); + + const payouts = {}; + + for (const seat of ordered) { + payouts[seat] = base; + } + + for ( + let index = 0; + remainder > 0; + index++, remainder-- + ) { + const seat = + ordered[ + index % + ordered.length + ]; + + payouts[seat]++; + } + + return payouts; +} + +function resolveShowdown( + state +) { + const active = + state.players.filter( + player => + !player.folded + ); + + if ( + active.length === 0 + ) { + throw new PokerEngineError( + "NO_ACTIVE_PLAYERS", + "Showdown has no active players." + ); + } + + let winners = []; + let bestRank = null; + + for (const player of active) { + const rank = + evaluateHand([ + ...player.holeCards, + ...state.communityCards + ]); + + player.handRank = rank; + + if (!bestRank) { + bestRank = rank; + winners = [player]; + continue; + } + + const comparison = + compareHandRanks( + rank, + bestRank + ); + + if (comparison > 0) { + bestRank = rank; + winners = [player]; + } + + else if ( + comparison === 0 + ) { + winners.push(player); + } + } + + const payouts = + splitPot( + state.pot, + winners.map( + player => + player.seat + ), + state.dealerSeat + ); + + for (const player of state.players) { + const payout = + payouts[player.seat] ?? 0; + + player.stack += payout; + } + + state.result = { + type: + "showdown", + winnerSeats: + winners.map( + player => + player.seat + ), + payouts, + handRank: + bestRank + }; + + state.pot = 0; + state.state = + "PAYOUT"; + state.currentSeat = null; + + return state; +} + +function startHand({ + players, + dealerSeat = null, + smallBlind = 100, + bigBlind = 200, + deck = null +}) { + validatePlayers( + players + ); + + if ( + !Number.isSafeInteger( + smallBlind + ) || + !Number.isSafeInteger( + bigBlind + ) || + smallBlind <= 0 || + bigBlind <= 0 || + smallBlind >= bigBlind + ) { + throw new PokerEngineError( + "INVALID_BLINDS", + "Blinds must be positive integers and the small blind must be lower than the big blind." + ); + } + + const sortedPlayers = + players + .map( + player => ({ + userId: + String( + player.userId + ), + seat: + Number( + player.seat + ), + stack: + Number( + player.stack + ) + }) + ) + .sort( + (a, b) => + a.seat - b.seat + ); + + const handCap = + Math.min( + ...sortedPlayers.map( + player => + player.stack + ) + ); + + if ( + handCap < bigBlind + ) { + throw new PokerEngineError( + "STACK_BELOW_BIG_BLIND", + "Every player must have at least one big blind available to start a hand." + ); + } + + const seats = + sortedPlayers.map( + player => + player.seat + ); + + const resolvedDealerSeat = + dealerSeat === null + ? seats[0] + : Number(dealerSeat); + + if ( + !seats.includes( + resolvedDealerSeat + ) + ) { + throw new PokerEngineError( + "INVALID_DEALER", + "Dealer seat does not belong to an active player." + ); + } + + const workingDeck = + deck + ? deck.map( + card => ({ + ...card + }) + ) + : shuffleDeck( + createDeck() + ); + + if ( + workingDeck.length < + sortedPlayers.length * 2 + 5 + ) { + throw new PokerEngineError( + "INSUFFICIENT_DECK", + "The deck does not contain enough cards." + ); + } + + const state = { + state: + "PREFLOP", + street: + "PREFLOP", + players: + sortedPlayers.map( + player => ({ + ...player, + startingStack: + player.stack, + handCap, + totalBet: 0, + streetBet: 0, + folded: false, + allIn: false, + actedThisStreet: + false, + holeCards: [] + }) + ), + dealerSeat: + resolvedDealerSeat, + smallBlind, + bigBlind, + smallBlindSeat: null, + bigBlindSeat: null, + currentSeat: null, + communityCards: [], + deck: + workingDeck, + pot: 0, + currentBet: 0, + lastRaiseSize: + bigBlind, + handCap, + result: null + }; + + dealHoleCards( + state + ); + + const headsUp = + state.players.length === 2; + + const smallBlindSeat = + headsUp + ? state.dealerSeat + : nextSeatAfter( + state.players, + state.dealerSeat, + () => true + ); + + const bigBlindSeat = + nextSeatAfter( + state.players, + smallBlindSeat, + () => true + ); + + state.smallBlindSeat = + smallBlindSeat; + state.bigBlindSeat = + bigBlindSeat; + + postForcedBet( + state, + smallBlindSeat, + smallBlind + ); + + postForcedBet( + state, + bigBlindSeat, + bigBlind + ); + + state.currentBet = + bigBlind; + + state.lastRaiseSize = + bigBlind; + + state.currentSeat = + nextSeatAfter( + state.players, + bigBlindSeat, + canPlayerAct + ); + + if ( + state.currentSeat === null + ) { + progressUntilActionOrPayout( + state + ); + } + + return state; +} + +function validatePlayers( + players +) { + if ( + !Array.isArray(players) || + players.length < 2 || + players.length > 6 + ) { + throw new PokerEngineError( + "INVALID_PLAYER_COUNT", + "Poker requires between 2 and 6 players." + ); + } + + const userIds = + new Set(); + const seats = + new Set(); + + for (const player of players) { + const userId = + String( + player?.userId ?? "" + ); + + const seat = + Number( + player?.seat + ); + + const stack = + Number( + player?.stack + ); + + if (!userId) { + throw new PokerEngineError( + "INVALID_PLAYER", + "Every player needs a userId." + ); + } + + if ( + !Number.isSafeInteger(seat) || + seat < 0 + ) { + throw new PokerEngineError( + "INVALID_SEAT", + "Every player needs a non-negative integer seat." + ); + } + + if ( + !Number.isSafeInteger(stack) || + stack <= 0 + ) { + throw new PokerEngineError( + "INVALID_STACK", + "Every player needs a positive integer stack." + ); + } + + if ( + userIds.has(userId) + ) { + throw new PokerEngineError( + "DUPLICATE_PLAYER", + "A player cannot occupy multiple seats." + ); + } + + if ( + seats.has(seat) + ) { + throw new PokerEngineError( + "DUPLICATE_SEAT", + "Two players cannot occupy the same seat." + ); + } + + userIds.add(userId); + seats.add(seat); + } +} + +function dealHoleCards( + state +) { + for ( + let round = 0; + round < 2; + round++ + ) { + const orderedSeats = + orderSeatsAfter( + state.players.map( + player => + player.seat + ), + state.dealerSeat + ); + + for (const seat of orderedSeats) { + const player = + getPlayerBySeat( + state, + seat + ); + + player.holeCards.push( + state.deck.pop() + ); + } + } +} + +function postForcedBet( + state, + seat, + amount +) { + const player = + getPlayerBySeat( + state, + seat + ); + + const wager = + Math.min( + amount, + remainingHandBudget( + player + ) + ); + + commitChips( + state, + player, + wager + ); + + if ( + remainingHandBudget( + player + ) === 0 + ) { + player.allIn = true; + } +} + +function getLegalActions( + state, + userId +) { + if ( + !STREETS.includes( + state.state + ) + ) { + return { + actions: [], + reason: + "HAND_NOT_ACTIVE" + }; + } + + const player = + state.players.find( + candidate => + candidate.userId === + String(userId) + ); + + if (!player) { + return { + actions: [], + reason: + "PLAYER_NOT_FOUND" + }; + } + + if ( + player.seat !== + state.currentSeat + ) { + return { + actions: [], + reason: + "OUT_OF_TURN" + }; + } + + if ( + !canPlayerAct(player) + ) { + return { + actions: [], + reason: + "PLAYER_CANNOT_ACT" + }; + } + + const toCall = + Math.max( + 0, + state.currentBet - + player.streetBet + ); + + const remaining = + remainingHandBudget( + player + ); + + const maxBetTo = + player.streetBet + + remaining; + + const actions = [ + "fold" + ]; + + if ( + toCall === 0 + ) { + actions.push( + "check" + ); + } + + else if ( + remaining > 0 + ) { + actions.push( + "call" + ); + } + + if ( + remaining > 0 && + maxBetTo > + state.currentBet + ) { + actions.push( + state.currentBet === 0 + ? "bet" + : "raise" + ); + } + + if ( + remaining > 0 + ) { + actions.push( + "all_in" + ); + } + + const minimumRaiseTo = + state.currentBet === 0 + ? state.bigBlind + : state.currentBet + + state.lastRaiseSize; + + return { + actions, + toCall, + minRaiseTo: + Math.min( + minimumRaiseTo, + maxBetTo + ), + maxRaiseTo: + maxBetTo, + handCap: + state.handCap, + remainingHandBudget: + remaining + }; +} + +function applyAction( + originalState, + userId, + action +) { + const state = + cloneState( + originalState + ); + + if ( + !action || + !action.type + ) { + throw new PokerEngineError( + "INVALID_ACTION", + "An action type is required." + ); + } + + if ( + !STREETS.includes( + state.state + ) + ) { + throw new PokerEngineError( + "HAND_NOT_ACTIVE", + "The hand is not accepting actions." + ); + } + + const player = + state.players.find( + candidate => + candidate.userId === + String(userId) + ); + + if (!player) { + throw new PokerEngineError( + "PLAYER_NOT_FOUND", + "Player is not part of this hand." + ); + } + + if ( + player.seat !== + state.currentSeat + ) { + throw new PokerEngineError( + "OUT_OF_TURN", + "It is not this player's turn." + ); + } + + if ( + !canPlayerAct(player) + ) { + throw new PokerEngineError( + "PLAYER_CANNOT_ACT", + "This player cannot act." + ); + } + + const type = + String( + action.type + ).toLowerCase(); + + const toCall = + Math.max( + 0, + state.currentBet - + player.streetBet + ); + + if (type === "fold") { + player.folded = true; + player.actedThisStreet = true; + } + + else if ( + type === "check" + ) { + if ( + toCall !== 0 + ) { + throw new PokerEngineError( + "CANNOT_CHECK", + "Player cannot check while facing a bet." + ); + } + + player.actedThisStreet = true; + } + + else if ( + type === "call" + ) { + if ( + toCall <= 0 + ) { + throw new PokerEngineError( + "NOTHING_TO_CALL", + "There is no bet to call." + ); + } + + const remaining = + remainingHandBudget( + player + ); + + if ( + remaining < toCall + ) { + throw new PokerEngineError( + "CALL_EXCEEDS_CAP", + "Call would exceed the single-pot hand cap." + ); + } + + commitChips( + state, + player, + toCall + ); + + player.actedThisStreet = true; + + if ( + remainingHandBudget( + player + ) === 0 + ) { + player.allIn = true; + } + } + + else if ( + type === "bet" || + type === "raise" + ) { + const target = + Number( + action.amount + ); + + if ( + !Number.isSafeInteger( + target + ) || + target <= 0 + ) { + throw new PokerEngineError( + "INVALID_RAISE", + "Bet or raise amount must be a positive integer target." + ); + } + + if ( + type === "bet" && + state.currentBet !== 0 + ) { + throw new PokerEngineError( + "BET_NOT_AVAILABLE", + "Use raise when a bet already exists." + ); + } + + if ( + type === "raise" && + state.currentBet === 0 + ) { + throw new PokerEngineError( + "RAISE_NOT_AVAILABLE", + "Use bet when no bet exists." + ); + } + + applyRaiseTo( + state, + player, + target + ); + } + + else if ( + type === "all_in" + ) { + const remaining = + remainingHandBudget( + player + ); + + if ( + remaining <= 0 + ) { + throw new PokerEngineError( + "NO_CHIPS_AVAILABLE", + "Player has no playable chips remaining this hand." + ); + } + + const oldCurrentBet = + state.currentBet; + + const target = + player.streetBet + + remaining; + + commitChips( + state, + player, + remaining + ); + + player.allIn = true; + player.actedThisStreet = true; + + if ( + target > + oldCurrentBet + ) { + const raiseSize = + target - + oldCurrentBet; + + if ( + raiseSize >= + state.lastRaiseSize + ) { + state.lastRaiseSize = + raiseSize; + + reopenBettingAfterRaise( + state, + player.seat + ); + } + + state.currentBet = + target; + } + } + + else { + throw new PokerEngineError( + "UNKNOWN_ACTION", + "Unknown poker action." + ); + } + + progressAfterAction( + state, + player.seat + ); + + return state; +} + +function applyRaiseTo( + state, + player, + target +) { + const remaining = + remainingHandBudget( + player + ); + + const maxTarget = + player.streetBet + + remaining; + + if ( + target > maxTarget + ) { + throw new PokerEngineError( + "RAISE_EXCEEDS_CAP", + "Raise would exceed the single-pot hand cap." + ); + } + + if ( + target <= + state.currentBet + ) { + throw new PokerEngineError( + "RAISE_TOO_SMALL", + "Raise target must exceed the current bet." + ); + } + + const minimumTarget = + state.currentBet === 0 + ? state.bigBlind + : state.currentBet + + state.lastRaiseSize; + + if ( + target < minimumTarget + ) { + throw new PokerEngineError( + "RAISE_TOO_SMALL", + `Minimum raise target is ${minimumTarget}.` + ); + } + + const oldCurrentBet = + state.currentBet; + + const amount = + target - + player.streetBet; + + commitChips( + state, + player, + amount + ); + + state.currentBet = + target; + + state.lastRaiseSize = + target - + oldCurrentBet; + + reopenBettingAfterRaise( + state, + player.seat + ); + + player.actedThisStreet = true; + + if ( + remainingHandBudget( + player + ) === 0 + ) { + player.allIn = true; + } +} + +function reopenBettingAfterRaise( + state, + raiserSeat +) { + for (const candidate of state.players) { + if ( + candidate.seat !== + raiserSeat && + canPlayerAct(candidate) + ) { + candidate.actedThisStreet = + false; + } + } +} + +function commitChips( + state, + player, + amount +) { + if ( + !Number.isSafeInteger(amount) || + amount < 0 + ) { + throw new PokerEngineError( + "INVALID_COMMIT", + "Committed chips must be a non-negative integer." + ); + } + + if ( + amount > + remainingHandBudget(player) + ) { + throw new PokerEngineError( + "HAND_CAP_EXCEEDED", + "Player cannot contribute beyond the single-pot hand cap." + ); + } + + if ( + amount > player.stack + ) { + throw new PokerEngineError( + "STACK_EXCEEDED", + "Player does not have enough chips." + ); + } + + player.stack -= amount; + player.totalBet += amount; + player.streetBet += amount; + state.pot += amount; +} + +function remainingHandBudget( + player +) { + return Math.max( + 0, + player.handCap - + player.totalBet + ); +} + +function canPlayerAct( + player +) { + return ( + !player.folded && + !player.allIn && + remainingHandBudget( + player + ) > 0 + ); +} + +function progressAfterAction( + state, + actedSeat +) { + const activePlayers = + state.players.filter( + player => + !player.folded + ); + + if ( + activePlayers.length === 1 + ) { + awardFoldWin( + state, + activePlayers[0] + ); + return; + } + + if ( + isBettingRoundComplete( + state + ) + ) { + advanceStreet( + state + ); + + progressUntilActionOrPayout( + state + ); + + return; + } + + state.currentSeat = + nextSeatAfter( + state.players, + actedSeat, + canPlayerAct + ); + + if ( + state.currentSeat === null + ) { + advanceStreet( + state + ); + + progressUntilActionOrPayout( + state + ); + } +} + +function progressUntilActionOrPayout( + state +) { + while ( + STREETS.includes( + state.state + ) + ) { + const activePlayers = + state.players.filter( + player => + !player.folded + ); + + if ( + activePlayers.length === 1 + ) { + awardFoldWin( + state, + activePlayers[0] + ); + return; + } + + const actionable = + state.players.filter( + canPlayerAct + ); + + if ( + actionable.length === 0 + ) { + advanceStreet( + state + ); + continue; + } + + if ( + state.currentSeat === null || + !actionable.some( + player => + player.seat === + state.currentSeat + ) + ) { + state.currentSeat = + firstPostflopSeat( + state + ); + } + + return; + } +} + +function isBettingRoundComplete( + state +) { + const actionable = + state.players.filter( + canPlayerAct + ); + + if ( + actionable.length === 0 + ) { + return true; + } + + return actionable.every( + player => + player.actedThisStreet && + player.streetBet === + state.currentBet + ); +} + +function advanceStreet( + state +) { + if ( + state.state === + "PREFLOP" + ) { + state.state = "FLOP"; + state.street = "FLOP"; + + state.communityCards.push( + state.deck.pop(), + state.deck.pop(), + state.deck.pop() + ); + } + + else if ( + state.state === + "FLOP" + ) { + state.state = "TURN"; + state.street = "TURN"; + + state.communityCards.push( + state.deck.pop() + ); + } + + else if ( + state.state === + "TURN" + ) { + state.state = "RIVER"; + state.street = "RIVER"; + + state.communityCards.push( + state.deck.pop() + ); + } + + else if ( + state.state === + "RIVER" + ) { + state.state = + "SHOWDOWN"; + state.street = + "SHOWDOWN"; + + resolveShowdown( + state + ); + + return state; + } + + else { + return state; + } + + state.currentBet = 0; + state.lastRaiseSize = + state.bigBlind; + + for (const player of state.players) { + player.streetBet = 0; + player.actedThisStreet = + false; + } + + state.currentSeat = + firstPostflopSeat( + state + ); + + return state; +} + +function awardFoldWin( + state, + winner +) { + const payout = + state.pot; + + winner.stack += payout; + + state.result = { + type: + "fold", + winnerSeats: [ + winner.seat + ], + payouts: { + [winner.seat]: + payout + }, + handRank: null + }; + + state.pot = 0; + state.state = + "PAYOUT"; + state.currentSeat = null; +} + +function firstPostflopSeat( + state +) { + return nextSeatAfter( + state.players, + state.dealerSeat, + canPlayerAct + ); +} + +function nextSeatAfter( + players, + seat, + predicate = () => true +) { + const seats = + players + .map( + player => + player.seat + ) + .sort( + (a, b) => + a - b + ); + + if ( + seats.length === 0 + ) { + return null; + } + + const startIndex = + seats.findIndex( + candidate => + candidate > seat + ); + + const ordered = + startIndex === -1 + ? seats.slice() + : [ + ...seats.slice( + startIndex + ), + ...seats.slice( + 0, + startIndex + ) + ]; + + if ( + startIndex === -1 + ) { + ordered.sort( + (a, b) => + a - b + ); + } + + for (const candidateSeat of ordered) { + const player = + players.find( + candidate => + candidate.seat === + candidateSeat + ); + + if ( + player && + predicate(player) + ) { + return candidateSeat; + } + } + + return null; +} + +function orderSeatsAfter( + seats, + seat +) { + const sorted = + [...new Set(seats)] + .sort( + (a, b) => + a - b + ); + + if ( + sorted.length <= 1 + ) { + return sorted; + } + + const after = + sorted.filter( + candidate => + candidate > seat + ); + + const before = + sorted.filter( + candidate => + candidate <= seat + ); + + return [ + ...after, + ...before + ]; +} + +function getPlayerBySeat( + state, + seat +) { + const player = + state.players.find( + candidate => + candidate.seat === + seat + ); + + if (!player) { + throw new PokerEngineError( + "PLAYER_NOT_FOUND", + `No player occupies seat ${seat}.` + ); + } + + return player; +} + +function getPublicState( + state +) { + return { + state: + state.state, + street: + state.street, + dealerSeat: + state.dealerSeat, + smallBlindSeat: + state.smallBlindSeat, + bigBlindSeat: + state.bigBlindSeat, + currentSeat: + state.currentSeat, + smallBlind: + state.smallBlind, + bigBlind: + state.bigBlind, + handCap: + state.handCap, + pot: + state.pot, + currentBet: + state.currentBet, + communityCards: + state.communityCards.map( + card => ({ + ...card + }) + ), + players: + state.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: + state.result + }; +} + +function getPrivateCards( + state, + userId +) { + const player = + state.players.find( + candidate => + candidate.userId === + String(userId) + ); + + if (!player) { + throw new PokerEngineError( + "PLAYER_NOT_FOUND", + "Player is not part of this hand." + ); + } + + return player.holeCards.map( + card => ({ + ...card + }) + ); +} + +module.exports = { + SUITS, + RANKS, + RANK_VALUES, + HAND_CATEGORIES, + HAND_CATEGORY_NAMES, + STREETS, + + PokerEngineError, + + createDeck, + shuffleDeck, + cardToString, + + evaluateFiveCardHand, + evaluateHand, + compareHandRanks, + splitPot, + resolveShowdown, + + startHand, + getLegalActions, + applyAction, + advanceStreet, + + getPublicState, + getPrivateCards, + + remainingHandBudget, + canPlayerAct, + nextSeatAfter, + orderSeatsAfter +}; diff --git a/utils/pokerInteractions.js b/utils/pokerInteractions.js new file mode 100644 index 0000000..7254bcf --- /dev/null +++ b/utils/pokerInteractions.js @@ -0,0 +1,168 @@ +const { + getPokerService +} = require("./crumbPoker"); + +const { + deferPokerReply, + editPokerError, + getPokerGameIdFromInteraction, + refreshPokerTableMessage +} = require("./pokerDiscord"); + + +async function executePokerAction( + interaction, + action, + successText +) { + await deferPokerReply( + interaction + ); + + const gameId = + getPokerGameIdFromInteraction( + interaction + ); + + if (!gameId) { + return interaction.editReply({ + content: + "❌ This Poker control is no longer attached to an active table.", + embeds: [], + components: [] + }); + } + + try { + const service = + getPokerService(); + + const game = + await service.act({ + gameId, + userId: + interaction.user.id, + action + }); + + await refreshPokerTableMessage( + interaction.client, + game + ); + + return interaction.editReply({ + content: + typeof successText === + "function" + ? successText(game) + : successText, + embeds: [], + components: [] + }); + } + + catch (error) { + return editPokerError( + interaction, + error + ); + } +} + + +async function executeMinimumRaise( + interaction +) { + await deferPokerReply( + interaction + ); + + const gameId = + getPokerGameIdFromInteraction( + interaction + ); + + if (!gameId) { + return interaction.editReply({ + content: + "❌ This Poker control is no longer attached to an active table.", + embeds: [], + components: [] + }); + } + + try { + const service = + getPokerService(); + + const legal = + await service.getLegalPlayerActions({ + gameId, + userId: + interaction.user.id + }); + + const type = + legal.actions?.includes( + "bet" + ) + ? "bet" + : legal.actions?.includes( + "raise" + ) + ? "raise" + : null; + + if ( + !type + || + !Number.isSafeInteger( + legal.minRaiseTo + ) + ) { + return interaction.editReply({ + content: + "❌ A bet or raise is not available right now.", + embeds: [], + components: [] + }); + } + + const game = + await service.act({ + gameId, + userId: + interaction.user.id, + action: { + type, + amount: + legal.minRaiseTo + } + }); + + await refreshPokerTableMessage( + interaction.client, + game + ); + + return interaction.editReply({ + content: + `✅ ${type === "bet" ? "Bet" : "Raised to"} ${legal.minRaiseTo.toLocaleString()} 🍪`, + embeds: [], + components: [] + }); + } + + catch (error) { + return editPokerError( + interaction, + error + ); + } +} + + +module.exports = { + executePokerAction, + executeMinimumRaise +};