Files
ShaiBot/utils/crumbBlackjack.js
T
2026-08-27 19:50:12 +02:00

1535 lines
31 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
} = require("discord.js");
const {
randomUUID
} = require("node:crypto");
const {
getCollections,
getCookieBalance,
transferCookies,
temporaryEphemeralReply,
getUnixTime
} = require("./cookieEconomy");
const {
recordGameOutcome,
trackProfileEvent
} = require("./profileSystem");
const GAME_COLLECTION =
"items_games";
const BLACKJACK_BETS = [
10,
50,
100,
500,
1000
];
async function showBlackjack(
interaction
) {
const ownerId =
getBlackjackSessionOwner(
interaction
);
if (
ownerId !==
interaction.user.id
) {
return temporaryEphemeralReply(
interaction,
{
content:
"This isn't your Crumb Saucer session."
}
);
}
const active =
await getActiveBlackjack(
interaction.user.id,
interaction.guildId
);
if (active) {
return interaction.update({
embeds: [
buildGameEmbed(active)
],
components:
buildGameButtons(active)
});
}
const balance =
await getCookieBalance(
interaction.user.id
);
const embed =
new EmbedBuilder()
.setTitle(
"🃏 Crumb Saucer Blackjack"
)
.setDescription(
[
"Beat the dealer without going over **21**.",
"",
"**Dealer stands on 17.**",
"**Blackjack pays 3:2.**",
"**Push refunds your wager.**",
"",
"Choose your wager below."
].join("\n")
)
.addFields({
name:
"Your Vault",
value:
`**${balance.toLocaleString()} 🍪**`
})
.setFooter({
text:
`crumbSaucerBlackjack:${interaction.user.id}`
});
return interaction.update({
embeds: [embed],
components:
buildBetButtons()
});
}
async function startBlackjack(
interaction,
bet,
allowCustomBet = false,
source = "normal",
previousAllIn = false,
previousBet = 0
) {
if (
!allowCustomBet &&
!BLACKJACK_BETS.includes(bet)
) {
return temporaryEphemeralReply(
interaction,
{
content:
"That wager isn't available."
}
);
}
if (
!Number.isSafeInteger(bet) ||
bet <= 0
) {
return temporaryEphemeralReply(
interaction,
{
content:
"That wager isn't valid."
}
);
}
const ownerId =
getBlackjackSessionOwner(
interaction
);
if (
ownerId !==
interaction.user.id
) {
return temporaryEphemeralReply(
interaction,
{
content:
"This isn't your Blackjack table."
}
);
}
const { db } =
getCollections();
const games =
db.collection(
GAME_COLLECTION
);
const existing =
await getActiveBlackjack(
interaction.user.id,
interaction.guildId
);
if (existing) {
return temporaryEphemeralReply(
interaction,
{
content:
"You already have an active Blackjack hand."
}
);
}
const playerId =
interaction.user.id;
const houseId =
interaction.client.user.id;
const [
playerBalance,
houseBalance
] = await Promise.all([
getCookieBalance(playerId),
getCookieBalance(houseId)
]);
const allIn =
bet === playerBalance;
if (playerBalance < bet) {
return temporaryEphemeralReply(
interaction,
{
content:
`You need **${bet.toLocaleString()} Cookies** for that wager.`
}
);
}
if (
houseBalance <
Math.ceil(bet * 1.5)
) {
return temporaryEphemeralReply(
interaction,
{
content:
"The Crumb Saucer vault can't currently cover that wager."
}
);
}
try {
await transferCookies(
playerId,
houseId,
bet
);
} catch (error) {
if (
error.message ===
"INSUFFICIENT_COOKIES"
) {
return temporaryEphemeralReply(
interaction,
{
content:
"Your Cookie balance changed before the hand could begin."
}
);
}
throw error;
}
const deck =
createDeck();
shuffleDeck(deck);
const playerCards = [
deck.pop(),
deck.pop()
];
const dealerCards = [
deck.pop(),
deck.pop()
];
const game = {
eventType:
"Blackjack",
gameId:
randomUUID(),
userId:
playerId,
guildId:
interaction.guildId,
channelId:
interaction.channelId,
state:
"Playing",
bet,
originalBet:
bet,
doubled:
false,
allIn,
vaultAtBet:
playerBalance,
source,
previousAllIn,
previousBet,
deck,
playerCards,
dealerCards,
createdAt:
getUnixTime()
};
await games.insertOne(game);
await trackProfileEvent(
interaction,
playerId,
{
type: "game_start",
game: "blackjack",
source,
bet,
wager: bet,
allIn,
previousAllIn,
previousBet
}
);
const playerBlackjack =
isBlackjack(playerCards);
const dealerBlackjack =
isBlackjack(dealerCards);
if (
playerBlackjack ||
dealerBlackjack
) {
if (
playerBlackjack &&
dealerBlackjack
) {
return finishBlackjack(
interaction,
game,
"Push"
);
}
if (playerBlackjack) {
return finishBlackjack(
interaction,
game,
"Blackjack"
);
}
return finishBlackjack(
interaction,
game,
"DealerBlackjack"
);
}
return interaction.update({
embeds: [
buildGameEmbed(game)
],
components:
buildGameButtons(game)
});
}
async function startBlackjackAllIn(
interaction
) {
const ownerId =
getBlackjackSessionOwner(
interaction
);
if (
ownerId !==
interaction.user.id
) {
return temporaryEphemeralReply(
interaction,
{
content:
"This isn't your Blackjack table."
}
);
}
const balance =
await getCookieBalance(
interaction.user.id
);
if (balance <= 0) {
return temporaryEphemeralReply(
interaction,
{
content:
"You don't have any Cookies to go All In with."
}
);
}
return startBlackjack(
interaction,
balance,
true,
"all_in"
);
}
async function runBlackjackBack(
interaction
) {
const footer =
interaction.message
?.embeds?.[0]
?.footer
?.text;
const parts =
footer?.split(":") ?? [];
if (
parts[0] !==
"crumbBlackjackResult"
) {
return temporaryEphemeralReply(
interaction,
{
content:
"This Blackjack result can no longer be replayed."
}
);
}
const ownerId =
parts[1];
const bet =
Number(parts[2]);
const previousAllIn =
parts[3] === "1";
if (
ownerId !==
interaction.user.id
) {
return temporaryEphemeralReply(
interaction,
{
content:
"This isn't your Blackjack session."
}
);
}
if (
!Number.isSafeInteger(bet) ||
bet <= 0
) {
return temporaryEphemeralReply(
interaction,
{
content:
"The previous wager is no longer valid."
}
);
}
return startBlackjack(
interaction,
bet,
true,
"run_it_back",
previousAllIn,
bet
);
}
async function hitBlackjack(
interaction
) {
const game =
await getGameFromInteraction(
interaction
);
if (!game) {
return temporaryEphemeralReply(
interaction,
{
content:
"This Blackjack hand no longer exists."
}
);
}
if (
game.userId !==
interaction.user.id
) {
return temporaryEphemeralReply(
interaction,
{
content:
"This isn't your Blackjack hand."
}
);
}
if (
game.state !==
"Playing"
) {
return temporaryEphemeralReply(
interaction,
{
content:
"This hand is already finished."
}
);
}
const deck =
[...game.deck];
const playerCards =
[...game.playerCards];
playerCards.push(
deck.pop()
);
const score =
getHandValue(
playerCards
);
const { db } =
getCollections();
await db
.collection(
GAME_COLLECTION
)
.updateOne(
{
gameId:
game.gameId,
state:
"Playing"
},
{
$set: {
deck,
playerCards
}
}
);
const updated = {
...game,
deck,
playerCards
};
if (score > 21) {
return finishBlackjack(
interaction,
updated,
"Bust"
);
}
if (score === 21) {
return standBlackjack(
interaction,
updated
);
}
return interaction.update({
embeds: [
buildGameEmbed(updated)
],
components:
buildGameButtons(updated)
});
}
async function standBlackjack(
interaction,
suppliedGame = null
) {
let game =
suppliedGame;
if (!game) {
game =
await getGameFromInteraction(
interaction
);
}
if (!game) {
return temporaryEphemeralReply(
interaction,
{
content:
"This Blackjack hand no longer exists."
}
);
}
if (
game.userId !==
interaction.user.id
) {
return temporaryEphemeralReply(
interaction,
{
content:
"This isn't your Blackjack hand."
}
);
}
const deck =
[...game.deck];
const dealerCards =
[...game.dealerCards];
while (
getHandValue(dealerCards) <
17
) {
dealerCards.push(
deck.pop()
);
}
const updated = {
...game,
deck,
dealerCards
};
const playerScore =
getHandValue(
updated.playerCards
);
const dealerScore =
getHandValue(
dealerCards
);
let result;
if (dealerScore > 21) {
result = "DealerBust";
}
else if (
playerScore >
dealerScore
) {
result = "Win";
}
else if (
playerScore <
dealerScore
) {
result = "Loss";
}
else {
result = "Push";
}
return finishBlackjack(
interaction,
updated,
result
);
}
async function doubleBlackjack(
interaction
) {
const game =
await getGameFromInteraction(
interaction
);
if (!game) {
return temporaryEphemeralReply(
interaction,
{
content:
"This Blackjack hand no longer exists."
}
);
}
if (
game.userId !==
interaction.user.id
) {
return temporaryEphemeralReply(
interaction,
{
content:
"This isn't your Blackjack hand."
}
);
}
if (
game.playerCards.length !== 2 ||
game.doubled
) {
return temporaryEphemeralReply(
interaction,
{
content:
"You can only Double on your initial two cards."
}
);
}
const playerBalance =
await getCookieBalance(
interaction.user.id
);
if (
playerBalance <
game.originalBet
) {
return temporaryEphemeralReply(
interaction,
{
content:
`You need another **${game.originalBet.toLocaleString()} Cookies** to Double.`
}
);
}
try {
await transferCookies(
interaction.user.id,
interaction.client.user.id,
game.originalBet
);
} catch (error) {
if (
error.message ===
"INSUFFICIENT_COOKIES"
) {
return temporaryEphemeralReply(
interaction,
{
content:
"Your balance changed before you could Double."
}
);
}
throw error;
}
const deck =
[...game.deck];
const playerCards =
[...game.playerCards];
playerCards.push(
deck.pop()
);
const updated = {
...game,
deck,
playerCards,
bet:
game.bet +
game.originalBet,
doubled:
true
};
const { db } =
getCollections();
await db
.collection(
GAME_COLLECTION
)
.updateOne(
{
gameId:
game.gameId,
state:
"Playing"
},
{
$set: {
deck,
playerCards,
bet:
updated.bet,
doubled:
true
}
}
);
if (
getHandValue(playerCards) >
21
) {
return finishBlackjack(
interaction,
updated,
"Bust"
);
}
return standBlackjack(
interaction,
updated
);
}
async function finishBlackjack(
interaction,
game,
result
) {
const { db } = getCollections();
const claim = await db
.collection(GAME_COLLECTION)
.updateOne(
{
gameId: game.gameId,
state: "Playing"
},
{
$set: {
state: "Resolving"
}
}
);
if (claim.modifiedCount !== 1) {
return temporaryEphemeralReply(
interaction,
{
content:
"This Blackjack hand is already being resolved."
}
);
}
const houseId =
interaction.client.user.id;
let payout = 0;
switch (result) {
case "Win":
case "DealerBust":
payout =
game.bet * 2;
break;
case "Push":
payout =
game.bet;
break;
case "Blackjack":
payout =
Math.floor(
game.bet * 2.5
);
break;
default:
payout = 0;
}
if (payout > 0) {
const houseBalance =
await getCookieBalance(
houseId
);
if (
houseBalance <
payout
) {
console.error(
`[Blackjack] House cannot cover payout ${payout} for ${game.userId}`
);
await db
.collection(GAME_COLLECTION)
.updateOne(
{
gameId: game.gameId,
state: "Resolving"
},
{
$set: {
state: "Finished",
result: "PayoutError",
payout: 0,
finishedAt: getUnixTime()
}
}
);
return interaction.update({
embeds: [
new EmbedBuilder()
.setTitle(
"🃏 Blackjack Error"
)
.setDescription(
"The Crumb Saucer vault couldn't cover the payout. Please contact an administrator."
)
.setFooter({
text:
`crumbBlackjackResult:${game.userId}:${game.originalBet}:${game.allIn ? 1 : 0}`
})
],
components:
buildResultButtons()
});
}
await transferCookies(
houseId,
game.userId,
payout
);
}
await db
.collection(
GAME_COLLECTION
)
.updateOne(
{
gameId:
game.gameId,
state:
"Resolving"
},
{
$set: {
state:
"Finished",
result,
payout,
finishedAt:
getUnixTime(),
dealerCards:
game.dealerCards,
playerCards:
game.playerCards
}
}
);
const playerBalance =
await getCookieBalance(
game.userId
);
const profit =
payout - game.bet;
let profileResult;
if (result === "Push") {
profileResult = "push";
}
else if (
[
"Win",
"DealerBust",
"Blackjack"
].includes(result)
) {
profileResult = "win";
}
else {
profileResult = "loss";
}
await recordGameOutcome(
interaction,
game.userId,
{
game:
"blackjack",
result:
profileResult,
profit,
natural:
result ===
"Blackjack",
allIn:
game.allIn ===
true
}
);
const description = [
"**Dealer**",
formatCards(
game.dealerCards
),
`Score: **${getHandValue(game.dealerCards)}**`,
"",
"**You**",
formatCards(
game.playerCards
),
`Score: **${getHandValue(game.playerCards)}**`
];
if (result === "Push") {
description.push(
"",
"🤝 **Push — your wager has been refunded.**"
);
}
const embed =
new EmbedBuilder()
.setTitle(
getResultTitle(result)
)
.setDescription(
description.join("\n")
)
.addFields(
{
name:
"Bet",
value:
`${game.bet.toLocaleString()} 🍪`,
inline:
true
},
{
name:
"Payout",
value:
`${payout.toLocaleString()} 🍪`,
inline:
true
},
{
name:
"Profit",
value:
`${profit >= 0 ? "+" : ""}${profit.toLocaleString()} 🍪`,
inline:
true
},
{
name:
"Your Vault",
value:
`${playerBalance.toLocaleString()} 🍪`
}
)
.setFooter({
text:
`crumbBlackjackResult:${game.userId}:${game.originalBet}:${game.allIn ? 1 : 0}`
});
return interaction.update({
embeds: [embed],
components:
buildResultButtons()
});
}
function buildGameEmbed(
game
) {
const playerScore =
getHandValue(
game.playerCards
);
const dealerVisible =
game.dealerCards[0];
return new EmbedBuilder()
.setTitle(
"🃏 Crumb Saucer Blackjack"
)
.setDescription(
[
"**Dealer**",
`${formatCard(dealerVisible)} 🂠`,
"",
"**You**",
formatCards(
game.playerCards
),
`Score: **${playerScore}**`
].join("\n")
)
.addFields(
{
name:
"Bet",
value:
`**${game.bet.toLocaleString()} 🍪**`,
inline:
true
},
{
name:
"Status",
value:
"**Your turn**",
inline:
true
}
)
.setFooter({
text:
`crumbBlackjack:${game.gameId}:${game.userId}`
});
}
function buildBetButtons() {
return [
new ActionRowBuilder()
.addComponents(
...BLACKJACK_BETS.map(
bet =>
new ButtonBuilder()
.setCustomId(
`crumbBlackjackBet${bet}`
)
.setLabel(
bet.toLocaleString()
)
.setEmoji("🍪")
.setStyle(
bet >= 500
? ButtonStyle.Primary
: ButtonStyle.Secondary
)
)
),
new ActionRowBuilder()
.addComponents(
new ButtonBuilder()
.setCustomId(
"crumbBlackjackAllIn"
)
.setLabel(
"All In"
)
.setEmoji("🔥")
.setStyle(
ButtonStyle.Danger
),
new ButtonBuilder()
.setCustomId(
"crumbSaucerBack"
)
.setLabel(
"Back to Crumb Saucer"
)
.setEmoji("◀️")
.setStyle(
ButtonStyle.Secondary
)
)
];
}
function buildGameButtons(
game
) {
const canDouble =
game.playerCards.length === 2 &&
!game.doubled;
return [
new ActionRowBuilder()
.addComponents(
new ButtonBuilder()
.setCustomId(
"crumbBlackjackHit"
)
.setLabel("Hit")
.setEmoji("➕")
.setStyle(
ButtonStyle.Primary
),
new ButtonBuilder()
.setCustomId(
"crumbBlackjackStand"
)
.setLabel("Stand")
.setEmoji("✋")
.setStyle(
ButtonStyle.Success
),
new ButtonBuilder()
.setCustomId(
"crumbBlackjackDouble"
)
.setLabel("Double")
.setEmoji("💰")
.setDisabled(
!canDouble
)
.setStyle(
ButtonStyle.Danger
)
)
];
}
function buildResultButtons() {
return [
new ActionRowBuilder()
.addComponents(
new ButtonBuilder()
.setCustomId(
"crumbBlackjackRunItBack"
)
.setLabel(
"Run It Back"
)
.setEmoji("🔁")
.setStyle(
ButtonStyle.Success
),
new ButtonBuilder()
.setCustomId(
"crumbSaucerBlackjack"
)
.setLabel(
"Change Bet"
)
.setEmoji("🃏")
.setStyle(
ButtonStyle.Primary
),
new ButtonBuilder()
.setCustomId(
"crumbSaucerBack"
)
.setLabel(
"Crumb Saucer"
)
.setEmoji("◀️")
.setStyle(
ButtonStyle.Secondary
)
)
];
}
function createDeck() {
const suits = [
"♠️",
"♥️",
"♦️",
"♣️"
];
const ranks = [
"A", "2", "3", "4", "5",
"6", "7", "8", "9", "10",
"J", "Q", "K"
];
const deck = [];
for (const suit of suits) {
for (const rank of ranks) {
deck.push({
rank,
suit
});
}
}
return deck;
}
function shuffleDeck(
deck
) {
for (
let i =
deck.length - 1;
i > 0;
i--
) {
const j =
Math.floor(
Math.random() *
(i + 1)
);
[
deck[i],
deck[j]
] = [
deck[j],
deck[i]
];
}
}
function getHandValue(
cards
) {
let total = 0;
let aces = 0;
for (const card of cards) {
switch (card.rank) {
case "A":
total += 11;
aces++;
break;
case "K":
case "Q":
case "J":
total += 10;
break;
default:
total +=
Number(card.rank);
}
}
while (
total > 21 &&
aces > 0
) {
total -= 10;
aces--;
}
return total;
}
function isBlackjack(
cards
) {
return (
cards.length === 2 &&
getHandValue(cards) === 21
);
}
function formatCard(
card
) {
return `**${card.rank}${card.suit}**`;
}
function formatCards(
cards
) {
return cards
.map(formatCard)
.join(" ");
}
async function getActiveBlackjack(
userId,
guildId
) {
const { db } =
getCollections();
return db
.collection(
GAME_COLLECTION
)
.findOne({
eventType:
"Blackjack",
userId,
guildId,
state:
"Playing"
});
}
async function getGameFromInteraction(
interaction
) {
const footer =
interaction.message
?.embeds?.[0]
?.footer
?.text;
const parts =
footer?.split(":") ?? [];
if (
parts[0] !==
"crumbBlackjack"
) {
return null;
}
const gameId =
parts[1];
const { db } =
getCollections();
return db
.collection(
GAME_COLLECTION
)
.findOne({
eventType:
"Blackjack",
gameId,
state:
"Playing"
});
}
function getBlackjackSessionOwner(
interaction
) {
const footer =
interaction.message
?.embeds?.[0]
?.footer
?.text;
const parts =
footer?.split(":") ?? [];
if (
parts[0] ===
"crumbSaucer"
) {
return parts[1];
}
if (
parts[0] ===
"crumbSaucerBlackjack"
) {
return parts[1];
}
if (
parts[0] ===
"crumbBlackjack"
) {
return parts[2];
}
if (
parts[0] ===
"crumbBlackjackResult"
) {
return parts[1];
}
return null;
}
function getResultTitle(
result
) {
switch (result) {
case "Blackjack":
return "🃏 BLACKJACK!";
case "Win":
return "🎉 You Win!";
case "DealerBust":
return "💥 Dealer Bust!";
case "Push":
return "🤝 Push — Wager Refunded";
case "Bust":
return "💥 Bust!";
case "DealerBlackjack":
return "🃏 Dealer Blackjack";
default:
return "🃏 Dealer Wins";
}
}
module.exports = {
showBlackjack,
startBlackjack,
startBlackjackAllIn,
hitBlackjack,
standBlackjack,
doubleBlackjack,
runBlackjackBack
};