Files
ShaiBot/utils/crumbRoulette.js
T
2026-08-22 20:27:42 +02:00

1520 lines
29 KiB
JavaScript

const {
EmbedBuilder,
ActionRowBuilder,
ButtonBuilder,
ButtonStyle
} = require("discord.js");
const {
getCookieBalance,
transferCookies,
temporaryEphemeralReply
} = require("./cookieEconomy");
const {
trackProfileEvent
} = require("./profileSystem");
/*
* ============================================================
* CONFIG
* ============================================================
*/
const ROULETTE_BETS = [
10,
50,
100,
500,
1000
];
const ROULETTE_CHOICES = {
Red: {
name: "Red",
emoji: "🔴",
multiplier: 2
},
Black: {
name: "Black",
emoji: "⚫",
multiplier: 2
},
Odd: {
name: "Odd",
emoji: "🔢",
multiplier: 2
},
Even: {
name: "Even",
emoji: "2️⃣",
multiplier: 2
},
Zero: {
name: "Zero",
emoji: "🟢",
multiplier: 36
}
};
/*
* European Roulette red numbers.
*/
const RED_NUMBERS =
new Set([
1,
3,
5,
7,
9,
12,
14,
16,
18,
19,
21,
23,
25,
27,
30,
32,
34,
36
]);
/*
* ============================================================
* SHOW ROULETTE
* ============================================================
*/
async function showRoulette(
interaction
) {
const ownerId =
getRouletteSessionOwner(
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 embed =
new EmbedBuilder()
.setTitle(
"🎡 Crumb Saucer Roulette"
)
.setDescription(
[
"European Roulette — **0 to 36**.",
"",
"Choose your wager.",
"",
"🔴 **Red** — x2",
"⚫ **Black** — x2",
"🔢 **Odd** — x2",
"2️⃣ **Even** — x2",
"🟢 **Zero** — x36"
].join("\n")
)
.addFields({
name:
"Your Vault",
value:
`**${balance.toLocaleString()} 🍪**`
})
.setFooter({
text:
`crumbRouletteMenu:${interaction.user.id}`
});
return interaction.update({
embeds: [
embed
],
components:
buildBetButtons()
});
}
/*
* ============================================================
* CHOOSE NORMAL WAGER
* ============================================================
*/
async function chooseRouletteBet(
interaction,
bet
) {
if (
!ROULETTE_BETS.includes(
bet
)
) {
return temporaryEphemeralReply(
interaction,
{
content:
"That Roulette wager isn't available."
}
);
}
const ownerId =
getRouletteSessionOwner(
interaction
);
if (
ownerId !==
interaction.user.id
) {
return temporaryEphemeralReply(
interaction,
{
content:
"This isn't your Roulette table."
}
);
}
return showRouletteChoices(
interaction,
bet
);
}
/*
* ============================================================
* ALL IN
* ============================================================
*/
async function startRouletteAllIn(
interaction
) {
const ownerId =
getRouletteSessionOwner(
interaction
);
if (
ownerId !==
interaction.user.id
) {
return temporaryEphemeralReply(
interaction,
{
content:
"This isn't your Roulette table."
}
);
}
/*
* Resolve the 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."
}
);
}
/*
* All In only selects the wager.
*
* Nothing is transferred until the player
* chooses Red / Black / Odd / Even / Zero.
*/
return showRouletteChoices(
interaction,
balance
);
}
/*
* ============================================================
* SHOW CHOICE MENU
* ============================================================
*
* Shared by:
*
* - normal wager buttons
* - All In
* - Run It Back
*/
async function showRouletteChoices(
interaction,
bet
) {
if (
!Number.isSafeInteger(
bet
)
||
bet <=
0
) {
return temporaryEphemeralReply(
interaction,
{
content:
"That Roulette wager isn't valid."
}
);
}
const balance =
await getCookieBalance(
interaction.user.id
);
if (
balance <
bet
) {
return temporaryEphemeralReply(
interaction,
{
content:
`You need **${bet.toLocaleString()} Cookies** for that wager.`
}
);
}
const embed =
new EmbedBuilder()
.setTitle(
"🎡 Crumb Saucer Roulette"
)
.setDescription(
[
`Wager: **${bet.toLocaleString()} 🍪**`,
"",
"Where do you want to place your Cookies?",
"",
"🔴 **Red** — x2",
"⚫ **Black** — x2",
"🔢 **Odd** — x2",
"2️⃣ **Even** — x2",
"🟢 **Zero** — x36"
].join("\n")
)
.addFields({
name:
"Your Vault",
value:
`**${balance.toLocaleString()} 🍪**`
})
.setFooter({
text:
`crumbRouletteBet:${interaction.user.id}:${bet}`
});
return interaction.update({
embeds: [
embed
],
components:
buildChoiceButtons()
});
}
/*
* ============================================================
* RUN IT BACK
* ============================================================
*
* Remembers ONLY the previous wager.
*
* It does NOT remember Red / Black / Odd / Even / Zero.
*/
async function runRouletteBack(
interaction
) {
const footer =
interaction.message
?.embeds?.[0]
?.footer
?.text;
const parts =
footer?.split(":")
??
[];
/*
* Expected:
*
* crumbRouletteResult:<userId>:<bet>
*/
if (
parts[0] !==
"crumbRouletteResult"
) {
return temporaryEphemeralReply(
interaction,
{
content:
"This Roulette 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 Roulette session."
}
);
}
if (
!Number.isSafeInteger(
bet
)
||
bet <=
0
) {
return temporaryEphemeralReply(
interaction,
{
content:
"The previous wager is no longer valid."
}
);
}
/*
* Same wager, fresh choice.
*/
return showRouletteChoices(
interaction,
bet
);
}
/*
* ============================================================
* PLAY ROULETTE
* ============================================================
*/
async function playRoulette(
interaction,
choiceKey
) {
const choice =
ROULETTE_CHOICES[
choiceKey
];
if (!choice) {
return temporaryEphemeralReply(
interaction,
{
content:
"That Roulette bet doesn't exist."
}
);
}
const footer =
interaction.message
?.embeds?.[0]
?.footer
?.text;
const parts =
footer?.split(":")
??
[];
if (
parts[0] !==
"crumbRouletteBet"
) {
return temporaryEphemeralReply(
interaction,
{
content:
"This Roulette wager is no longer valid."
}
);
}
const ownerId =
parts[1];
const bet =
Number(
parts[2]
);
if (
ownerId !==
interaction.user.id
) {
return temporaryEphemeralReply(
interaction,
{
content:
"This isn't your Roulette table."
}
);
}
/*
* IMPORTANT:
*
* Do NOT require ROULETTE_BETS.includes(bet) here.
*
* All In and Run It Back may legitimately use
* amounts outside the predefined wager buttons.
*/
if (
!Number.isSafeInteger(
bet
)
||
bet <=
0
) {
return temporaryEphemeralReply(
interaction,
{
content:
"That Roulette wager is invalid."
}
);
}
const playerId =
interaction.user.id;
const houseId =
interaction.client.user.id;
const [
playerBalance,
houseBalance
] =
await Promise.all([
getCookieBalance(
playerId
),
getCookieBalance(
houseId
)
]);
/*
* Balance may have changed while the player
* was choosing their bet type.
*/
if (
playerBalance <
bet
) {
return temporaryEphemeralReply(
interaction,
{
content:
"You no longer have enough Cookies for that wager."
}
);
}
/*
* Worst payout depends on the selected wager.
*
* Red / Black / Odd / Even:
* x2
*
* Zero:
* x36
*
* The player's wager is transferred to the house
* before payout, so houseBalance + bet is available
* after accepting the wager.
*/
const maximumPayout =
bet *
choice.multiplier;
if (
houseBalance +
bet <
maximumPayout
) {
return temporaryEphemeralReply(
interaction,
{
content:
"The Crumb Saucer vault can't currently cover that Roulette wager."
}
);
}
/*
* ========================================================
* TAKE WAGER
* ========================================================
*/
try {
await transferCookies(
playerId,
houseId,
bet
);
} catch (error) {
if (
error.message ===
"INSUFFICIENT_COOKIES"
) {
return temporaryEphemeralReply(
interaction,
{
content:
"Your balance changed before the wheel could spin."
}
);
}
throw error;
}
/*
* ========================================================
* PSEUDO ANIMATION
* ========================================================
*/
await interaction.update({
embeds: [
buildSpinEmbed(
choice,
bet,
randomRouletteNumber(),
"The wheel begins to spin..."
)
],
components: []
});
await sleep(
650
);
await interaction.editReply({
embeds: [
buildSpinEmbed(
choice,
bet,
randomRouletteNumber(),
"Round and round..."
)
],
components: []
});
await sleep(
650
);
await interaction.editReply({
embeds: [
buildSpinEmbed(
choice,
bet,
randomRouletteNumber(),
"The ball is slowing down..."
)
],
components: []
});
await sleep(
750
);
/*
* ========================================================
* ACTUAL RESULT
* ========================================================
*/
const result =
randomRouletteNumber();
const won =
didRouletteBetWin(
choiceKey,
result
);
const payout =
won
? bet *
choice.multiplier
: 0;
/*
* ========================================================
* PAYOUT
* ========================================================
*/
if (
payout >
0
) {
try {
await transferCookies(
houseId,
playerId,
payout
);
} catch (error) {
console.error(
"[Roulette] Payout failed:",
error
);
return interaction.editReply({
embeds: [
new EmbedBuilder()
.setTitle(
"🎡 Roulette Error"
)
.setDescription(
"The wheel resolved, but the payout failed. Please contact an administrator."
)
.setFooter({
text:
`crumbRouletteResult:${playerId}:${bet}`
})
],
components:
buildResultButtons()
});
}
}
const [
playerAfter,
houseAfter
] =
await Promise.all([
getCookieBalance(
playerId
),
getCookieBalance(
houseId
)
]);
const profit =
payout -
bet;
await trackProfileEvent(
interaction,
playerId,
{
type:
"game_result",
game:
"roulette",
result:
profit > 0
? "win"
: profit < 0
? "loss"
: "push"
}
);
if (
profit >
0
) {
await trackProfileEvent(
interaction,
playerId,
{
type:
"cookie_gain",
amount:
profit
}
);
}
else if (
profit <
0
) {
await trackProfileEvent(
interaction,
playerId,
{
type:
"cookie_loss",
amount:
Math.abs(
profit
)
}
);
}
const resultEmbed =
new EmbedBuilder()
.setTitle(
won
? "🎉 Roulette Winner!"
: "🎡 Roulette"
)
.setDescription(
[
"## The ball lands on...",
"",
`# ${formatRouletteNumber(result)}`,
"",
`${choice.emoji} Your bet: **${choice.name}**`,
"",
won
? `🎉 **YOU WIN ${payout.toLocaleString()} 🍪!**`
: "The house takes this one."
].join("\n")
)
.addFields(
{
name:
"Wager",
value:
`${bet.toLocaleString()} 🍪`,
inline:
true
},
{
name:
"Payout",
value:
`${payout.toLocaleString()} 🍪`,
inline:
true
},
{
name:
"Profit",
value:
`${profit >= 0 ? "+" : ""}${profit.toLocaleString()} 🍪`,
inline:
true
},
{
name:
"Your Vault",
value:
`${playerAfter.toLocaleString()} 🍪`,
inline:
true
},
{
name:
"House Vault",
value:
`${houseAfter.toLocaleString()} 🍪`,
inline:
true
}
)
/*
* Remember only:
*
* owner
* wager
*/
.setFooter({
text:
`crumbRouletteResult:${playerId}:${bet}`
});
return interaction.editReply({
embeds: [
resultEmbed
],
components:
buildResultButtons()
});
}
/*
* ============================================================
* RESULT LOGIC
* ============================================================
*/
function didRouletteBetWin(
choice,
number
) {
switch (choice) {
case "Red":
return (
number !==
0
&&
RED_NUMBERS.has(
number
)
);
case "Black":
return (
number !==
0
&&
!RED_NUMBERS.has(
number
)
);
case "Odd":
return (
number !==
0
&&
number %
2 ===
1
);
case "Even":
return (
number !==
0
&&
number %
2 ===
0
);
case "Zero":
return (
number ===
0
);
default:
return false;
}
}
/*
* ============================================================
* DISPLAY
* ============================================================
*/
function buildSpinEmbed(
choice,
bet,
number,
status
) {
return new EmbedBuilder()
.setTitle(
"🎡 Crumb Saucer Roulette"
)
.setDescription(
[
"## 🎡 Spinning...",
"",
`### ${formatRouletteNumber(number)}`,
"",
`**${status}**`
].join("\n")
)
.addFields(
{
name:
"Bet",
value:
`${choice.emoji} ${choice.name}`,
inline:
true
},
{
name:
"Wager",
value:
`${bet.toLocaleString()} 🍪`,
inline:
true
}
);
}
function formatRouletteNumber(
number
) {
if (
number ===
0
) {
return "🟢 0";
}
if (
RED_NUMBERS.has(
number
)
) {
return `🔴 ${number}`;
}
return `⚫ ${number}`;
}
/*
* ============================================================
* WAGER BUTTONS
* ============================================================
*/
function buildBetButtons() {
return [
new ActionRowBuilder()
.addComponents(
...ROULETTE_BETS.map(
bet =>
new ButtonBuilder()
.setCustomId(
`crumbRouletteBet${bet}`
)
.setLabel(
bet.toLocaleString()
)
.setEmoji(
"🍪"
)
.setStyle(
bet >=
500
? ButtonStyle.Primary
: ButtonStyle.Secondary
)
)
),
new ActionRowBuilder()
.addComponents(
new ButtonBuilder()
.setCustomId(
"crumbRouletteAllIn"
)
.setLabel(
"All In"
)
.setEmoji(
"🔥"
)
.setStyle(
ButtonStyle.Danger
),
new ButtonBuilder()
.setCustomId(
"crumbSaucerBack"
)
.setLabel(
"Back to Crumb Saucer"
)
.setEmoji(
"◀️"
)
.setStyle(
ButtonStyle.Secondary
)
)
];
}
/*
* ============================================================
* CHOICE BUTTONS
* ============================================================
*/
function buildChoiceButtons() {
return [
new ActionRowBuilder()
.addComponents(
new ButtonBuilder()
.setCustomId(
"crumbRouletteRed"
)
.setLabel(
"Red"
)
.setEmoji(
"🔴"
)
.setStyle(
ButtonStyle.Danger
),
new ButtonBuilder()
.setCustomId(
"crumbRouletteBlack"
)
.setLabel(
"Black"
)
.setEmoji(
"⚫"
)
.setStyle(
ButtonStyle.Secondary
),
new ButtonBuilder()
.setCustomId(
"crumbRouletteOdd"
)
.setLabel(
"Odd"
)
.setStyle(
ButtonStyle.Primary
),
new ButtonBuilder()
.setCustomId(
"crumbRouletteEven"
)
.setLabel(
"Even"
)
.setStyle(
ButtonStyle.Primary
),
new ButtonBuilder()
.setCustomId(
"crumbRouletteZero"
)
.setLabel(
"Zero"
)
.setEmoji(
"🟢"
)
.setStyle(
ButtonStyle.Success
)
),
new ActionRowBuilder()
.addComponents(
new ButtonBuilder()
.setCustomId(
"crumbSaucerRoulette"
)
.setLabel(
"Change Wager"
)
.setEmoji(
"↩️"
)
.setStyle(
ButtonStyle.Secondary
)
)
];
}
/*
* ============================================================
* RESULT BUTTONS
* ============================================================
*/
function buildResultButtons() {
return [
new ActionRowBuilder()
.addComponents(
/*
* Same wager.
*
* Player chooses Red/Black/etc again.
*/
new ButtonBuilder()
.setCustomId(
"crumbRouletteRunItBack"
)
.setLabel(
"Run It Back"
)
.setEmoji(
"🔁"
)
.setStyle(
ButtonStyle.Success
),
/*
* Return to wager selection.
*/
new ButtonBuilder()
.setCustomId(
"crumbSaucerRoulette"
)
.setLabel(
"Change Bet"
)
.setEmoji(
"🎡"
)
.setStyle(
ButtonStyle.Primary
),
/*
* Main Crumb Saucer.
*/
new ButtonBuilder()
.setCustomId(
"crumbSaucerBack"
)
.setLabel(
"Crumb Saucer"
)
.setEmoji(
"◀️"
)
.setStyle(
ButtonStyle.Secondary
)
)
];
}
/*
* ============================================================
* SESSION OWNER
* ============================================================
*/
function getRouletteSessionOwner(
interaction
) {
const footer =
interaction.message
?.embeds?.[0]
?.footer
?.text;
const parts =
footer?.split(":")
??
[];
/*
* Main Saucer
*/
if (
parts[0] ===
"crumbSaucer"
) {
return parts[1];
}
/*
* Roulette wager menu
*/
if (
parts[0] ===
"crumbRouletteMenu"
) {
return parts[1];
}
/*
* Roulette choice menu.
*/
if (
parts[0] ===
"crumbRouletteBet"
) {
return parts[1];
}
/*
* Roulette result.
*/
if (
parts[0] ===
"crumbRouletteResult"
) {
return parts[1];
}
return null;
}
/*
* ============================================================
* RANDOM
* ============================================================
*/
function randomRouletteNumber() {
return Math.floor(
Math.random() *
37
);
}
function sleep(
ms
) {
return new Promise(
resolve =>
setTimeout(
resolve,
ms
)
);
}
/*
* ============================================================
* EXPORTS
* ============================================================
*/
module.exports = {
showRoulette,
chooseRouletteBet,
startRouletteAllIn,
showRouletteChoices,
playRoulette,
runRouletteBack
};