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

1473 lines
28 KiB
JavaScript

const {
EmbedBuilder,
ActionRowBuilder,
ButtonBuilder,
ButtonStyle
} = require("discord.js");
const {
getCookieBalance,
transferCookies,
temporaryEphemeralReply,
randomItem
} = require("./cookieEconomy");
/*
* ============================================================
* SLOT CONFIG
* ============================================================
*/
const SLOT_BETS = [
10,
50,
100,
500,
1000
];
const SLOT_SYMBOLS = [
"🍒",
"🍋",
"🍪",
"⭐",
"💎"
];
/*
* Weighted outcomes.
*/
const SLOT_OUTCOMES = [
{
weight: 300,
type: "loss",
multiplier: 0
},
{
weight: 390,
type: "pair",
multiplier: 0.5
},
{
weight: 150,
type: "triple",
symbol: "🍋",
multiplier: 1.5
},
{
weight: 80,
type: "triple",
symbol: "🍒",
multiplier: 2
},
{
weight: 45,
type: "triple",
symbol: "🍪",
multiplier: 3
},
{
weight: 25,
type: "triple",
symbol: "⭐",
multiplier: 5
},
{
weight: 10,
type: "triple",
symbol: "💎",
multiplier: 10
}
];
/*
* ============================================================
* MAIN MENU
* ============================================================
*/
async function showCrumbSaucer(
interaction
) {
/*
* If this is being called from a button,
* verify that the player owns the current
* Crumb Saucer / game session.
*
* This prevents another user from pressing
* "Crumb Saucer" / "Back" on somebody else's
* game message and taking over their session.
*/
if (
interaction.isButton()
) {
const ownerId =
getCrumbSaucerSessionOwner(
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 =
buildMainMenuEmbed(
interaction.user,
balance
);
const components =
buildMainMenuButtons();
/*
* Button:
* update existing menu/game message.
*/
if (
interaction.isButton()
) {
return interaction.update({
embeds: [
embed
],
components
});
}
/*
* Slash command:
* create a new Crumb Saucer.
*/
return interaction.reply({
embeds: [
embed
],
components
});
}
/*
* ============================================================
* SLOT MENU
* ============================================================
*/
async function showSlots(
interaction
) {
const ownerId =
getSlotsSessionOwner(
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 Slots"
)
.setDescription(
[
"Welcome to the Cookie reels.",
"",
"Choose your wager below.",
"",
"🍋 **Triple Lemon** — x1.5",
"🍒 **Triple Cherry** — x2",
"🍪 **Triple Cookie** — x3",
"⭐ **Triple Star** — x5",
"💎 **Jackpot** — x10"
].join("\n")
)
.addFields({
name:
"Your Vault",
value:
`**${balance.toLocaleString()} 🍪**`
})
.setFooter({
text:
`crumbSaucerSlots:${interaction.user.id}`
});
return interaction.update({
embeds: [
embed
],
components:
buildSlotBetButtons()
});
}
/*
* ============================================================
* PLAY SLOTS
* ============================================================
*/
async function playSlots(
interaction,
bet
) {
const ownerId =
getSlotsSessionOwner(
interaction
);
if (
ownerId !==
interaction.user.id
) {
return temporaryEphemeralReply(
interaction,
{
content:
"These aren't your slots. Open your own Crumb Saucer session."
}
);
}
if (
!SLOT_BETS.includes(
bet
)
) {
return temporaryEphemeralReply(
interaction,
{
content:
"That wager isn't available."
}
);
}
const playerId =
interaction.user.id;
const vaultId =
interaction.client.user.id;
const [
playerBalance,
vaultBalance
] =
await Promise.all([
getCookieBalance(
playerId
),
getCookieBalance(
vaultId
)
]);
if (
playerBalance <
bet
) {
return temporaryEphemeralReply(
interaction,
{
content:
`You need **${bet.toLocaleString()} Cookies** for that wager.`
}
);
}
/*
* Worst-case payout is x10.
*
* Once the house receives the player's bet,
* it needs another 9x available to cover
* the complete x10 payout.
*/
if (
vaultBalance <
bet * 9
) {
return temporaryEphemeralReply(
interaction,
{
content:
"The Crumb Saucer vault doesn't currently have enough Cookies to cover that wager."
}
);
}
/*
* Take wager first.
*/
try {
await transferCookies(
playerId,
vaultId,
bet
);
} catch (error) {
if (
error.message ===
"INSUFFICIENT_COOKIES"
) {
return temporaryEphemeralReply(
interaction,
{
content:
"Your Cookie balance changed before the spin could begin."
}
);
}
throw error;
}
/*
* Lock the buttons while spinning.
*/
await interaction.update({
embeds: [
buildSpinningEmbed(
bet,
generateRandomGrid(),
"🎰 Spinning..."
)
],
components: []
});
await sleep(
700
);
await interaction.editReply({
embeds: [
buildSpinningEmbed(
bet,
generateRandomGrid(),
"🎰 The reels are moving..."
)
],
components: []
});
await sleep(
700
);
await interaction.editReply({
embeds: [
buildSpinningEmbed(
bet,
generateRandomGrid(),
"🎰 Almost..."
)
],
components: []
});
await sleep(
700
);
/*
* Actual outcome is determined only now.
*/
const outcome =
rollSlotOutcome();
const grid =
generateOutcomeGrid(
outcome
);
const payout =
Math.floor(
bet *
outcome.multiplier
);
/*
* House pays winnings.
*/
if (
payout >
0
) {
try {
await transferCookies(
vaultId,
playerId,
payout
);
} catch (error) {
console.error(
"CRITICAL: Crumb Saucer payout failed:",
error
);
return interaction.editReply({
embeds: [
new EmbedBuilder()
.setTitle(
"🎰 Crumb Saucer Error"
)
.setDescription(
"The reels stopped, but the payout system had a problem. Please contact an administrator."
)
.setFooter({
text:
`crumbSaucerSlotsResult:${playerId}:${bet}`
})
],
components:
buildResultButtons()
});
}
}
const [
playerAfter,
vaultAfter
] =
await Promise.all([
getCookieBalance(
playerId
),
getCookieBalance(
vaultId
)
]);
const profit =
payout -
bet;
const resultEmbed =
new EmbedBuilder()
.setTitle(
"🎰 Crumb Saucer Slots"
)
.setDescription(
[
buildSlotDisplay(
grid
),
"",
`### ${getSlotResultName(outcome)}`
].join("\n")
)
.addFields(
{
name:
"Bet",
value:
`${bet.toLocaleString()} 🍪`,
inline:
true
},
{
name:
"Multiplier",
value:
`x${outcome.multiplier}`,
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:
`${vaultAfter.toLocaleString()} 🍪`,
inline:
true
}
)
/*
* Store owner + previous wager.
*
* Run It Back can now replay the exact
* same wager without opening the bet menu.
*/
.setFooter({
text:
`crumbSaucerSlotsResult:${playerId}:${bet}`
});
return interaction.editReply({
embeds: [
resultEmbed
],
components:
buildResultButtons()
});
}
/*
* ============================================================
* RUN IT BACK
* ============================================================
*/
async function runSlotsBack(
interaction
) {
const footer =
interaction.message
?.embeds?.[0]
?.footer
?.text;
const parts =
footer?.split(":")
??
[];
if (
parts[0] !==
"crumbSaucerSlotsResult"
) {
return temporaryEphemeralReply(
interaction,
{
content:
"This Slots result can no longer be replayed."
}
);
}
const ownerId =
parts[1];
const bet =
Number(
parts[2]
);
if (
ownerId !==
interaction.user.id
) {
return temporaryEphemeralReply(
interaction,
{
content:
"These aren't your slots."
}
);
}
if (
!SLOT_BETS.includes(
bet
)
) {
return temporaryEphemeralReply(
interaction,
{
content:
"The previous wager is no longer available."
}
);
}
return playSlots(
interaction,
bet
);
}
/*
* ============================================================
* MAIN MENU EMBED
* ============================================================
*/
function buildMainMenuEmbed(
user,
balance
) {
return new EmbedBuilder()
.setTitle(
"🎰 The Crumb Saucer"
)
.setDescription(
[
`Welcome, ${user}!`,
"",
"Where Cookies are won, lost,",
"and occasionally confiscated by ShaiBot.",
"",
"### 🎰 Slots",
"Spin the reels and chase the Cookie jackpot.",
"",
"### 🏦 Vault Heist",
"Attempt to breach ShaiBot's Cookie Vault.",
"",
"### 🃏 Blackjack",
"Beat the dealer without going over 21.",
"",
"### 🎡 Roulette",
"Bet on Red, Black, Odd, Even, or Zero.",
"",
"*More attractions coming soon... make a lala offering or buy me a white monster*"
].join("\n")
)
.addFields({
name:
"Your Vault",
value:
`**${balance.toLocaleString()} 🍪**`
})
.setFooter({
text:
`crumbSaucer:${user.id}`
});
}
/*
* ============================================================
* MAIN MENU BUTTONS
* ============================================================
*/
function buildMainMenuButtons() {
return [
new ActionRowBuilder()
.addComponents(
new ButtonBuilder()
.setCustomId(
"crumbSaucerSlots"
)
.setLabel(
"Slots"
)
.setEmoji(
"🎰"
)
.setStyle(
ButtonStyle.Primary
),
new ButtonBuilder()
.setCustomId(
"crumbSaucerBlackjack"
)
.setLabel(
"Blackjack"
)
.setEmoji(
"🃏"
)
.setStyle(
ButtonStyle.Success
),
new ButtonBuilder()
.setCustomId(
"crumbSaucerRoulette"
)
.setLabel(
"Roulette"
)
.setEmoji(
"🎡"
)
.setStyle(
ButtonStyle.Primary
),
new ButtonBuilder()
.setCustomId(
"crumbSaucerHeist"
)
.setLabel(
"Vault Heist"
)
.setEmoji(
"🏦"
)
.setStyle(
ButtonStyle.Danger
)
)
];
}
/*
* ============================================================
* SLOT BET BUTTONS
* ============================================================
*/
function buildSlotBetButtons() {
return [
new ActionRowBuilder()
.addComponents(
new ButtonBuilder()
.setCustomId(
"crumbSaucerSlots10"
)
.setLabel(
"10"
)
.setEmoji(
"🍪"
)
.setStyle(
ButtonStyle.Secondary
),
new ButtonBuilder()
.setCustomId(
"crumbSaucerSlots50"
)
.setLabel(
"50"
)
.setEmoji(
"🍪"
)
.setStyle(
ButtonStyle.Secondary
),
new ButtonBuilder()
.setCustomId(
"crumbSaucerSlots100"
)
.setLabel(
"100"
)
.setEmoji(
"🍪"
)
.setStyle(
ButtonStyle.Primary
),
new ButtonBuilder()
.setCustomId(
"crumbSaucerSlots500"
)
.setLabel(
"500"
)
.setEmoji(
"🍪"
)
.setStyle(
ButtonStyle.Primary
),
new ButtonBuilder()
.setCustomId(
"crumbSaucerSlots1000"
)
.setLabel(
"1,000"
)
.setEmoji(
"🍪"
)
.setStyle(
ButtonStyle.Danger
)
),
new ActionRowBuilder()
.addComponents(
new ButtonBuilder()
.setCustomId(
"crumbSaucerBack"
)
.setLabel(
"Back to Crumb Saucer"
)
.setEmoji(
"◀️"
)
.setStyle(
ButtonStyle.Secondary
)
)
];
}
/*
* ============================================================
* SLOT RESULT BUTTONS
* ============================================================
*/
function buildResultButtons() {
return [
new ActionRowBuilder()
.addComponents(
/*
* Exact same wager immediately.
*/
new ButtonBuilder()
.setCustomId(
"crumbSaucerSlotsRunItBack"
)
.setLabel(
"Run It Back"
)
.setEmoji(
"🔁"
)
.setStyle(
ButtonStyle.Success
),
/*
* Return to wager selection.
*/
new ButtonBuilder()
.setCustomId(
"crumbSaucerSlots"
)
.setLabel(
"Change Bet"
)
.setEmoji(
"🎰"
)
.setStyle(
ButtonStyle.Primary
),
/*
* Main Saucer.
*/
new ButtonBuilder()
.setCustomId(
"crumbSaucerBack"
)
.setLabel(
"Crumb Saucer"
)
.setEmoji(
"◀️"
)
.setStyle(
ButtonStyle.Secondary
)
)
];
}
/*
* ============================================================
* SLOT LOGIC
* ============================================================
*/
function rollSlotOutcome() {
const totalWeight =
SLOT_OUTCOMES.reduce(
(
total,
outcome
) =>
total +
outcome.weight,
0
);
let roll =
Math.random() *
totalWeight;
for (
const outcome
of SLOT_OUTCOMES
) {
roll -=
outcome.weight;
if (
roll <=
0
) {
return outcome;
}
}
return SLOT_OUTCOMES[0];
}
function generateRandomGrid() {
return Array.from(
{
length: 3
},
() =>
Array.from(
{
length: 3
},
() =>
randomItem(
SLOT_SYMBOLS
)
)
);
}
function generateOutcomeGrid(
outcome
) {
const grid =
generateRandomGrid();
if (
outcome.type ===
"triple"
) {
grid[1] = [
outcome.symbol,
outcome.symbol,
outcome.symbol
];
return grid;
}
if (
outcome.type ===
"pair"
) {
const symbol =
randomItem(
SLOT_SYMBOLS
);
let other;
do {
other =
randomItem(
SLOT_SYMBOLS
);
} while (
other ===
symbol
);
const oddPosition =
Math.floor(
Math.random() *
3
);
grid[1] = [
symbol,
symbol,
symbol
];
grid[1][
oddPosition
] =
other;
return grid;
}
let line;
do {
line = [
randomItem(
SLOT_SYMBOLS
),
randomItem(
SLOT_SYMBOLS
),
randomItem(
SLOT_SYMBOLS
)
];
} while (
new Set(
line
).size <
3
);
grid[1] =
line;
return grid;
}
function buildSlotDisplay(
grid
) {
return [
"╔═════════╗",
`║ ${grid[0][0]} │ ${grid[0][1]} │ ${grid[0][2]} ║`,
`║ ${grid[1][0]} │ ${grid[1][1]} │ ${grid[1][2]} ║`,
`║ ${grid[2][0]} │ ${grid[2][1]} │ ${grid[2][2]} ║`,
"╚═════════╝"
].join("\n");
}
function buildSpinningEmbed(
bet,
grid,
status
) {
return new EmbedBuilder()
.setTitle(
"🎰 Crumb Saucer Slots"
)
.setDescription(
[
buildSlotDisplay(
grid
),
"",
`**${status}**`
].join("\n")
)
.addFields({
name:
"Bet",
value:
`**${bet.toLocaleString()} 🍪**`
});
}
function getSlotResultName(
outcome
) {
if (
outcome.type ===
"loss"
) {
return "No luck this time.";
}
if (
outcome.type ===
"pair"
) {
return "Pair!";
}
switch (
outcome.symbol
) {
case "🍋":
return "🍋 Triple Lemon!";
case "🍒":
return "🍒 Triple Cherry!";
case "🍪":
return "🍪 Triple Cookie!";
case "⭐":
return "⭐ Triple Star!";
case "💎":
return "💎 JACKPOT! 💎";
default:
return "Winner!";
}
}
/*
* ============================================================
* SESSION OWNER RESOLUTION
* ============================================================
*
* This intentionally understands every Crumb Saucer
* screen that can navigate back to the main menu.
*
* That prevents another player from pressing the
* Back / Crumb Saucer button on somebody else's
* public interaction.
*/
function getCrumbSaucerSessionOwner(
interaction
) {
const footer =
interaction.message
?.embeds?.[0]
?.footer
?.text;
const parts =
footer?.split(":")
??
[];
switch (
parts[0]
) {
/*
* Main menu
*/
case "crumbSaucer":
/*
* Slots wager menu
*/
case "crumbSaucerSlots":
/*
* Slots completed result
*/
case "crumbSaucerSlotsResult":
/*
* Blackjack wager/result menu
*/
case "crumbSaucerBlackjack":
case "crumbSaucerBlackjackResult":
/*
* Roulette
*/
case "crumbRouletteMenu":
case "crumbRouletteBet":
case "crumbRouletteResult":
/*
* Heist
*/
case "crumbSaucerHeist":
case "cookieHeist":
return parts[1];
/*
* Active Blackjack footer:
*
* crumbBlackjack:<gameId>:<userId>
*/
case "crumbBlackjack":
return parts[2];
default:
return null;
}
}
/*
* ============================================================
* SLOT OWNER RESOLUTION
* ============================================================
*/
function getSlotsSessionOwner(
interaction
) {
const footer =
interaction.message
?.embeds?.[0]
?.footer
?.text;
const parts =
footer?.split(":")
??
[];
/*
* Main Crumb Saucer.
*/
if (
parts[0] ===
"crumbSaucer"
) {
return parts[1];
}
/*
* Slots wager menu.
*/
if (
parts[0] ===
"crumbSaucerSlots"
) {
return parts[1];
}
/*
* Completed Slots result.
*/
if (
parts[0] ===
"crumbSaucerSlotsResult"
) {
return parts[1];
}
return null;
}
/*
* ============================================================
* SLEEP
* ============================================================
*/
function sleep(
ms
) {
return new Promise(
resolve =>
setTimeout(
resolve,
ms
)
);
}
/*
* ============================================================
* EXPORTS
* ============================================================
*/
module.exports = {
showCrumbSaucer,
showSlots,
playSlots,
runSlotsBack
};