Files
Arthonor-Neo/utils/nutBlackjack.js
T

1792 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.
// utils/nutBlackjack.js
const {
EmbedBuilder,
ActionRowBuilder,
ButtonBuilder,
ButtonStyle
} = require("discord.js");
const {
randomUUID
} = require("node:crypto");
const {
getCollections,
getNutBalance,
transferNuts,
temporaryEphemeralReply,
getUnixTime
} = require("./nutEconomy");
/*
* ============================================================
* CONFIG
* ============================================================
*/
const SESSION_COLLECTION =
"sessions_blackjack";
const BLACKJACK_BETS = [
10,
50,
100,
500,
1000
];
/*
* ============================================================
* SHOW BLACKJACK MENU
* ============================================================
*/
async function showNutBlackjack(
interaction
) {
const ownerId =
getBlackjackSessionOwner(
interaction
);
if (
ownerId !==
interaction.user.id
) {
return temporaryEphemeralReply(
interaction,
{
content:
"Das ist nicht deine Nut-Hub-Session."
}
);
}
/*
* Resume active hand if one exists.
*/
const active =
await getActiveBlackjack(
interaction.user.id
);
if (active) {
return interaction.update({
embeds: [
buildGameEmbed(
active
)
],
components:
buildGameButtons(
active
)
});
}
const balance =
await getNutBalance(
interaction.user.id
);
const embed =
new EmbedBuilder()
.setTitle(
"🃏 Nut Blackjack"
)
.setDescription(
[
"Schlag den Dealer ohne über **21** zu kommen.",
"",
"**Dealer stands on 17.**",
"**Blackjack pays 3:2.**",
"**Push returns your wager.**",
"",
"Choose your wager."
].join("\n")
)
.addFields({
name:
"Deine Nüsse",
value:
`**${balance.toLocaleString()} 🥜**`
})
.setFooter({
text:
`nutBlackjackMenu:${interaction.user.id}`
});
return interaction.update({
embeds: [
embed
],
components:
buildBetButtons()
});
}
/*
* ============================================================
* START BLACKJACK
* ============================================================
*/
async function startNutBlackjack(
interaction,
bet
) {
if (
!BLACKJACK_BETS.includes(
bet
)
) {
return temporaryEphemeralReply(
interaction,
{
content:
"Dieser Blackjack-Einsatz ist nicht verfügbar."
}
);
}
const ownerId =
getBlackjackSessionOwner(
interaction
);
if (
ownerId !==
interaction.user.id
) {
return temporaryEphemeralReply(
interaction,
{
content:
"Das ist nicht dein Blackjack-Tisch."
}
);
}
/*
* Prevent multiple active hands.
*/
const existing =
await getActiveBlackjack(
interaction.user.id
);
if (existing) {
return temporaryEphemeralReply(
interaction,
{
content:
"Du hast bereits eine aktive Blackjack-Hand."
}
);
}
const playerId =
interaction.user.id;
const houseId =
interaction.client.user.id;
const [
playerBalance,
houseBalance
] =
await Promise.all([
getNutBalance(
playerId
),
getNutBalance(
houseId
)
]);
if (
playerBalance <
bet
) {
return temporaryEphemeralReply(
interaction,
{
content:
`Du brauchst **${bet.toLocaleString()} Nüsse**.`
}
);
}
/*
* Natural Blackjack can require 2.5x total
* returned to the player.
*
* Since the player's wager moves into the house
* before resolving, the house needs enough liquidity
* to cover the extra 1.5x.
*/
if (
houseBalance <
Math.ceil(
bet *
1.5
)
) {
return temporaryEphemeralReply(
interaction,
{
content:
"Arthonor's Nut Stash kann diesen Einsatz aktuell nicht absichern."
}
);
}
try {
await transferNuts(
playerId,
houseId,
bet
);
} catch (error) {
if (
error.message ===
"INSUFFICIENT_NUTS"
) {
return temporaryEphemeralReply(
interaction,
{
content:
"Dein Nut-Bestand hat sich verändert."
}
);
}
throw error;
}
/*
* Fresh real 52-card deck.
*/
const deck =
createDeck();
shuffleDeck(
deck
);
const playerCards = [
deck.pop(),
deck.pop()
];
const dealerCards = [
deck.pop(),
deck.pop()
];
const game = {
sessionId:
randomUUID(),
userId:
playerId,
state:
"Playing",
bet,
originalBet:
bet,
doubled:
false,
deck,
playerCards,
dealerCards,
createdAt:
getUnixTime()
};
const {
db
} =
getCollections();
await db
.collection(
SESSION_COLLECTION
)
.insertOne(
game
);
/*
* Natural Blackjack check.
*/
const playerBlackjack =
isBlackjack(
playerCards
);
const dealerBlackjack =
isBlackjack(
dealerCards
);
if (
playerBlackjack ||
dealerBlackjack
) {
if (
playerBlackjack &&
dealerBlackjack
) {
return finishNutBlackjack(
interaction,
game,
"Push"
);
}
if (playerBlackjack) {
return finishNutBlackjack(
interaction,
game,
"Blackjack"
);
}
return finishNutBlackjack(
interaction,
game,
"DealerBlackjack"
);
}
return interaction.update({
embeds: [
buildGameEmbed(
game
)
],
components:
buildGameButtons(
game
)
});
}
/*
* ============================================================
* HIT
* ============================================================
*/
async function hitNutBlackjack(
interaction
) {
const game =
await getBlackjackFromInteraction(
interaction
);
if (
!game ||
game.state !==
"Playing"
) {
return temporaryEphemeralReply(
interaction,
{
content:
"Diese Blackjack-Hand existiert nicht mehr."
}
);
}
if (
game.userId !==
interaction.user.id
) {
return temporaryEphemeralReply(
interaction,
{
content:
"Das ist nicht deine Blackjack-Hand."
}
);
}
const deck =
[
...game.deck
];
const playerCards =
[
...game.playerCards
];
playerCards.push(
deck.pop()
);
const updated = {
...game,
deck,
playerCards
};
await updateBlackjackSession(
game.sessionId,
{
deck,
playerCards
}
);
const score =
getHandValue(
playerCards
);
if (
score >
21
) {
return finishNutBlackjack(
interaction,
updated,
"Bust"
);
}
/*
* Auto-stand on 21.
*/
if (
score ===
21
) {
return standNutBlackjack(
interaction,
updated
);
}
return interaction.update({
embeds: [
buildGameEmbed(
updated
)
],
components:
buildGameButtons(
updated
)
});
}
/*
* ============================================================
* STAND
* ============================================================
*/
async function standNutBlackjack(
interaction,
suppliedGame =
null
) {
const game =
suppliedGame
??
await getBlackjackFromInteraction(
interaction
);
if (!game) {
return temporaryEphemeralReply(
interaction,
{
content:
"Diese Blackjack-Hand existiert nicht mehr."
}
);
}
if (
game.userId !==
interaction.user.id
) {
return temporaryEphemeralReply(
interaction,
{
content:
"Das ist nicht deine Blackjack-Hand."
}
);
}
const deck =
[
...game.deck
];
const dealerCards =
[
...game.dealerCards
];
/*
* Dealer stands on all 17s.
*/
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 finishNutBlackjack(
interaction,
updated,
result
);
}
/*
* ============================================================
* DOUBLE
* ============================================================
*/
async function doubleNutBlackjack(
interaction
) {
const game =
await getBlackjackFromInteraction(
interaction
);
if (
!game ||
game.state !==
"Playing"
) {
return temporaryEphemeralReply(
interaction,
{
content:
"Diese Blackjack-Hand existiert nicht mehr."
}
);
}
if (
game.userId !==
interaction.user.id
) {
return temporaryEphemeralReply(
interaction,
{
content:
"Das ist nicht deine Blackjack-Hand."
}
);
}
/*
* Double is only possible on the original hand.
*/
if (
game.playerCards.length !==
2
||
game.doubled
) {
return temporaryEphemeralReply(
interaction,
{
content:
"Double ist nur mit den ersten zwei Karten möglich."
}
);
}
const playerBalance =
await getNutBalance(
interaction.user.id
);
if (
playerBalance <
game.originalBet
) {
return temporaryEphemeralReply(
interaction,
{
content:
`Du brauchst weitere **${game.originalBet.toLocaleString()} Nüsse** für Double.`
}
);
}
try {
await transferNuts(
interaction.user.id,
interaction.client.user.id,
game.originalBet
);
} catch (error) {
if (
error.message ===
"INSUFFICIENT_NUTS"
) {
return temporaryEphemeralReply(
interaction,
{
content:
"Du hast nicht mehr genug Nüsse für 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
};
await updateBlackjackSession(
game.sessionId,
{
deck,
playerCards,
bet:
updated.bet,
doubled:
true
}
);
if (
getHandValue(
playerCards
)
>
21
) {
return finishNutBlackjack(
interaction,
updated,
"Bust"
);
}
/*
* Double automatically stands.
*/
return standNutBlackjack(
interaction,
updated
);
}
/*
* ============================================================
* FINISH BLACKJACK
* ============================================================
*/
async function finishNutBlackjack(
interaction,
game,
result
) {
let payout =
0;
switch (result) {
/*
* Normal win:
*
* return original wager + winnings.
*/
case "Win":
case "DealerBust":
payout =
game.bet *
2;
break;
/*
* Push:
*
* wager returned.
*/
case "Push":
payout =
game.bet;
break;
/*
* Natural Blackjack:
*
* wager + 3:2 winnings
* = 2.5x returned.
*/
case "Blackjack":
payout =
Math.floor(
game.bet *
2.5
);
break;
/*
* Bust / Loss / Dealer Blackjack:
* house keeps wager.
*/
default:
payout =
0;
}
if (
payout >
0
) {
try {
await transferNuts(
interaction.client.user.id,
game.userId,
payout
);
} catch (error) {
console.error(
"[Nut Blackjack] Payout failed:",
error
);
const errorEmbed =
new EmbedBuilder()
.setTitle(
"🃏 Blackjack Error"
)
.setDescription(
[
"The hand resolved, but Arthonor's Nut Stash could not cover the payout.",
"",
"Please contact an administrator."
].join("\n")
)
.setFooter({
text:
`nutBlackjackMenu:${game.userId}`
});
return interaction.update({
embeds: [
errorEmbed
],
components:
buildResultButtons()
});
}
}
/*
* sessions_* represents currently active state.
*
* Finished hand = remove session.
*/
const {
db
} =
getCollections();
await db
.collection(
SESSION_COLLECTION
)
.deleteOne({
sessionId:
game.sessionId
});
const balance =
await getNutBalance(
game.userId
);
const profit =
payout -
game.bet;
const embed =
new EmbedBuilder()
.setTitle(
getResultTitle(
result
)
)
.setDescription(
[
"**Dealer**",
formatCards(
game.dealerCards
),
`Score: **${getHandValue(game.dealerCards)}**`,
"",
"**Du**",
formatCards(
game.playerCards
),
`Score: **${getHandValue(game.playerCards)}**`
].join("\n")
)
.addFields(
{
name:
"Einsatz",
value:
`${game.bet.toLocaleString()} 🥜`,
inline:
true
},
{
name:
"Payout",
value:
`${payout.toLocaleString()} 🥜`,
inline:
true
},
{
name:
"Profit",
value:
`${profit >= 0 ? "+" : ""}${profit.toLocaleString()} 🥜`,
inline:
true
},
{
name:
"Deine Nüsse",
value:
`${balance.toLocaleString()} 🥜`
}
)
.setFooter({
text:
`nutBlackjackMenu:${game.userId}`
});
return interaction.update({
embeds: [
embed
],
components:
buildResultButtons()
});
}
/*
* ============================================================
* ACTIVE GAME EMBED
* ============================================================
*/
function buildGameEmbed(
game
) {
const playerScore =
getHandValue(
game.playerCards
);
return new EmbedBuilder()
.setTitle(
"🃏 Nut Blackjack"
)
.setDescription(
[
"**Dealer**",
`${formatCard(game.dealerCards[0])} 🂠`,
"",
"**Du**",
formatCards(
game.playerCards
),
`Score: **${playerScore}**`
].join("\n")
)
.addFields(
{
name:
"Einsatz",
value:
`${game.bet.toLocaleString()} 🥜`,
inline:
true
},
{
name:
"Status",
value:
"**Dein Zug**",
inline:
true
}
)
.setFooter({
text:
`nutBlackjack:${game.sessionId}:${game.userId}`
});
}
/*
* ============================================================
* BET BUTTONS
* ============================================================
*/
function buildBetButtons() {
return [
new ActionRowBuilder()
.addComponents(
...BLACKJACK_BETS.map(
bet =>
new ButtonBuilder()
.setCustomId(
`nutBlackjackBet${bet}`
)
.setLabel(
bet.toLocaleString()
)
.setEmoji(
"🥜"
)
.setStyle(
bet >=
500
? ButtonStyle.Primary
: ButtonStyle.Secondary
)
)
),
new ActionRowBuilder()
.addComponents(
new ButtonBuilder()
.setCustomId(
"nutHubBack"
)
.setLabel(
"Nut Hub"
)
.setEmoji(
"◀️"
)
.setStyle(
ButtonStyle.Secondary
)
)
];
}
/*
* ============================================================
* GAME BUTTONS
* ============================================================
*/
function buildGameButtons(
game
) {
const canDouble =
game.playerCards.length ===
2
&&
!game.doubled;
return [
new ActionRowBuilder()
.addComponents(
new ButtonBuilder()
.setCustomId(
"nutBlackjackHit"
)
.setLabel(
"Hit"
)
.setEmoji(
"➕"
)
.setStyle(
ButtonStyle.Primary
),
new ButtonBuilder()
.setCustomId(
"nutBlackjackStand"
)
.setLabel(
"Stand"
)
.setEmoji(
"✋"
)
.setStyle(
ButtonStyle.Success
),
new ButtonBuilder()
.setCustomId(
"nutBlackjackDouble"
)
.setLabel(
"Double"
)
.setEmoji(
"💰"
)
.setDisabled(
!canDouble
)
.setStyle(
ButtonStyle.Danger
)
)
];
}
/*
* ============================================================
* RESULT BUTTONS
* ============================================================
*/
function buildResultButtons() {
return [
new ActionRowBuilder()
.addComponents(
new ButtonBuilder()
.setCustomId(
"nutHubBlackjack"
)
.setLabel(
"Play Again"
)
.setEmoji(
"🃏"
)
.setStyle(
ButtonStyle.Primary
),
new ButtonBuilder()
.setCustomId(
"nutHubBack"
)
.setLabel(
"Nut Hub"
)
.setEmoji(
"◀️"
)
.setStyle(
ButtonStyle.Secondary
)
)
];
}
/*
* ============================================================
* DECK
* ============================================================
*/
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;
}
/*
* Fisher-Yates shuffle.
*/
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]
];
}
}
/*
* ============================================================
* HAND VALUE
* ============================================================
*/
function getHandValue(
cards
) {
let total =
0;
let aces =
0;
for (
const card
of cards
) {
if (
card.rank ===
"A"
) {
total +=
11;
aces++;
}
else if (
[
"J",
"Q",
"K"
].includes(
card.rank
)
) {
total +=
10;
}
else {
total +=
Number(
card.rank
);
}
}
/*
* Convert Aces from 11 → 1 as required.
*/
while (
total >
21
&&
aces >
0
) {
total -=
10;
aces--;
}
return total;
}
function isBlackjack(
cards
) {
return (
cards.length ===
2
&&
getHandValue(
cards
) ===
21
);
}
/*
* ============================================================
* CARD DISPLAY
* ============================================================
*/
function formatCard(
card
) {
return `**${card.rank}${card.suit}**`;
}
function formatCards(
cards
) {
return cards
.map(
formatCard
)
.join(
" "
);
}
/*
* ============================================================
* ACTIVE SESSION
* ============================================================
*/
async function getActiveBlackjack(
userId
) {
const {
db
} =
getCollections();
return db
.collection(
SESSION_COLLECTION
)
.findOne({
userId,
state:
"Playing"
});
}
/*
* ============================================================
* GET SESSION FROM MESSAGE
* ============================================================
*/
async function getBlackjackFromInteraction(
interaction
) {
const footer =
interaction.message
?.embeds?.[0]
?.footer
?.text;
const parts =
footer?.split(":")
??
[];
if (
parts[0] !==
"nutBlackjack"
) {
return null;
}
const sessionId =
parts[1];
const {
db
} =
getCollections();
return db
.collection(
SESSION_COLLECTION
)
.findOne({
sessionId,
state:
"Playing"
});
}
/*
* ============================================================
* UPDATE SESSION
* ============================================================
*/
async function updateBlackjackSession(
sessionId,
fields
) {
const {
db
} =
getCollections();
return db
.collection(
SESSION_COLLECTION
)
.updateOne(
{
sessionId,
state:
"Playing"
},
{
$set:
fields
}
);
}
/*
* ============================================================
* SESSION OWNERSHIP
* ============================================================
*/
function getBlackjackSessionOwner(
interaction
) {
const footer =
interaction.message
?.embeds?.[0]
?.footer
?.text;
const parts =
footer?.split(":")
??
[];
/*
* Nut Hub:
*
* nutHub:<userId>
*/
if (
parts[0] ===
"nutHub"
) {
return parts[1];
}
/*
* Blackjack menu/result:
*
* nutBlackjackMenu:<userId>
*/
if (
parts[0] ===
"nutBlackjackMenu"
) {
return parts[1];
}
/*
* Active Blackjack:
*
* nutBlackjack:<sessionId>:<userId>
*/
if (
parts[0] ===
"nutBlackjack"
) {
return parts[2];
}
return null;
}
/*
* ============================================================
* RESULT TITLES
* ============================================================
*/
function getResultTitle(
result
) {
switch (
result
) {
case "Blackjack":
return "🃏 BLACKJACK!";
case "Win":
return "🎉 Du gewinnst!";
case "DealerBust":
return "💥 Dealer Bust!";
case "Push":
return "🤝 Push";
case "Bust":
return "💥 Bust!";
case "DealerBlackjack":
return "🃏 Dealer Blackjack";
default:
return "🃏 Dealer gewinnt";
}
}
/*
* ============================================================
* EXPORTS
* ============================================================
*/
module.exports = {
showNutBlackjack,
startNutBlackjack,
hitNutBlackjack,
standNutBlackjack,
doubleNutBlackjack
};