Files
ShaiBot/utils/crumbBlackjack.js
T
2026-08-22 19:52:47 +02:00

2125 lines
36 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");
/*
* ============================================================
* CONFIG
* ============================================================
*/
const GAME_COLLECTION =
"items_games";
const BLACKJACK_BETS = [
10,
50,
100,
500,
1000
];
/*
* ============================================================
* SHOW BLACKJACK MENU
* ============================================================
*/
async function showBlackjack(
interaction
) {
const ownerId =
getBlackjackSessionOwner(
interaction
);
if (
ownerId !==
interaction.user.id
) {
return temporaryEphemeralReply(
interaction,
{
content:
"This isn't your Crumb Saucer session."
}
);
}
/*
* Resume an unfinished game.
*/
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()
});
}
/*
* ============================================================
* START GAME
* ============================================================
*/
async function startBlackjack(
interaction,
bet,
allowCustomBet =
false
) {
/*
* Normal wager buttons must use one of our
* predefined values.
*
* All In and Run It Back are allowed to use
* custom wager amounts.
*/
if (
!allowCustomBet
&&
!BLACKJACK_BETS.includes(
bet
)
) {
return temporaryEphemeralReply(
interaction,
{
content:
"That wager isn't available."
}
);
}
/*
* Every wager, predefined or custom, must
* still be a positive safe integer.
*/
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
);
/*
* Prevent multiple active hands.
*/
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
)
]);
if (
playerBalance <
bet
) {
return temporaryEphemeralReply(
interaction,
{
content:
`You need **${bet.toLocaleString()} Cookies** for that wager.`
}
);
}
/*
* Natural Blackjack returns:
*
* wager + 3:2 winnings
*
* = 2.5x total payout.
*
* The wager will move into the house before
* resolution, so the house currently needs
* enough to cover the additional 1.5x.
*/
if (
houseBalance <
Math.ceil(
bet *
1.5
)
) {
return temporaryEphemeralReply(
interaction,
{
content:
"The Crumb Saucer vault can't currently cover that wager."
}
);
}
/*
* Take 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;
}
/*
* ========================================================
* REAL 52-CARD DECK
* ========================================================
*/
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,
deck,
playerCards,
dealerCards,
createdAt:
getUnixTime()
};
await games.insertOne(
game
);
/*
* ========================================================
* NATURAL BLACKJACK CHECK
* ========================================================
*/
const playerBlackjack =
isBlackjack(
playerCards
);
const dealerBlackjack =
isBlackjack(
dealerCards
);
if (
playerBlackjack ||
dealerBlackjack
) {
/*
* Both natural Blackjack = Push.
*/
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
)
});
}
/*
* ============================================================
* ALL IN
* ============================================================
*/
async function startBlackjackAllIn(
interaction
) {
const ownerId =
getBlackjackSessionOwner(
interaction
);
if (
ownerId !==
interaction.user.id
) {
return temporaryEphemeralReply(
interaction,
{
content:
"This isn't your Blackjack table."
}
);
}
/*
* Resolve balance at click time.
*/
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."
}
);
}
/*
* true allows the balance to be used even though
* it likely isn't one of BLACKJACK_BETS.
*/
return startBlackjack(
interaction,
balance,
true
);
}
/*
* ============================================================
* RUN IT BACK
* ============================================================
*
* Reuses only the previous BET.
*
* A completely new shuffled deck and hand are created.
*/
async function runBlackjackBack(
interaction
) {
const footer =
interaction.message
?.embeds?.[0]
?.footer
?.text;
const parts =
footer?.split(":")
??
[];
/*
* Expected:
*
* crumbBlackjackResult:<userId>:<bet>
*/
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]
);
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."
}
);
}
/*
* Reuse the exact previous wager.
*
* true is required because a previous All In bet
* may not be part of BLACKJACK_BETS.
*/
return startBlackjack(
interaction,
bet,
true
);
}
/*
* ============================================================
* HIT
* ============================================================
*/
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();
const games =
db.collection(
GAME_COLLECTION
);
await games.updateOne(
{
gameId:
game.gameId,
state:
"Playing"
},
{
$set: {
deck,
playerCards
}
}
);
const updated = {
...game,
deck,
playerCards
};
/*
* Bust.
*/
if (
score >
21
) {
return finishBlackjack(
interaction,
updated,
"Bust"
);
}
/*
* Automatically stand on exactly 21.
*/
if (
score ===
21
) {
return standBlackjack(
interaction,
updated
);
}
return interaction.update({
embeds: [
buildGameEmbed(
updated
)
],
components:
buildGameButtons(
updated
)
});
}
/*
* ============================================================
* STAND
* ============================================================
*/
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
];
/*
* Dealer stands on 17.
*/
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";
}
/*
* Same value = Push.
*/
else {
result =
"Push";
}
return finishBlackjack(
interaction,
updated,
result
);
}
/*
* ============================================================
* DOUBLE
* ============================================================
*/
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."
}
);
}
/*
* Double only on initial two cards.
*/
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
];
/*
* Double receives exactly one additional card.
*/
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"
);
}
/*
* Double automatically stands.
*/
return standBlackjack(
interaction,
updated
);
}
/*
* ============================================================
* FINISH GAME
* ============================================================
*/
async function finishBlackjack(
interaction,
game,
result
) {
const houseId =
interaction.client.user.id;
let payout =
0;
switch (result) {
/*
* Normal win.
*/
case "Win":
case "DealerBust":
payout =
game.bet *
2;
break;
/*
* Push:
*
* Return exactly the wager.
*/
case "Push":
payout =
game.bet;
break;
/*
* Natural Blackjack:
*
* stake + 3:2 winnings.
*/
case "Blackjack":
payout =
Math.floor(
game.bet *
2.5
);
break;
/*
* Bust / Loss / Dealer Blackjack.
*/
default:
payout =
0;
}
/*
* Pay player.
*/
if (
payout >
0
) {
const houseBalance =
await getCookieBalance(
houseId
);
if (
houseBalance <
payout
) {
console.error(
`[Blackjack] House cannot cover payout ${payout} for ${game.userId}`
);
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.bet}`
})
],
components:
buildResultButtons()
});
}
await transferCookies(
houseId,
game.userId,
payout
);
}
/*
* Mark game finished.
*/
const {
db
} =
getCollections();
await db
.collection(
GAME_COLLECTION
)
.updateOne(
{
gameId:
game.gameId,
state:
"Playing"
},
{
$set: {
state:
"Finished",
result,
payout,
finishedAt:
getUnixTime(),
dealerCards:
game.dealerCards,
playerCards:
game.playerCards
}
}
);
const playerBalance =
await getCookieBalance(
game.userId
);
const profit =
payout -
game.bet;
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()} 🍪`
}
)
/*
* Owner + previous exact wager.
*/
.setFooter({
text:
`crumbBlackjackResult:${game.userId}:${game.bet}`
});
return interaction.update({
embeds: [
embed
],
components:
buildResultButtons()
});
}
/*
* ============================================================
* GAME EMBED
* ============================================================
*/
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}`
});
}
/*
* ============================================================
* BET BUTTONS
* ============================================================
*/
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
)
)
];
}
/*
* ============================================================
* ACTIVE GAME BUTTONS
* ============================================================
*/
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
)
)
];
}
/*
* ============================================================
* RESULT BUTTONS
* ============================================================
*/
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
)
)
];
}
/*
* ============================================================
* CARDS
* ============================================================
*/
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(
" "
);
}
/*
* ============================================================
* DATABASE LOOKUPS
* ============================================================
*/
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"
});
}
/*
* ============================================================
* SESSION OWNER
* ============================================================
*/
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];
}
/*
* Active hand:
*
* crumbBlackjack:<gameId>:<userId>
*/
if (
parts[0] ===
"crumbBlackjack"
) {
return parts[2];
}
/*
* Finished result:
*
* crumbBlackjackResult:<userId>:<bet>
*/
if (
parts[0] ===
"crumbBlackjackResult"
) {
return parts[1];
}
return null;
}
/*
* ============================================================
* RESULT TITLES
* ============================================================
*/
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";
}
}
/*
* ============================================================
* EXPORTS
* ============================================================
*/
module.exports = {
showBlackjack,
startBlackjack,
startBlackjackAllIn,
hitBlackjack,
standBlackjack,
doubleBlackjack,
runBlackjackBack
};