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

1191 lines
22 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 {
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
) {
const balance =
await getCookieBalance(
interaction.user.id
);
const embed =
buildMainMenuEmbed(
interaction.user,
balance
);
const components =
buildMainMenuButtons();
/*
* Slash command:
* reply normally.
*
* Button:
* update existing menu message.
*/
if (
interaction.isButton()
) {
return interaction.update({
embeds: [
embed
],
components
});
}
return interaction.reply({
embeds: [
embed
],
components
});
}
/*
* ============================================================
* SLOT MENU
* ============================================================
*/
async function showSlots(
interaction
) {
const ownerId =
getMenuOwner(
interaction
);
if (
ownerId !==
interaction.user.id
) {
return temporaryEphemeralReply(
interaction,
{
content:
"This isn't your Crumb Saucer session. Open your own with `/crumbsaucer`."
}
);
}
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** — ×1.5",
"🍒 **Triple Cherry** — ×2",
"🍪 **Triple Cookie** — ×3",
"⭐ **Triple Star** — ×5",
"💎 **Jackpot** — ×10"
].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 =
getSlotsOwner(
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: []
});
/*
* A few slow edits give a slot-machine feel
* without hammering the Discord API.
*/
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.
*
* The original stake was already transferred
* to the house, so x2 means a total payout of 2x.
*/
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."
)
],
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:
`×${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
}
)
.setFooter({
text:
`crumbSaucerSlots:${interaction.user.id}`
});
return interaction.editReply({
embeds: [
resultEmbed
],
components:
buildResultButtons()
});
}
/*
* ============================================================
* 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.",
"",
"*More attractions coming soon...*"
].join("\n")
)
.addFields({
name:
"Your Vault",
value:
`**${balance.toLocaleString()} 🍪**`
})
.setFooter({
text:
`crumbSaucer:${user.id}`
});
}
/*
* ============================================================
* BUTTONS
* ============================================================
*/
function buildMainMenuButtons() {
return [
new ActionRowBuilder()
.addComponents(
new ButtonBuilder()
.setCustomId(
"crumbSaucerSlots"
)
.setLabel(
"Slots"
)
.setEmoji(
"🎰"
)
.setStyle(
ButtonStyle.Primary
),
new ButtonBuilder()
.setCustomId(
"crumbSaucerHeist"
)
.setLabel(
"Vault Heist"
)
.setEmoji(
"🏦"
)
.setStyle(
ButtonStyle.Danger
),
new ButtonBuilder()
.setCustomId(
"crumbSaucerBlackjack"
)
.setLabel(
"Blackjack"
)
.setEmoji(
"🃏"
)
.setStyle(
ButtonStyle.Success
)
)
];
}
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
)
)
];
}
function buildResultButtons() {
return [
new ActionRowBuilder()
.addComponents(
new ButtonBuilder()
.setCustomId(
"crumbSaucerSlots"
)
.setLabel(
"Play Again"
)
.setEmoji(
"🎰"
)
.setStyle(
ButtonStyle.Primary
),
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();
/*
* Winning middle row.
*/
if (
outcome.type ===
"triple"
) {
grid[1] = [
outcome.symbol,
outcome.symbol,
outcome.symbol
];
return grid;
}
/*
* Pair.
*/
if (
outcome.type ===
"pair"
) {
const symbol =
randomItem(
SLOT_SYMBOLS
);
let other;
do {
other =
randomItem(
SLOT_SYMBOLS
);
} while (
other === symbol
);
/*
* Randomize the location of the odd symbol.
*/
const oddPosition =
Math.floor(
Math.random() *
3
);
grid[1] = [
symbol,
symbol,
symbol
];
grid[1][
oddPosition
] =
other;
return grid;
}
/*
* Loss:
* ensure all three center symbols differ.
*/
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!";
}
}
/*
* ============================================================
* OWNER RESOLUTION
* ============================================================
*/
function getMenuOwner(
interaction
) {
const footer =
interaction.message
.embeds[0]
?.footer
?.text;
const [
type,
userId
] =
footer
?.split(":")
??
[];
if (
type !==
"crumbSaucer"
) {
return null;
}
return userId;
}
function getSlotsOwner(
interaction
) {
const footer =
interaction.message
.embeds[0]
?.footer
?.text;
const [
type,
userId
] =
footer
?.split(":")
??
[];
if (
type !==
"crumbSaucerSlots"
) {
return null;
}
return userId;
}
function sleep(ms) {
return new Promise(
resolve =>
setTimeout(
resolve,
ms
)
);
}
module.exports = {
showCrumbSaucer,
showSlots,
playSlots
};