Files
ShaiBot/utils/crumbBlackjack.js
T
2026-08-18 20:44:51 +02:00

1780 lines
30 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 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."
}
);
}
const balance =
await getCookieBalance(
interaction.user.id
);
const active =
await getActiveBlackjack(
interaction.user.id,
interaction.guildId
);
if (active) {
return interaction.update({
embeds: [
buildGameEmbed(
active
)
],
components:
buildGameButtons(
active
)
});
}
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 returns 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
) {
if (
!BLACKJACK_BETS.includes(
bet
)
) {
return temporaryEphemeralReply(
interaction,
{
content:
"That wager isn't available."
}
);
}
const owner =
getBlackjackMenuOwner(
interaction
);
if (
owner !==
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 balance =
await getCookieBalance(
interaction.user.id
);
if (
balance <
bet
) {
return temporaryEphemeralReply(
interaction,
{
content:
`You need **${bet.toLocaleString()} Cookies** for that wager.`
}
);
}
/*
* The house must be able to cover a natural Blackjack.
*
* Total Blackjack payout is 2.5x stake after
* the player's stake has already moved to the house.
*/
const houseId =
interaction.client.user.id;
const houseBalance =
await getCookieBalance(
houseId
);
if (
houseBalance <
Math.ceil(
bet *
1.5
)
) {
return temporaryEphemeralReply(
interaction,
{
content:
"The Crumb Saucer vault can't currently cover that wager."
}
);
}
try {
await transferCookies(
interaction.user.id,
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:
interaction.user.id,
guildId:
interaction.guildId,
channelId:
interaction.channelId,
state:
"Playing",
bet,
originalBet:
bet,
doubled:
false,
deck,
playerCards,
dealerCards,
createdAt:
getUnixTime()
};
await games.insertOne(
game
);
/*
* Natural Blackjack checks.
*/
const playerBlackjack =
isBlackjack(
playerCards
);
const dealerBlackjack =
isBlackjack(
dealerCards
);
if (
playerBlackjack ||
dealerBlackjack
) {
return resolveInitialBlackjack(
interaction,
game,
playerBlackjack,
dealerBlackjack
);
}
return interaction.update({
embeds: [
buildGameEmbed(
game
)
],
components:
buildGameButtons(
game
)
});
}
/*
* ============================================================
* 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
}
}
);
if (
score >
21
) {
return finishBlackjack(
interaction,
{
...game,
deck,
playerCards
},
"Bust"
);
}
if (
score ===
21
) {
return standBlackjack(
interaction,
{
...game,
deck,
playerCards
}
);
}
const updated = {
...game,
deck,
playerCards
};
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];
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
);
}
/*
* ============================================================
* 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 is only allowed on the initial 2-card hand.
*/
if (
game.playerCards.length !==
2
) {
return temporaryEphemeralReply(
interaction,
{
content:
"You can only Double on your initial two cards."
}
);
}
const balance =
await getCookieBalance(
interaction.user.id
);
if (
balance <
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"
);
}
/*
* Double always automatically stands.
*/
return standBlackjack(
interaction,
updated
);
}
/*
* ============================================================
* NATURAL BLACKJACK
* ============================================================
*/
async function resolveInitialBlackjack(
interaction,
game,
playerBlackjack,
dealerBlackjack
) {
if (
playerBlackjack &&
dealerBlackjack
) {
return finishBlackjack(
interaction,
game,
"Push"
);
}
if (playerBlackjack) {
return finishBlackjack(
interaction,
game,
"Blackjack"
);
}
return finishBlackjack(
interaction,
game,
"DealerBlackjack"
);
}
/*
* ============================================================
* FINISH GAME
* ============================================================
*/
async function finishBlackjack(
interaction,
game,
result
) {
const houseId =
interaction.client.user.id;
let payout =
0;
switch (result) {
/*
* Regular win returns stake + matching winnings.
*/
case "Win":
case "DealerBust":
payout =
game.bet *
2;
break;
/*
* Push simply returns the player's stake.
*/
case "Push":
payout =
game.bet;
break;
/*
* Natural Blackjack:
*
* original stake + 3:2 winnings
*
* = 2.5x total returned
*/
case "Blackjack":
payout =
Math.floor(
game.bet *
2.5
);
break;
/*
* Loss / Bust / Dealer Blackjack:
* house keeps the stake.
*/
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}`
);
return interaction.update({
embeds: [
new EmbedBuilder()
.setTitle(
"🃏 Blackjack Error"
)
.setDescription(
"The Crumb Saucer vault couldn't cover the payout. Please contact an administrator."
)
],
components:
buildReturnButtons()
});
}
await transferCookies(
houseId,
game.userId,
payout
);
}
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 embed =
new EmbedBuilder()
.setTitle(
getResultTitle(
result
)
)
.setDescription(
[
"**Dealer**",
formatCards(
game.dealerCards
),
`Score: **${getHandValue(game.dealerCards)}**`,
"",
"**You**",
formatCards(
game.playerCards
),
`Score: **${getHandValue(game.playerCards)}**`
].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:
`crumbSaucerBlackjack:${game.userId}`
});
return interaction.update({
embeds: [
embed
],
components:
buildReturnButtons()
});
}
/*
* ============================================================
* 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}`
});
}
/*
* ============================================================
* 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(
"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 buildReturnButtons() {
return [
new ActionRowBuilder()
.addComponents(
new ButtonBuilder()
.setCustomId(
"crumbSaucerBlackjack"
)
.setLabel(
"Play Again"
)
.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(
" "
);
}
/*
* ============================================================
* 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
});
}
/*
* ============================================================
* MENU OWNERS
* ============================================================
*/
function getBlackjackSessionOwner(
interaction
) {
const footer =
interaction.message
.embeds[0]
?.footer
?.text;
const parts =
footer?.split(":")
??
[];
/*
* Main Crumb Saucer
*
* crumbSaucer:<userId>
*/
if (
parts[0] ===
"crumbSaucer"
) {
return parts[1];
}
/*
* Blackjack wager / result menu
*
* crumbSaucerBlackjack:<userId>
*/
if (
parts[0] ===
"crumbSaucerBlackjack"
) {
return parts[1];
}
/*
* Active Blackjack hand
*
* crumbBlackjack:<gameId>:<userId>
*/
if (
parts[0] ===
"crumbBlackjack"
) {
return parts[2];
}
return null;
}
function getBlackjackMenuOwner(
interaction
) {
const footer =
interaction.message
.embeds[0]
?.footer
?.text;
const [
type,
userId
] =
footer?.split(":")
??
[];
if (
type !==
"crumbSaucerBlackjack"
) {
return null;
}
return userId;
}
/*
* ============================================================
* 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";
case "Bust":
return "💥 Bust!";
case "DealerBlackjack":
return "🃏 Dealer Blackjack";
default:
return "🃏 Dealer Wins";
}
}
module.exports = {
showBlackjack,
startBlackjack,
hitBlackjack,
standBlackjack,
doubleBlackjack
};