Files
ShaiBot/utils/pokerDiscord.js
T
2026-09-08 20:51:58 +02:00

1040 lines
23 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
const POKER_ACTIVE_STATES_FOR_DISPLAY =
new Set([
"PREFLOP",
"FLOP",
"TURN",
"RIVER"
]);
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 ===
"NEXT_HAND"
? "🏆 HAND COMPLETE"
: POKER_ACTIVE_STATES_FOR_DISPLAY.has(
publicGame.state
)
? `🃏 ${publicGame.state.replaceAll("_", " ")}`
: publicGame.state
.replaceAll("_", " ");
const pot =
hand?.pot ?? 0;
const description = [
`**State:** ${stateLabel}`,
hand && POKER_ACTIVE_STATES_FOR_DISPLAY.has(
publicGame.state
)
? `**Hand #${publicGame.data.handNumber}** • 🃏 Cards have been dealt. Use **Cards** to view your hand.`
: null,
`**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 TURN: <@${current.userId}>`
);
if (
publicGame.data.turnExpiresAt
) {
description.push(
`⏱️ **90-second turn** • Auto-folds <t:${publicGame.data.turnExpiresAt}:R>`
);
}
description.push(
"━━━━━━━━━━━━━━━━━━"
);
}
}
const lastHand =
buildLastHandText(
publicGame
);
if (lastHand) {
description.push(
"",
"### Last Hand",
lastHand
);
}
if (
publicGame.state ===
"WAITING"
||
publicGame.state ===
"NEXT_HAND"
) {
description.push(
"",
`Lobby expires <t:${publicGame.data.lobbyExpiresAt}:R>.`
);
}
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
};