diff --git a/buttons/cookies/bossBurst.js b/buttons/cookies/bossBurst.js new file mode 100644 index 0000000..9f088ef --- /dev/null +++ b/buttons/cookies/bossBurst.js @@ -0,0 +1,18 @@ +const { + performBossAction +} = require( + "../../utils/cookieBoss" +); + + +module.exports = { + name: + "cookieBossBurst", + + async execute(interaction) { + return performBossAction( + interaction, + "Burst" + ); + } +}; \ No newline at end of file diff --git a/buttons/cookies/bossJoinChampion.js b/buttons/cookies/bossJoinChampion.js new file mode 100644 index 0000000..3e96499 --- /dev/null +++ b/buttons/cookies/bossJoinChampion.js @@ -0,0 +1,18 @@ +const { + joinBoss +} = require( + "../../utils/cookieBoss" +); + + +module.exports = { + name: + "cookieBossJoinChampion", + + async execute(interaction) { + return joinBoss( + interaction, + "Champion" + ); + } +}; \ No newline at end of file diff --git a/buttons/cookies/bossJoinFighter.js b/buttons/cookies/bossJoinFighter.js new file mode 100644 index 0000000..6fbf3d7 --- /dev/null +++ b/buttons/cookies/bossJoinFighter.js @@ -0,0 +1,18 @@ +const { + joinBoss +} = require( + "../../utils/cookieBoss" +); + + +module.exports = { + name: + "cookieBossJoinFighter", + + async execute(interaction) { + return joinBoss( + interaction, + "Fighter" + ); + } +}; \ No newline at end of file diff --git a/buttons/cookies/bossJoinScout.js b/buttons/cookies/bossJoinScout.js new file mode 100644 index 0000000..fe968d6 --- /dev/null +++ b/buttons/cookies/bossJoinScout.js @@ -0,0 +1,18 @@ +const { + joinBoss +} = require( + "../../utils/cookieBoss" +); + + +module.exports = { + name: + "cookieBossJoinScout", + + async execute(interaction) { + return joinBoss( + interaction, + "Scout" + ); + } +}; \ No newline at end of file diff --git a/buttons/cookies/bossLimit.js b/buttons/cookies/bossLimit.js new file mode 100644 index 0000000..2fdd8eb --- /dev/null +++ b/buttons/cookies/bossLimit.js @@ -0,0 +1,18 @@ +const { + performBossAction +} = require( + "../../utils/cookieBoss" +); + + +module.exports = { + name: + "cookieBossLimit", + + async execute(interaction) { + return performBossAction( + interaction, + "Limit" + ); + } +}; \ No newline at end of file diff --git a/buttons/cookies/bossStart.js b/buttons/cookies/bossStart.js new file mode 100644 index 0000000..950c3fd --- /dev/null +++ b/buttons/cookies/bossStart.js @@ -0,0 +1,17 @@ +const { + startBossBattle +} = require( + "../../utils/cookieBoss" +); + + +module.exports = { + name: + "cookieBossStart", + + async execute(interaction) { + return startBossBattle( + interaction + ); + } +}; \ No newline at end of file diff --git a/buttons/cookies/bossStrike.js b/buttons/cookies/bossStrike.js new file mode 100644 index 0000000..171f016 --- /dev/null +++ b/buttons/cookies/bossStrike.js @@ -0,0 +1,18 @@ +const { + performBossAction +} = require( + "../../utils/cookieBoss" +); + + +module.exports = { + name: + "cookieBossStrike", + + async execute(interaction) { + return performBossAction( + interaction, + "Strike" + ); + } +}; \ No newline at end of file diff --git a/buttons/cookies/heistHack.js b/buttons/cookies/heistHack.js new file mode 100644 index 0000000..7ce86ff --- /dev/null +++ b/buttons/cookies/heistHack.js @@ -0,0 +1,18 @@ +const { + resolveCookieHeist +} = require( + "../../utils/cookieHeist" +); + + +module.exports = { + name: + "cookieHeistHack", + + async execute(interaction) { + return resolveCookieHeist( + interaction, + "Hack" + ); + } +}; \ No newline at end of file diff --git a/buttons/cookies/heistSmash.js b/buttons/cookies/heistSmash.js new file mode 100644 index 0000000..dbce8c9 --- /dev/null +++ b/buttons/cookies/heistSmash.js @@ -0,0 +1,18 @@ +const { + resolveCookieHeist +} = require( + "../../utils/cookieHeist" +); + + +module.exports = { + name: + "cookieHeistSmash", + + async execute(interaction) { + return resolveCookieHeist( + interaction, + "Smash" + ); + } +}; \ No newline at end of file diff --git a/buttons/cookies/heistSneak.js b/buttons/cookies/heistSneak.js new file mode 100644 index 0000000..f56c760 --- /dev/null +++ b/buttons/cookies/heistSneak.js @@ -0,0 +1,18 @@ +const { + resolveCookieHeist +} = require( + "../../utils/cookieHeist" +); + + +module.exports = { + name: + "cookieHeistSneak", + + async execute(interaction) { + return resolveCookieHeist( + interaction, + "Sneak" + ); + } +}; \ No newline at end of file diff --git a/commands/applications/cookies.js b/commands/applications/cookies.js index fdf1fc4..88e81d9 100644 --- a/commands/applications/cookies.js +++ b/commands/applications/cookies.js @@ -1,74 +1,245 @@ const { SlashCommandBuilder, EmbedBuilder, + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, MessageFlags } = require("discord.js"); -const { mClient } = require("../.."); +const { randomUUID } = + require("node:crypto"); + +const { + getCollections, + getCookieBalance, + changeCookies, + transferCookies, + getCooldown, + setCooldown, + getUnixTime, + randomInteger, + randomFloat, + randomItem, + formatPercentage +} = require("../../utils/cookieEconomy"); -const DATABASE = "neo-db"; +/* + * CONFIG + */ -const COOKIE_COLLECTION = "items_cookies"; -const COOLDOWN_COLLECTION = "cooldowns_cookies"; +const COLLECT_COOLDOWN = + 60 * 60; -const COLLECT_COOLDOWN = 60 * 60; // 1 hour -const STEAL_COOLDOWN = 15 * 60; // 15 minutes +const STEAL_COOLDOWN = + 15 * 60; -const STEAL_SUCCESS_CHANCE = 0.50; +const SLOT_MIN_BET = + 10; -const ABRA_ID = "378135175059603457"; -const ABRA_MULTIPLIER = 1.5; +const SLOT_MAX_BET = + 1000; + +const BOSS_SPAWN_COST = + 500; + +const BOSS_JOIN_DURATION = + 30 * 60; + +const BOSS_MINIMUM_PLAYERS = + 5; + + +const ABRA_ID = + "378135175059603457"; + +const ABRA_MULTIPLIER = + 1.5; + + +/* + * SLOT TABLE + * + * RTP ≈ 94% + */ + +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 + } +]; + + +const SLOT_SYMBOLS = [ + "🍒", + "🍋", + "🍪", + "⭐", + "💎" +]; module.exports = { data: new SlashCommandBuilder() .setName("cookies") - .setDescription("The ShaiBot cookie economy.") + .setDescription( + "The ShaiBot cookie economy." + ) .addSubcommand(subcommand => subcommand .setName("collect") - .setDescription("Collect a fresh batch of cookies.") + .setDescription( + "Collect a fresh batch of cookies." + ) ) .addSubcommand(subcommand => subcommand .setName("steal") - .setDescription("Attempt to steal another user's cookies.") + .setDescription( + "Attempt to steal another user's cookies." + ) + .addUserOption(option => option .setName("target") - .setDescription("The unfortunate victim.") + .setDescription( + "The unfortunate victim." + ) .setRequired(true) ) ) + .addSubcommand(subcommand => + subcommand + .setName("heist") + .setDescription( + "Attempt to breach the ShaiBot Cookie Vault." + ) + ) + + .addSubcommand(subcommand => + subcommand + .setName("slots") + .setDescription( + "Play the Shaiwase Cookie Slots." + ) + + .addIntegerOption(option => + option + .setName("bet") + .setDescription( + "How many Cookies to wager." + ) + .setRequired(true) + .setMinValue( + SLOT_MIN_BET + ) + .setMaxValue( + SLOT_MAX_BET + ) + ) + ) + + .addSubcommandGroup(group => + group + .setName("boss") + .setDescription( + "Cookie Boss commands." + ) + + .addSubcommand(subcommand => + subcommand + .setName("spawn") + .setDescription( + `Spawn a Cookie Boss for ${BOSS_SPAWN_COST} Cookies.` + ) + ) + ) + .addSubcommand(subcommand => subcommand .setName("check") - .setDescription("Check a cookie balance.") + .setDescription( + "Check a Cookie balance." + ) + .addUserOption(option => option .setName("target") - .setDescription("The user whose cookies you want to check.") + .setDescription( + "The user whose Cookies you want to check." + ) ) ) .addSubcommand(subcommand => subcommand .setName("give") - .setDescription("Give some of your cookies to another user.") + .setDescription( + "Give some of your Cookies to another user." + ) + .addUserOption(option => option .setName("target") - .setDescription("The recipient.") + .setDescription( + "The recipient." + ) .setRequired(true) ) + .addIntegerOption(option => option .setName("amount") - .setDescription("How many cookies to give.") + .setDescription( + "How many Cookies to give." + ) .setRequired(true) .setMinValue(1) ) @@ -77,309 +248,118 @@ module.exports = { .addSubcommand(subcommand => subcommand .setName("leaderboard") - .setDescription("Show the richest cookie collectors.") + .setDescription( + "Show the richest Cookie collectors." + ) ), + async execute(interaction) { - switch (interaction.options.getSubcommand()) { + const group = + interaction.options + .getSubcommandGroup(false); + + const subcommand = + interaction.options + .getSubcommand(); + + + if ( + group === "boss" && + subcommand === "spawn" + ) { + return spawnBoss( + interaction + ); + } + + + switch (subcommand) { case "collect": - return collect(interaction); + return collect( + interaction + ); case "steal": - return steal(interaction); + return steal( + interaction + ); + + case "heist": + return heist( + interaction + ); + + case "slots": + return slots( + interaction + ); case "check": - return check(interaction); + return check( + interaction + ); case "give": - return give(interaction); + return give( + interaction + ); case "leaderboard": - return leaderboard(interaction); - - default: - return interaction.reply({ - content: "Unknown subcommand.", - ephemeral: true - }); + return leaderboard( + interaction + ); } } }; /* - * DATABASE - */ - -function getCollections() { - const db = mClient.db(DATABASE); - - return { - cookies: db.collection(COOKIE_COLLECTION), - cooldowns: db.collection(COOLDOWN_COLLECTION) - }; -} - - -/* - * COOKIE HELPERS - */ - -async function getCookieBalance(userId) { - const { cookies } = getCollections(); - - const result = await cookies.findOne( - { - userId - }, - { - projection: { - _id: 0, - cookies: 1 - } - } - ); - - return result?.cookies ?? 0; -} - - -async function changeCookies( - userId, - amount -) { - const { cookies } = - getCollections(); - - - return cookies.updateOne( - { - userId - }, - { - $inc: { - cookies: - amount - }, - - $setOnInsert: { - userId - } - }, - { - upsert: - true - } - ); -} - - -/* - * COOKIE TRANSFER - */ - -async function transferCookies( - fromUserId, - toUserId, - amount -) { - const { cookies } = - getCollections(); - - - /* - * Atomically debit the sender only if - * they still have enough cookies. - */ - const debit = - await cookies.updateOne( - { - userId: - fromUserId, - - cookies: { - $gte: - amount - } - }, - { - $inc: { - cookies: - -amount - } - } - ); - - - if ( - debit.modifiedCount !== 1 - ) { - throw new Error( - "INSUFFICIENT_COOKIES" - ); - } - - - /* - * Credit the recipient. - */ - try { - await cookies.updateOne( - { - userId: - toUserId - }, - { - $inc: { - cookies: - amount - }, - - $setOnInsert: { - userId: - toUserId - } - }, - { - upsert: - true - } - ); - - } catch (error) { - - /* - * Credit failed. - * - * Attempt to restore the sender's - * cookies so the transfer doesn't - * destroy currency. - */ - try { - await cookies.updateOne( - { - userId: - fromUserId - }, - { - $inc: { - cookies: - amount - } - } - ); - - } catch (rollbackError) { - console.error( - "CRITICAL: Cookie transfer rollback failed:", - rollbackError - ); - } - - - throw error; - } -} - - -/* - * COOLDOWN HELPERS - * - * cooldowns_cookies: - * - * { - * userId: "...", - * guildId: "...", - * collect: 1786741200, - * steal: 1786738500 - * } - */ - -async function getCooldown(userId, guildId, type) { - const { cooldowns } = getCollections(); - - const result = await cooldowns.findOne( - { - userId, - guildId - }, - { - projection: { - _id: 0, - [type]: 1 - } - } - ); - - return result?.[type] ?? 0; -} - - -async function setCooldown(userId, guildId, type, duration) { - const { cooldowns } = getCollections(); - - const now = getUnixTime(); - const next = now + duration; - - await cooldowns.updateOne( - { - userId, - guildId - }, - { - $set: { - [type]: next - }, - $setOnInsert: { - userId, - guildId - } - }, - { - upsert: true - } - ); - - return next; -} - - -/* - * /cookies collect + * COLLECT */ async function collect(interaction) { - const userId = interaction.user.id; - const guildId = interaction.guildId; + const userId = + interaction.user.id; - const now = getUnixTime(); + const now = + getUnixTime(); - const cooldown = await getCooldown( - userId, - guildId, - "collect" - ); + + const cooldown = + await getCooldown( + userId, + interaction.guildId, + "collect" + ); if (cooldown > now) { return interaction.reply({ content: `The batch is still cooking. Come back 🍪`, - ephemeral: true + flags: + MessageFlags.Ephemeral }); } - let amount = randomInteger(1, 20); - - - /* - * Legacy Abra bonus. - */ - if (userId === ABRA_ID) { - amount = Math.ceil( - amount * ABRA_MULTIPLIER + let amount = + randomInteger( + 1, + 20 ); + + + if ( + userId === + ABRA_ID + ) { + amount = + Math.ceil( + amount * + ABRA_MULTIPLIER + ); } @@ -391,23 +371,48 @@ async function collect(interaction) { await setCooldown( userId, - guildId, + interaction.guildId, "collect", COLLECT_COOLDOWN ); + const balance = + await getCookieBalance( + userId + ); + + + const embed = + new EmbedBuilder() + .setTitle( + "🍪 Fresh Batch" + ) + .setDescription( + randomCollectResponse( + amount, + interaction.user + ) + ) + .addFields({ + name: + "Your Vault", + + value: + `**${balance.toLocaleString()} Cookies**` + }); + + return interaction.reply({ - content: randomCollectResponse( - amount, - interaction.user - ) + embeds: [ + embed + ] }); } /* - * /cookies steal + * NORMAL PLAYER STEAL */ async function steal(interaction) { @@ -421,14 +426,29 @@ async function steal(interaction) { ); - /* - * VALIDATION - */ - - if (target.id === user.id) { + if ( + target.id === + user.id + ) { return interaction.reply({ content: - "C'mon, don't embarrass yourself. You can't steal from yourself.", + "You can't steal from yourself.", + flags: + MessageFlags.Ephemeral + }); + } + + + /* + * ShaiBot itself is reserved for /cookies heist. + */ + if ( + target.id === + interaction.client.user.id + ) { + return interaction.reply({ + content: + "The ShaiBot Vault requires a proper `/cookies heist`.", flags: MessageFlags.Ephemeral }); @@ -438,17 +458,13 @@ async function steal(interaction) { if (target.bot) { return interaction.reply({ content: - "Bots have highly sophisticated Cookie security systems.", + "Other bots are protected by Cookie Law.", flags: MessageFlags.Ephemeral }); } - /* - * COOLDOWN - */ - const now = getUnixTime(); @@ -464,17 +480,13 @@ async function steal(interaction) { if (cooldown > now) { return interaction.reply({ content: - `Lay low for a while. You can steal again .`, + `You can steal again .`, flags: MessageFlags.Ephemeral }); } - /* - * BALANCES - */ - const [ yourCookies, theirCookies @@ -503,18 +515,13 @@ async function steal(interaction) { if (theirCookies < 1) { return interaction.reply({ content: - `Shame on you! ${target} doesn't have any Cookies. D:`, - + `${target} doesn't have any Cookies.`, flags: MessageFlags.Ephemeral }); } - /* - * DYNAMIC SUCCESS CHANCE - */ - const successChance = getStealChance( yourCookies, @@ -527,70 +534,23 @@ async function steal(interaction) { successChance; - /* - * SUCCESS - */ + let embed; + if (success) { - - /* - * Roll a weighted percentage - * of the target's entire vault. - */ - const { - amount, - percentage + amount } = getStealAmount( theirCookies ); - try { - await transferCookies( - target.id, - user.id, - amount - ); - - } catch (error) { - - /* - * Target may have spent/given/stolen - * cookies between our balance check - * and the actual transfer. - */ - - if ( - error.message === - "INSUFFICIENT_COOKIES" - ) { - return interaction.reply({ - content: - `${target}'s Cookie balance changed before you could finish the robbery.`, - - flags: - MessageFlags.Ephemeral - }); - } - - - throw error; - } - - - /* - * Apply cooldown after robbery. - */ - - const nextSteal = - await setCooldown( - user.id, - interaction.guildId, - "steal", - STEAL_COOLDOWN - ); + await transferCookies( + target.id, + user.id, + amount + ); const actualPercentage = @@ -601,78 +561,100 @@ async function steal(interaction) { ); - return interaction.reply({ - content: - `${randomStealSuccessResponse(amount, target)}\n` + - `🍪 You made off with **${actualPercentage.toFixed(1)}%** of their vault!\n` + - `🎲 **Success Chance:** ${formatPercentage(successChance)}\n` + - `⏱️ Next robbery ` - }); - } - - - /* - * FAILURE - * - * Penalty is also percentage based. - * - * Roll between roughly 1–8% of the - * attacker's current vault. - */ - - const penaltyPercentage = - randomFloat( - 0.01, - 0.08 - ); - - - const penalty = - Math.max( - 1, - - Math.min( - yourCookies, - - Math.floor( - yourCookies * - penaltyPercentage + embed = + new EmbedBuilder() + .setTitle( + "🥷 Cookie Robbery Successful" ) - ) - ); + .setDescription( + randomStealSuccessResponse( + amount, + target + ) + ) + .addFields( + { + name: + "Vault Taken", + + value: + `**${actualPercentage.toFixed(1)}%**`, + + inline: + true + }, + + { + name: + "Success Chance", + + value: + `**${formatPercentage(successChance)}**`, + + inline: + true + } + ); + + } else { + const penaltyPercentage = + randomFloat( + 0.01, + 0.08 + ); - try { + const penalty = + Math.max( + 1, + + Math.min( + yourCookies, + + Math.floor( + yourCookies * + penaltyPercentage + ) + ) + ); + + + /* + * Normal steal failure pays the victim. + */ await transferCookies( user.id, target.id, penalty ); - } catch (error) { - if ( - error.message === - "INSUFFICIENT_COOKIES" - ) { - return interaction.reply({ - content: - "Your Cookie balance changed before the robbery could finish.", + embed = + new EmbedBuilder() + .setTitle( + "🚨 Cookie Robbery Failed" + ) + .setDescription( + randomStealFailResponse( + penalty, + target + ) + ) + .addFields( + { + name: + "Success Chance", - flags: - MessageFlags.Ephemeral - }); - } + value: + `**${formatPercentage(successChance)}**`, - - throw error; + inline: + true + } + ); } - /* - * Apply cooldown. - */ - const nextSteal = await setCooldown( user.id, @@ -682,73 +664,759 @@ async function steal(interaction) { ); - const actualPenaltyPercentage = - ( - penalty / - yourCookies * - 100 - ); + embed.addFields({ + name: + "Next Robbery", + + value: + `` + }); return interaction.reply({ - content: - `${randomStealFailResponse(penalty, target)}\n` + - `💸 You lost **${actualPenaltyPercentage.toFixed(1)}%** of your vault.\n` + - `🎲 **Success Chance:** ${formatPercentage(successChance)}\n` + - `⏱️ Next robbery ` + embeds: [ + embed + ] }); } + /* - * /cookies check + * HEIST ENTRY + */ + +async function heist(interaction) { + const vaultId = + interaction.client.user.id; + + + const [ + vault, + player + ] = + await Promise.all([ + getCookieBalance( + vaultId + ), + + getCookieBalance( + interaction.user.id + ) + ]); + + + if (player < 1) { + return interaction.reply({ + content: + "You need Cookies before attempting a vault heist.", + flags: + MessageFlags.Ephemeral + }); + } + + + if (vault < 1) { + return interaction.reply({ + content: + "The ShaiBot Vault is currently empty.", + flags: + MessageFlags.Ephemeral + }); + } + + + const security = + getSecurityLabel( + vault + ); + + + const embed = + new EmbedBuilder() + .setTitle( + "🏦 ShaiBot Cookie Vault" + ) + .setDescription( + [ + `**Vault:** ${vault.toLocaleString()} 🍪`, + `**Security:** ${security}`, + "", + "Choose your approach.", + "", + "🥷 **Sneak** — safer, smaller haul.", + "💻 **Hack** — balanced risk and reward.", + "💥 **Smash** — dangerous, but lucrative." + ].join("\n") + ) + .setFooter({ + text: + `cookieHeist:${interaction.user.id}` + }); + + + const row = + new ActionRowBuilder() + .addComponents( + new ButtonBuilder() + .setCustomId( + "cookieHeistSneak" + ) + .setLabel( + "Sneak" + ) + .setEmoji( + "🥷" + ) + .setStyle( + ButtonStyle.Primary + ), + + new ButtonBuilder() + .setCustomId( + "cookieHeistHack" + ) + .setLabel( + "Hack" + ) + .setEmoji( + "💻" + ) + .setStyle( + ButtonStyle.Secondary + ), + + new ButtonBuilder() + .setCustomId( + "cookieHeistSmash" + ) + .setLabel( + "Smash" + ) + .setEmoji( + "💥" + ) + .setStyle( + ButtonStyle.Danger + ) + ); + + + return interaction.reply({ + embeds: [ + embed + ], + + components: [ + row + ] + }); +} + + +/* + * SLOTS + */ + +async function slots(interaction) { + const bet = + interaction.options.getInteger( + "bet", + true + ); + + + 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 interaction.reply({ + content: + "You don't have enough Cookies for that bet.", + flags: + MessageFlags.Ephemeral + }); + } + + + /* + * The house needs enough BEFORE the wager + * to honor a 10x payout after receiving the bet. + */ + if ( + vaultBalance < + bet * 9 + ) { + return interaction.reply({ + content: + "The ShaiBot Vault doesn't currently have enough liquidity for that wager.", + flags: + MessageFlags.Ephemeral + }); + } + + + await transferCookies( + playerId, + vaultId, + bet + ); + + + const spinning = + new EmbedBuilder() + .setTitle( + "🎰 Shaiwase Cookie Slots" + ) + .setDescription( + buildSlotDisplay( + generateRandomGrid() + ) + ) + .addFields({ + name: + "Bet", + + value: + `**${bet.toLocaleString()} 🍪**` + }); + + + await interaction.reply({ + embeds: [ + spinning + ] + }); + + + await sleep(750); + + + await interaction.editReply({ + embeds: [ + EmbedBuilder + .from(spinning) + .setDescription( + buildSlotDisplay( + generateRandomGrid() + ) + ) + ] + }); + + + await sleep(750); + + + await interaction.editReply({ + embeds: [ + EmbedBuilder + .from(spinning) + .setDescription( + buildSlotDisplay( + generateRandomGrid() + ) + ) + ] + }); + + + await sleep(750); + + + const outcome = + rollSlotOutcome(); + + + const grid = + generateOutcomeGrid( + outcome + ); + + + const payout = + Math.floor( + bet * + outcome.multiplier + ); + + + if (payout > 0) { + await transferCookies( + vaultId, + playerId, + payout + ); + } + + + const [ + finalPlayer, + finalVault + ] = + await Promise.all([ + getCookieBalance( + playerId + ), + + getCookieBalance( + vaultId + ) + ]); + + + const profit = + payout - bet; + + + const finalEmbed = + new EmbedBuilder() + .setTitle( + "🎰 Shaiwase Cookie Slots" + ) + .setDescription( + buildSlotDisplay( + grid + ) + ) + .addFields( + { + name: + "Result", + + value: + getSlotResultName( + outcome + ) + }, + + { + 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: + `${finalPlayer.toLocaleString()} 🍪`, + + inline: + true + }, + + { + name: + "House Vault", + + value: + `${finalVault.toLocaleString()} 🍪`, + + inline: + true + } + ); + + + return interaction.editReply({ + embeds: [ + finalEmbed + ] + }); +} + + +/* + * BOSS SPAWN + */ + +async function spawnBoss(interaction) { + const { + db + } = + getCollections(); + + + const events = + db.collection( + "items_events" + ); + + + /* + * One unresolved boss per guild. + */ + + const existing = + await events.findOne({ + eventType: + "CookieBoss", + + guildId: + interaction.guildId, + + state: { + $in: [ + "Joining", + "Active", + "Starting", + "Resolving" + ] + } + }); + + + if (existing) { + return interaction.reply({ + content: + "There is already an active Cookie Boss in this server.", + flags: + MessageFlags.Ephemeral + }); + } + + + const balance = + await getCookieBalance( + interaction.user.id + ); + + + if ( + balance < + BOSS_SPAWN_COST + ) { + return interaction.reply({ + content: + `You need **${BOSS_SPAWN_COST} Cookies** to summon the boss.`, + flags: + MessageFlags.Ephemeral + }); + } + + + const eventId = + randomUUID(); + + const escrowId = + `boss:${eventId}`; + + const now = + getUnixTime(); + + const joinUntil = + now + + BOSS_JOIN_DURATION; + + + /* + * Spawner fee seeds the encounter pool. + */ + + await transferCookies( + interaction.user.id, + escrowId, + BOSS_SPAWN_COST + ); + + + const embed = + new EmbedBuilder() + .setTitle( + "🍪 The Crumbbound Behemoth Appears!" + ) + .setDescription( + [ + "*Drawn by the scent of an irresponsibly large Cookie Vault,", + "the Crumbbound Behemoth has lumbered into Shaiwase.*", + "", + `**Recruitment ends:** `, + `**Minimum adventurers:** ${BOSS_MINIMUM_PLAYERS}`, + `**Reward Pool:** ${BOSS_SPAWN_COST.toLocaleString()} 🍪`, + "", + "Choose how many Cookies you want to commit to the fight.", + "Your entry tier determines your temporary HP and Power." + ].join("\n") + ) + .setFooter({ + text: + `cookieBoss:${eventId}` + }); + + + const joinRow = + new ActionRowBuilder() + .addComponents( + new ButtonBuilder() + .setCustomId( + "cookieBossJoinScout" + ) + .setLabel( + "Scout — 100" + ) + .setEmoji( + "🍪" + ) + .setStyle( + ButtonStyle.Secondary + ), + + new ButtonBuilder() + .setCustomId( + "cookieBossJoinFighter" + ) + .setLabel( + "Fighter — 500" + ) + .setEmoji( + "⚔️" + ) + .setStyle( + ButtonStyle.Primary + ), + + new ButtonBuilder() + .setCustomId( + "cookieBossJoinChampion" + ) + .setLabel( + "Champion — 1000" + ) + .setEmoji( + "🔥" + ) + .setStyle( + ButtonStyle.Danger + ) + ); + + + const startRow = + new ActionRowBuilder() + .addComponents( + new ButtonBuilder() + .setCustomId( + "cookieBossStart" + ) + .setLabel( + "Start Battle" + ) + .setEmoji( + "⚔️" + ) + .setStyle( + ButtonStyle.Success + ) + ); + + + const message = + await interaction.reply({ + embeds: [ + embed + ], + + components: [ + joinRow, + startRow + ], + + fetchReply: + true + }); + + + await events.insertOne({ + eventType: + "CookieBoss", + + eventId, + + escrowId, + + guildId: + interaction.guildId, + + channelId: + interaction.channelId, + + messageId: + message.id, + + creatorId: + interaction.user.id, + + state: + "Joining", + + createdAt: + now, + + joinUntil, + + battleStartedAt: + null, + + battleEndsAt: + null, + + minimumParticipants: + BOSS_MINIMUM_PLAYERS, + + rewardPool: + BOSS_SPAWN_COST, + + boss: { + bossId: + "crumbbound-behemoth", + + name: + "The Crumbbound Behemoth", + + health: + 15000, + + maxHealth: + 15000, + + rage: + 0 + }, + + participants: + {} + }); +} + + +/* + * CHECK */ async function check(interaction) { const target = - interaction.options.getUser("target") - ?? interaction.user; + interaction.options.getUser( + "target" + ) + ?? + interaction.user; const balance = - await getCookieBalance(target.id); + await getCookieBalance( + target.id + ); - const mood = - balance === 0 - ? "😔" - : "🍪"; + const embed = + new EmbedBuilder() + .setTitle( + "🍪 Cookie Vault" + ) + .setDescription( + `${target} has **${balance.toLocaleString()} Cookies**.` + ); return interaction.reply({ - content: - `${target} has **${balance.toLocaleString()} Cookie${balance === 1 ? "" : "s"}**! ${mood}` + embeds: [ + embed + ] }); } /* - * /cookies give + * GIVE */ async function give(interaction) { - const user = interaction.user; + const user = + interaction.user; - const target = interaction.options.getUser( - "target", - true - ); + const target = + interaction.options.getUser( + "target", + true + ); - const amount = interaction.options.getInteger( - "amount", - true - ); + const amount = + interaction.options.getInteger( + "amount", + true + ); - if (target.id === user.id) { + if ( + target.id === + user.id + ) { return interaction.reply({ content: "You cannot give Cookies to yourself.", - ephemeral: true + flags: + MessageFlags.Ephemeral }); } @@ -756,8 +1424,9 @@ async function give(interaction) { if (target.bot) { return interaction.reply({ content: - "Bots don't need Cookies. Allegedly.", - ephemeral: true + "Use the Cookie games if you want to feed the house.", + flags: + MessageFlags.Ephemeral }); } @@ -768,12 +1437,17 @@ async function give(interaction) { target.id, amount ); + } catch (error) { - if (error.message === "INSUFFICIENT_COOKIES") { + if ( + error.message === + "INSUFFICIENT_COOKIES" + ) { return interaction.reply({ content: - "We don't run a charity here. You don't have enough Cookies!", - ephemeral: true + "You don't have enough Cookies.", + flags: + MessageFlags.Ephemeral }); } @@ -781,151 +1455,98 @@ async function give(interaction) { } + const embed = + new EmbedBuilder() + .setTitle( + "🍪 Cookies Shared" + ) + .setDescription( + `${user} gave ${target} **${amount.toLocaleString()} Cookies**.` + ); + + return interaction.reply({ - content: - `${user} shared **${amount.toLocaleString()} Cookie${amount === 1 ? "" : "s"}** with ${target} 🍪` + embeds: [ + embed + ] }); } /* - * /cookies leaderboard + * LEADERBOARD */ async function leaderboard(interaction) { - const { cookies } = getCollections(); + const { + cookies + } = + getCollections(); - const results = await cookies - .find({ - cookies: { - $gt: 0 - } - }) - .sort({ - cookies: -1 - }) - .limit(10) - .toArray(); + /* + * Only Discord snowflake-style account IDs. + * + * This prevents boss escrow accounts from appearing. + */ + const results = + await cookies + .find({ + userId: { + $regex: + /^\d+$/ + }, - - const description = results.length - ? results - .map((record, index) => { - const medal = getLeaderboardMedal(index); - - return ( - `${medal} <@${record.userId}> — ` + - `**${record.cookies.toLocaleString()}** 🍪` - ); + cookies: { + $gt: 0 + } }) - .join("\n") - : "Nobody has collected any Cookies yet."; - - - const embed = new EmbedBuilder() - .setTitle("🍪 Cookie Leaderboard") - .setDescription(description) - .setTimestamp(); - - - if (interaction.guild?.iconURL()) { - embed.setThumbnail( - interaction.guild.iconURL({ - size: 256 + .sort({ + cookies: -1 }) - ); - } + .limit(10) + .toArray(); + + + const description = + results.length + ? results + .map( + ( + record, + index + ) => { + return ( + `${getLeaderboardMedal(index)} ` + + `<@${record.userId}> — ` + + `**${record.cookies.toLocaleString()}** 🍪` + ); + } + ) + .join("\n") + + : "Nobody has collected any Cookies yet."; + + + const embed = + new EmbedBuilder() + .setTitle( + "🍪 Cookie Leaderboard" + ) + .setDescription( + description + ) + .setTimestamp(); return interaction.reply({ - embeds: [embed] + embeds: [ + embed + ] }); } -/* - * COLLECT RESPONSES - */ - -function randomCollectResponse(amount, user) { - const cookies = - `**${amount.toLocaleString()} Cookie${amount === 1 ? "" : "s"}**`; - - - const responses = [ - `${user}, I made you ${cookies} 🍪`, - `${user}, here's your ${cookies} 🍪 I ate the rest, sorry >.<`, - `Only ${cookies} 🍪 for you today ${user}... Abra stole the rest :c`, - `${user}, would you like a glass of milk with your ${cookies}? 🥛`, - `${cookies} 🍪 Careful, ${user}! They're still warm :3`, - `${cookies} 🍪 Best batch yet!`, - `${user}, these ${cookies} I bequeath unto thine own self upon this day.`, - `${user}, your ${cookies} are fresh out of the oven. Try not to burn your tongue!`, - `${user}, I bribed the Cookie Monster to spare these ${cookies} just for you.`, - `${user}, Santa came early! You got ${cookies}. Milk not included.`, - `${user}, your ${cookies} are artisanal, gluten-free, and slightly cursed.`, - `A wild baker appeared! They hand ${user} ${cookies} 🍪`, - `${user}, Abra said these ${cookies} are "totally not stolen"... 🤔`, - `${user}, here's your ${cookies} — watch out, they may be booby-trapped.` - ]; - - - return randomItem(responses); -} - - -/* - * STEAL SUCCESS RESPONSES - */ - -function randomStealSuccessResponse(amount, target) { - const cookies = - `**${amount.toLocaleString()} Cookie${amount === 1 ? "" : "s"}**`; - - - const responses = [ - `You've successfully stolen ${cookies} from ${target}!`, - `Ninja mode activated. You snatched ${cookies} from ${target} without leaving a crumb behind.`, - `Abra taught you well — ${cookies} from ${target} are now yours!`, - `Like a phantom in the night, you took ${cookies} from ${target}.`, - `You just pulled the sweetest heist: ${cookies} from ${target}!`, - `Mission complete. ${target} is ${cookies} poorer.`, - `You distracted ${target} with a dance and swiped ${cookies}.`, - `Clean getaway! ${target} won't notice the missing ${cookies} until it's too late.`, - `The Cookie Vault has been breached. ${cookies} taken from ${target}.`, - `You emerged from the shadows carrying ${cookies} from ${target}.` - ]; - - - return randomItem(responses); -} - - -/* - * STEAL FAILURE RESPONSES - */ - -function randomStealFailResponse(amount, target) { - const cookies = - `**${amount.toLocaleString()} Cookie${amount === 1 ? "" : "s"}**`; - - - const responses = [ - `You were caught red-handed by ${target} and lost ${cookies} as a penalty!`, - `Fail! ${target} caught you mid-bite — you dropped ${cookies}.`, - `Not your best moment... You lost ${cookies} to ${target} in the struggle.`, - `Stealth level: 0. You handed ${cookies} to ${target} as an apology.`, - `The Cookie jar was booby-trapped! You gave ${cookies} to ${target} while running away.`, - `Ouch. The guard dog barked, and now ${target} has your ${cookies}.`, - `You tripped over your own shoelace — ${target} picked up ${cookies}.`, - `Caught by the Cookie Police! ${cookies} confiscated and given to ${target}.`, - `Your pockets had holes... ${target} found the ${cookies} you dropped.` - ]; - - - return randomItem(responses); -} /* * STEAL HELPERS */ @@ -944,25 +1565,13 @@ function getStealChance( 0.50; - /* - * Wealthy targets are harder to rob. - * - * Logarithmic scaling prevents massive - * vaults from becoming completely immune. - */ - const targetPenalty = Math.log10( targetCookies + 1 - ) - * + ) * 0.06; - /* - * Compare attacker wealth to victim. - */ - const wealthRatio = attackerCookies / Math.max( @@ -971,39 +1580,23 @@ function getStealChance( ); - /* - * Catch-up mechanic. - * - * If the attacker has less than the target, - * they gain up to +18 percentage points. - */ - const povertyBonus = wealthRatio < 1 ? ( 1 - wealthRatio - ) - * + ) * 0.18 : 0; - /* - * Anti-bullying penalty. - * - * Rich attackers targeting poorer users - * become progressively less successful. - */ - const bullyPenalty = wealthRatio > 1 ? Math.min( Math.log10( wealthRatio + 1 - ) - * + ) * 0.12, 0.20 @@ -1012,31 +1605,24 @@ function getStealChance( : 0; - const chance = - BASE_CHANCE - - - targetPenalty - + - povertyBonus - - - bullyPenalty; - - - /* - * Always keep robbery possible, - * but never make it nearly guaranteed. - */ - return Math.max( MIN_CHANCE, Math.min( MAX_CHANCE, - chance + + BASE_CHANCE + - + targetPenalty + + + povertyBonus + - + bullyPenalty ) ); } + function getStealAmount( targetCookies ) { @@ -1048,19 +1634,16 @@ function getStealAmount( getStealPercentage(); - const calculatedAmount = - Math.floor( - targetCookies * - percentage - ); - - const amount = Math.max( 1, Math.min( - calculatedAmount, + Math.floor( + targetCookies * + percentage + ), + targetCookies, MAX_STEAL_AMOUNT ) @@ -1073,106 +1656,305 @@ function getStealAmount( }; } + function getStealPercentage() { const roll = Math.random(); - /* - * 50% chance: - * steal 1–3% - */ - - if (roll < 0.50) { + if (roll < 0.50) return randomFloat( 0.01, 0.03 ); - } - /* - * 30% chance: - * steal 3–5% - */ - - if (roll < 0.80) { + if (roll < 0.80) return randomFloat( 0.03, 0.05 ); - } - /* - * 15% chance: - * steal 5–8% - */ - - if (roll < 0.95) { + if (roll < 0.95) return randomFloat( 0.05, 0.08 ); - } - /* - * 5% jackpot: - * steal 8–10% - */ - return randomFloat( 0.08, 0.10 ); } -function randomFloat( - min, - max -) { - return ( - Math.random() * - (max - min) - + - min - ); -} /* - * GENERIC HELPERS + * SLOT HELPERS */ -function getUnixTime() { - return Math.floor( - Date.now() / 1000 +function rollSlotOutcome() { + const total = + SLOT_OUTCOMES.reduce( + ( + sum, + outcome + ) => + sum + + outcome.weight, + + 0 + ); + + + let roll = + Math.random() * + total; + + + 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 randomInteger(min, max) { - return Math.floor( - Math.random() * (max - min + 1) - ) + min; +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 + ); + + + grid[1] = [ + symbol, + symbol, + other + ]; + + return grid; + } + + + /* + * Guaranteed non-pair middle line. + */ + + 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 randomItem(items) { - return items[ - Math.floor( - Math.random() * items.length - ) - ]; +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 formatPercentage(value) { - return `${Math.round(value * 100)}%`; +function getSlotResultName( + outcome +) { + if ( + outcome.type === + "loss" + ) { + return "No match"; + } + + + 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 "Triple"; + } } -function getLeaderboardMedal(index) { +function getSecurityLabel( + vault +) { + if (vault < 1000) + return "LOW"; + + if (vault < 10000) + return "MODERATE"; + + if (vault < 50000) + return "HIGH"; + + if (vault < 250000) + return "VERY HIGH"; + + return "MAXIMUM"; +} + + +/* + * TEXT + */ + +function randomCollectResponse( + amount, + user +) { + return randomItem([ + `${user}, I made you **${amount} Cookies** 🍪`, + `${user}, here's **${amount} Cookies**. I ate the rest.`, + `${user}, your **${amount} Cookies** are fresh out of the oven.`, + `A wild baker appeared and handed ${user} **${amount} Cookies**.`, + `${user}, Abra insists these **${amount} Cookies** are totally legitimate.` + ]); +} + + +function randomStealSuccessResponse( + amount, + target +) { + return randomItem([ + `You stole **${amount.toLocaleString()} Cookies** from ${target}!`, + `Clean getaway. ${target} is **${amount.toLocaleString()} Cookies** poorer.`, + `The Cookie Vault was breached for **${amount.toLocaleString()} Cookies**.` + ]); +} + + +function randomStealFailResponse( + amount, + target +) { + return randomItem([ + `${target} caught you and took **${amount.toLocaleString()} Cookies**.`, + `The robbery failed. ${target} gained **${amount.toLocaleString()} Cookies**.`, + `Cookie Police confiscated **${amount.toLocaleString()} Cookies** and handed them to ${target}.` + ]); +} + + +function getLeaderboardMedal( + index +) { switch (index) { case 0: return "🥇"; @@ -1186,4 +1968,15 @@ function getLeaderboardMedal(index) { default: return `**${index + 1}.**`; } +} + + +function sleep(ms) { + return new Promise( + resolve => + setTimeout( + resolve, + ms + ) + ); } \ No newline at end of file diff --git a/handlers/cookieBoss.js b/handlers/cookieBoss.js new file mode 100644 index 0000000..bd24b6c --- /dev/null +++ b/handlers/cookieBoss.js @@ -0,0 +1,230 @@ +const { + getCollections, + getUnixTime +} = require("../utils/cookieEconomy"); + +const { + activateBoss, + failBoss, + cancelBossAndRefund +} = require("../utils/cookieBoss"); + + +/* + * How often we check persistent boss timers. + * + * Discord timestamps handle the visible countdown, + * so checking once per minute is plenty. + */ +const CHECK_INTERVAL = 60 * 1000; + + +/* + * Your index.js automatically loads every file + * inside /handlers and passes the Discord client: + * + * require(`./handlers/${handler}`)(client) + */ +module.exports = (client) => { + + /* + * Run shortly after startup. + * + * This catches boss events whose timers expired + * while ShaiBot was offline/restarting. + */ + setTimeout(() => { + processBossEvents(client); + }, 5000); + + + /* + * Persistent boss event watcher. + */ + setInterval(() => { + processBossEvents(client); + }, CHECK_INTERVAL); + + + console.log( + "[CookieBoss] Boss event watcher initialized" + ); +}; + + +/* + * Check all currently pending Cookie Boss events. + */ +async function processBossEvents(client) { + try { + const { + db + } = getCollections(); + + + const events = + db.collection( + "items_events" + ); + + + const now = + getUnixTime(); + + + /* + * ========================================== + * JOINING PHASE + * ========================================== + * + * Recruitment lasts for 30 minutes. + * + * If 5+ players joined: + * start the battle automatically. + * + * If fewer than 5 joined: + * cancel the boss and refund everyone. + */ + + const joiningEvents = + await events + .find({ + eventType: + "CookieBoss", + + state: + "Joining", + + joinUntil: { + $lte: now + } + }) + .toArray(); + + + for ( + const event + of joiningEvents + ) { + const participantCount = + Object.keys( + event.participants ?? {} + ).length; + + + console.log( + `[CookieBoss] Recruitment expired for ${event.eventId} ` + + `(${participantCount}/${event.minimumParticipants} players)` + ); + + + /* + * Enough players joined. + * + * Start the fight automatically. + */ + if ( + participantCount >= + event.minimumParticipants + ) { + const started = + await activateBoss( + client, + event.eventId + ); + + + if (started) { + console.log( + `[CookieBoss] Battle ${event.eventId} started automatically` + ); + } + + + continue; + } + + + /* + * Not enough players. + * + * Nobody actually entered combat, + * so refund all committed Cookies. + */ + await cancelBossAndRefund( + client, + event.eventId + ); + + + console.log( + `[CookieBoss] Battle ${event.eventId} cancelled and refunded` + ); + } + + + /* + * ========================================== + * ACTIVE BATTLES + * ========================================== + * + * If the battle timer expires while the + * boss still has HP remaining, the raid fails. + * + * All Cookies held in the encounter escrow + * are transferred to the ShaiBot vault. + */ + + const activeEvents = + await events + .find({ + eventType: + "CookieBoss", + + state: + "Active", + + battleEndsAt: { + $lte: now + }, + + "boss.health": { + $gt: 0 + } + }) + .toArray(); + + + for ( + const event + of activeEvents + ) { + console.log( + `[CookieBoss] Battle ${event.eventId} expired ` + + `with ${event.boss.health}/${event.boss.maxHealth} HP remaining` + ); + + + await failBoss( + client, + event.eventId + ); + + + console.log( + `[CookieBoss] Battle ${event.eventId} failed` + ); + } + + } catch (error) { + /* + * Never let an exception kill the interval. + * + * The next watcher tick will simply try again. + */ + console.error( + "[CookieBoss] Boss watcher error:", + error + ); + } +} \ No newline at end of file diff --git a/index.js b/index.js index 97893a1..84b7e8e 100644 --- a/index.js +++ b/index.js @@ -1,41 +1,141 @@ -const { Client, GatewayIntentBits, Collection, Partials } = require('discord.js') -const { MongoClient } = require('mongodb') -require('dotenv').config() -const mClient = new MongoClient(process.env.M_URI) -exports.mClient = mClient; -const fs = require('node:fs') +const { + Client, + GatewayIntentBits, + Collection, + Partials +} = require("discord.js"); -const client = new Client({ - intents: [ - GatewayIntentBits.Guilds, - GatewayIntentBits.GuildMembers, - GatewayIntentBits.GuildMessages, - GatewayIntentBits.GuildPresences, - GatewayIntentBits.GuildMessageReactions, - GatewayIntentBits.GuildVoiceStates, - GatewayIntentBits.DirectMessages, - GatewayIntentBits.MessageContent, - - ], - partials: [ - Partials.Channel, - Partials.Message, - Partials.User, - Partials.GuildMember, - Partials.Reaction - ] -}); -exports.client = client +const { + MongoClient +} = require("mongodb"); -client.commands = new Collection() // slash commands collection -client.legacyCommands = new Collection() // legacy commands collection -client.aliases = new Collection() // list of command aliases -client.buttons = new Collection() // list of buttons -client.selectMenus = new Collection() // list of selectMenus -client.modals = new Collection(); +const fs = require("node:fs"); -fs.readdirSync('./handlers').forEach((handler) => { - require(`./handlers/${handler}`)(client) -}); +require("dotenv").config(); -client.login(process.env.D_TOKEN) \ No newline at end of file + +/* + * MONGODB + */ + +const mClient = + new MongoClient( + process.env.M_URI + ); + +exports.mClient = + mClient; + + +/* + * DISCORD CLIENT + */ + +const client = + new Client({ + intents: [ + GatewayIntentBits.Guilds, + GatewayIntentBits.GuildMembers, + GatewayIntentBits.GuildMessages, + GatewayIntentBits.GuildPresences, + GatewayIntentBits.GuildMessageReactions, + GatewayIntentBits.GuildVoiceStates, + GatewayIntentBits.DirectMessages, + GatewayIntentBits.MessageContent + ], + + partials: [ + Partials.Channel, + Partials.Message, + Partials.User, + Partials.GuildMember, + Partials.Reaction + ] + }); + + +exports.client = + client; + + +/* + * COLLECTIONS + */ + +client.commands = + new Collection(); + +client.legacyCommands = + new Collection(); + +client.aliases = + new Collection(); + +client.buttons = + new Collection(); + +client.selectMenus = + new Collection(); + +client.modals = + new Collection(); + + +/* + * START APPLICATION + */ + +async function start() { + try { + + /* + * Connect Mongo first. + */ + + await mClient.connect(); + + console.log( + "MongoDB connected" + ); + + + /* + * Load handlers. + */ + + const handlerFiles = + fs.readdirSync( + "./handlers" + ); + + + for ( + const handler + of handlerFiles + ) { + require( + `./handlers/${handler}` + )(client); + } + + + /* + * Login Discord. + */ + + await client.login( + process.env.D_TOKEN + ); + + } catch (error) { + console.error( + "Failed to start ShaiBot:", + error + ); + + process.exit(1); + } +} + + +start(); \ No newline at end of file diff --git a/utils/cookieBoss.js b/utils/cookieBoss.js new file mode 100644 index 0000000..8c0dc6a --- /dev/null +++ b/utils/cookieBoss.js @@ -0,0 +1,1693 @@ +const { + EmbedBuilder, + ActionRowBuilder, + ButtonBuilder, + ButtonStyle +} = require("discord.js"); + +const { + getCollections, + getCookieBalance, + transferCookies, + temporaryEphemeralReply, + temporaryEphemeralFollowUp, + getUnixTime, + randomInteger +} = require("./cookieEconomy"); + + +const EVENT_COLLECTION = + "items_events"; + +const BATTLE_DURATION = + 60 * 60; + + +/* + * Buy-in determines temporary combat stats. + */ + +const ENTRY_TIERS = { + Scout: { + cost: 100, + health: 180, + power: 20 + }, + + Fighter: { + cost: 500, + health: 280, + power: 35 + }, + + Champion: { + cost: 1000, + health: 380, + power: 50 + } +}; + + +const ACTIONS = { + Strike: { + multiplier: + 1.0, + + energyCost: + 0, + + cooldown: + 10 + }, + + Burst: { + multiplier: + 2.25, + + energyCost: + 40, + + cooldown: + 30 + }, + + Limit: { + multiplier: + 4.0, + + energyCost: + 100, + + cooldown: + 90 + } +}; + + +async function joinBoss( + interaction, + tierName +) { + const event = + await getBossFromMessage( + interaction + ); + + + if (!event) { + return temporaryEphemeralReply( + interaction, + { + content: + "This boss event no longer exists." + } + ); + } + + + if ( + event.state !== + "Joining" + ) { + return temporaryEphemeralReply( + interaction, + { + content: + "The recruitment phase has already ended." + } + ); + } + + + if ( + event.joinUntil <= + getUnixTime() + ) { + return temporaryEphemeralReply( + interaction, + { + content: + "Recruitment has already expired." + } + ); + } + + + if ( + event.participants?.[ + interaction.user.id + ] + ) { + return temporaryEphemeralReply( + interaction, + { + content: + "You're already signed up for this fight." + } + ); + } + + + const tier = + ENTRY_TIERS[ + tierName + ]; + + + if (!tier) + return; + + + const balance = + await getCookieBalance( + interaction.user.id + ); + + + if ( + balance < + tier.cost + ) { + return temporaryEphemeralReply( + interaction, + { + content: + `You need **${tier.cost} Cookies** for the ${tierName} tier.` + } + ); + } + + + /* + * Entry fee goes to encounter escrow, + * not directly into the house vault. + */ + + await transferCookies( + interaction.user.id, + event.escrowId, + tier.cost + ); + + + const { + db + } = + getCollections(); + + + const events = + db.collection( + EVENT_COLLECTION + ); + + + const now = + getUnixTime(); + + + await events.updateOne( + { + eventId: + event.eventId, + + state: + "Joining", + + [`participants.${interaction.user.id}`]: + { + $exists: false + } + }, + { + $set: { + [`participants.${interaction.user.id}`]: + { + tier: + tierName, + + entry: + tier.cost, + + health: + tier.health, + + maxHealth: + tier.health, + + power: + tier.power, + + energy: + 100, + + maxEnergy: + 100, + + lastEnergyUpdate: + now, + + damage: + 0, + + knockedOut: + false, + + cooldowns: { + Strike: 0, + Burst: 0, + Limit: 0 + } + } + }, + + $inc: { + rewardPool: + tier.cost + } + } + ); + + + await refreshBossMessage( + interaction.client, + event.eventId + ); + + + return temporaryEphemeralReply( + interaction, + { + content: + `You joined as a **${tierName}** for **${tier.cost} Cookies**.` + }, + 6000 + ); +} + + +async function startBossBattle( + interaction +) { + const event = + await getBossFromMessage( + interaction + ); + + + if (!event) { + return temporaryEphemeralReply( + interaction, + { + content: + "This boss event no longer exists." + } + ); + } + + + if ( + interaction.user.id !== + event.creatorId + ) { + return temporaryEphemeralReply( + interaction, + { + content: + "Only the player who summoned the boss can start the battle early." + } + ); + } + + + const participantCount = + Object.keys( + event.participants ?? + {} + ).length; + + + if ( + participantCount < + event.minimumParticipants + ) { + return temporaryEphemeralReply( + interaction, + { + content: + `You need at least **${event.minimumParticipants} players**. Currently **${participantCount}/${event.minimumParticipants}**.` + } + ); + } + + + await interaction.deferUpdate(); + + + await activateBoss( + interaction.client, + event.eventId + ); +} + + +async function activateBoss( + client, + eventId +) { + const { + db + } = + getCollections(); + + + const events = + db.collection( + EVENT_COLLECTION + ); + + + const event = + await events.findOne({ + eventId + }); + + + if ( + !event || + event.state !== + "Joining" + ) { + return false; + } + + + const participantCount = + Object.keys( + event.participants ?? + {} + ).length; + + + if ( + participantCount < + event.minimumParticipants + ) { + return false; + } + + + const now = + getUnixTime(); + + + /* + * Scale boss HP by actual party size. + */ + + const maxHealth = + 15000 + + ( + participantCount * + 5000 + ); + + + const claimed = + await events.updateOne( + { + eventId, + state: + "Joining" + }, + { + $set: { + state: + "Active", + + battleStartedAt: + now, + + battleEndsAt: + now + + BATTLE_DURATION, + + "boss.health": + maxHealth, + + "boss.maxHealth": + maxHealth, + + "boss.rage": + 0 + } + } + ); + + + if ( + claimed.modifiedCount !== + 1 + ) { + return false; + } + + + await refreshBossMessage( + client, + eventId + ); + + + return true; +} + + +async function performBossAction( + interaction, + actionName +) { + const event = + await getBossFromMessage( + interaction + ); + + + if (!event) { + return temporaryEphemeralReply( + interaction, + { + content: + "This boss event no longer exists." + } + ); + } + + + if ( + event.state !== + "Active" + ) { + return temporaryEphemeralReply( + interaction, + { + content: + "The battle isn't active." + } + ); + } + + + const player = + event.participants?.[ + interaction.user.id + ]; + + + if (!player) { + return temporaryEphemeralReply( + interaction, + { + content: + "You didn't join this battle." + } + ); + } + + + if (player.knockedOut) { + return temporaryEphemeralReply( + interaction, + { + content: + "You're knocked out and can't attack." + } + ); + } + + + const action = + ACTIONS[ + actionName + ]; + + + if (!action) + return; + + + const now = + getUnixTime(); + + + /* + * Lazy energy regeneration: + * +10 Energy per 10 seconds. + */ + + const elapsed = + Math.max( + 0, + now - + player.lastEnergyUpdate + ); + + + const regenerated = + Math.floor( + elapsed / + 10 + ) * + 10; + + + const energy = + Math.min( + player.maxEnergy, + + player.energy + + regenerated + ); + + + const cooldown = + player.cooldowns?.[ + actionName + ] + ?? + 0; + + + if ( + cooldown > now + ) { + return temporaryEphemeralReply( + interaction, + { + content: + `**${actionName}** is ready .` + } + ); + } + + + if ( + energy < + action.energyCost + ) { + return temporaryEphemeralReply( + interaction, + { + content: + `You need **${action.energyCost} Energy** for ${actionName}. You currently have **${energy}**.` + } + ); + } + + + await interaction.deferUpdate(); + + + const variance = + randomInteger( + 90, + 110 + ) / + 100; + + + const damage = + Math.max( + 1, + + Math.floor( + player.power * + action.multiplier * + variance + ) + ); + + + const nextCooldown = + now + + action.cooldown; + + + /* + * 35% chance of boss retaliation. + */ + + const retaliates = + Math.random() < + 0.35; + + + const retaliationDamage = + retaliates + ? randomInteger( + 15, + 45 + ) + + : 0; + + + const newHealth = + Math.max( + 0, + + player.health - + retaliationDamage + ); + + + const knockedOut = + newHealth <= 0; + + + const { + db + } = + getCollections(); + + + const events = + db.collection( + EVENT_COLLECTION + ); + + + await events.updateOne( + { + eventId: + event.eventId, + + state: + "Active", + + "boss.health": { + $gt: 0 + } + }, + { + $inc: { + "boss.health": + -damage, + + [`participants.${interaction.user.id}.damage`]: + damage + }, + + $set: { + [`participants.${interaction.user.id}.energy`]: + energy - + action.energyCost, + + [`participants.${interaction.user.id}.lastEnergyUpdate`]: + now, + + [`participants.${interaction.user.id}.health`]: + newHealth, + + [`participants.${interaction.user.id}.knockedOut`]: + knockedOut, + + [`participants.${interaction.user.id}.cooldowns.${actionName}`]: + nextCooldown + } + } + ); + + + const updated = + await events.findOne({ + eventId: + event.eventId + }); + + + /* + * Clamp boss HP if concurrent attacks pushed it below zero. + */ + + if ( + updated.boss.health <= 0 + ) { + await events.updateOne( + { + eventId: + event.eventId + }, + { + $set: { + "boss.health": + 0 + } + } + ); + + + await resolveBossVictory( + interaction.client, + event.eventId + ); + + } else { + await refreshBossMessage( + interaction.client, + event.eventId + ); + } + + + const result = + new EmbedBuilder() + .setTitle( + `⚔️ ${actionName}` + ) + .addFields( + { + name: + "Damage", + + value: + `**${damage.toLocaleString()}**`, + + inline: + true + }, + + { + name: + "HP", + + value: + `**${newHealth}/${player.maxHealth}**`, + + inline: + true + }, + + { + name: + "Energy", + + value: + `**${energy - action.energyCost}/${player.maxEnergy}**`, + + inline: + true + } + ); + + + if (retaliates) { + result.setDescription( + `The Crumbbound Behemoth retaliated for **${retaliationDamage} damage**!` + ); + } + + + if (knockedOut) { + result.setDescription( + "💀 The Crumbbound Behemoth knocked you out!" + ); + } + + + return temporaryEphemeralFollowUp( + interaction, + { + embeds: [ + result + ] + }, + 9000 + ); +} + + +async function resolveBossVictory( + client, + eventId +) { + const { + db + } = + getCollections(); + + + const events = + db.collection( + EVENT_COLLECTION + ); + + + const event = + await events.findOneAndUpdate( + { + eventId, + + state: + "Active" + }, + { + $set: { + state: + "Resolving" + } + }, + { + returnDocument: + "after" + } + ); + + + if (!event) + return; + + + const participants = + Object.entries( + event.participants ?? + {} + ); + + + if (!participants.length) + return; + + + const rewardPool = + event.rewardPool; + + + const totalDamage = + participants.reduce( + ( + sum, + [ + , + player + ] + ) => + sum + + player.damage, + + 0 + ); + + + const evenPool = + Math.floor( + rewardPool * + 0.30 + ); + + + const damagePool = + rewardPool - + evenPool; + + + const evenShare = + Math.floor( + evenPool / + participants.length + ); + + + let distributed = + 0; + + + const rewards = + []; + + + for ( + const [ + userId, + player + ] + of participants + ) { + const damageShare = + totalDamage > 0 + ? Math.floor( + damagePool * + ( + player.damage / + totalDamage + ) + ) + + : 0; + + + const reward = + evenShare + + damageShare; + + + if (reward > 0) { + await transferCookies( + event.escrowId, + userId, + reward + ); + + distributed += + reward; + } + + + rewards.push({ + userId, + reward, + damage: + player.damage + }); + } + + + /* + * Rounding remainder goes to top damage. + */ + + const remainder = + rewardPool - + distributed; + + + if ( + remainder > 0 + ) { + const top = + rewards + .slice() + .sort( + ( + a, + b + ) => + b.damage - + a.damage + )[0]; + + + if (top) { + await transferCookies( + event.escrowId, + top.userId, + remainder + ); + + top.reward += + remainder; + } + } + + + await events.updateOne( + { + eventId + }, + { + $set: { + state: + "Defeated", + + defeatedAt: + getUnixTime(), + + rewards + } + } + ); + + + await refreshBossMessage( + client, + eventId + ); +} + + +async function failBoss( + client, + eventId +) { + const { + db + } = + getCollections(); + + + const events = + db.collection( + EVENT_COLLECTION + ); + + + const event = + await events.findOneAndUpdate( + { + eventId, + + state: + "Active" + }, + { + $set: { + state: + "Failed", + + failedAt: + getUnixTime() + } + }, + { + returnDocument: + "after" + } + ); + + + if (!event) + return; + + + const escrowBalance = + await getCookieBalance( + event.escrowId + ); + + + /* + * Failure: + * all committed Cookies become house Cookies. + */ + + if ( + escrowBalance > 0 + ) { + await transferCookies( + event.escrowId, + client.user.id, + escrowBalance + ); + } + + + await refreshBossMessage( + client, + eventId + ); +} + + +async function cancelBossAndRefund( + client, + eventId +) { + const { + db + } = + getCollections(); + + + const events = + db.collection( + EVENT_COLLECTION + ); + + + const event = + await events.findOneAndUpdate( + { + eventId, + + state: + "Joining" + }, + { + $set: { + state: + "Cancelled", + + cancelledAt: + getUnixTime() + } + }, + { + returnDocument: + "after" + } + ); + + + if (!event) + return; + + + /* + * Refund all participant entries. + */ + + for ( + const [ + userId, + player + ] + of Object.entries( + event.participants ?? + {} + ) + ) { + if ( + player.entry > + 0 + ) { + await transferCookies( + event.escrowId, + userId, + player.entry + ); + } + } + + + /* + * Refund the original 500 spawn cost. + */ + + const remaining = + await getCookieBalance( + event.escrowId + ); + + + if ( + remaining > 0 + ) { + await transferCookies( + event.escrowId, + event.creatorId, + remaining + ); + } + + + await refreshBossMessage( + client, + eventId + ); +} + + +async function refreshBossMessage( + client, + eventId +) { + const { + db + } = + getCollections(); + + + const event = + await db + .collection( + EVENT_COLLECTION + ) + .findOne({ + eventId + }); + + + if (!event) + return; + + + let channel; + + + try { + channel = + await client.channels.fetch( + event.channelId + ); + + } catch { + return; + } + + + if (!channel) + return; + + + let message; + + + try { + message = + await channel.messages.fetch( + event.messageId + ); + + } catch { + return; + } + + + const { + embed, + components + } = + buildBossMessage( + event + ); + + + await message.edit({ + embeds: [ + embed + ], + + components + }); +} + + +function buildBossMessage( + event +) { + const participantCount = + Object.keys( + event.participants ?? + {} + ).length; + + + if ( + event.state === + "Joining" + ) { + const embed = + new EmbedBuilder() + .setTitle( + "🍪 The Crumbbound Behemoth" + ) + .setDescription( + [ + "*A creature of hunger, crumbs, and questionable dietary choices.*", + "", + `**Recruitment ends:** `, + `**Participants:** ${participantCount}/${event.minimumParticipants} required`, + `**Reward Pool:** ${event.rewardPool.toLocaleString()} 🍪`, + "", + participantCount >= + event.minimumParticipants + ? "✅ The summoner may start the battle early." + : "Recruit more adventurers before the fight can begin." + ].join("\n") + ) + .setFooter({ + text: + `cookieBoss:${event.eventId}` + }); + + + return { + embed, + + components: + buildJoinButtons() + }; + } + + + if ( + event.state === + "Active" + ) { + const hp = + Math.max( + 0, + event.boss.health + ); + + + const hpBar = + buildHpBar( + hp, + event.boss.maxHealth + ); + + + const embed = + new EmbedBuilder() + .setTitle( + "⚔️ The Crumbbound Behemoth" + ) + .setDescription( + [ + hpBar, + `**${hp.toLocaleString()} / ${event.boss.maxHealth.toLocaleString()} HP**`, + "", + `**Participants:** ${participantCount}`, + `**Reward Pool:** ${event.rewardPool.toLocaleString()} 🍪`, + `**Battle ends:** ` + ].join("\n") + ) + .setFooter({ + text: + `cookieBoss:${event.eventId}` + }); + + + return { + embed, + + components: + buildCombatButtons() + }; + } + + + if ( + event.state === + "Defeated" + ) { + const rewards = + ( + event.rewards ?? + [] + ) + .sort( + ( + a, + b + ) => + b.damage - + a.damage + ) + .slice( + 0, + 10 + ) + .map( + ( + player, + index + ) => + `**${index + 1}.** <@${player.userId}> — ` + + `${player.damage.toLocaleString()} dmg — ` + + `**${player.reward.toLocaleString()} 🍪**` + ) + .join("\n"); + + + return { + embed: + new EmbedBuilder() + .setTitle( + "🏆 The Crumbbound Behemoth Has Fallen!" + ) + .setDescription( + rewards || + "The beast has been defeated." + ) + .setFooter({ + text: + `cookieBoss:${event.eventId}` + }), + + components: + [] + }; + } + + + if ( + event.state === + "Failed" + ) { + return { + embed: + new EmbedBuilder() + .setTitle( + "💀 The Crumbbound Behemoth Survived" + ) + .setDescription( + [ + "The battle timer expired.", + "", + `**${event.rewardPool.toLocaleString()} Cookies** were lost to the ShaiBot Vault.` + ].join("\n") + ) + .setFooter({ + text: + `cookieBoss:${event.eventId}` + }), + + components: + [] + }; + } + + + return { + embed: + new EmbedBuilder() + .setTitle( + "🍪 Boss Hunt Cancelled" + ) + .setDescription( + "Not enough adventurers joined. All committed Cookies were refunded." + ) + .setFooter({ + text: + `cookieBoss:${event.eventId}` + }), + + components: + [] + }; +} + + +function buildJoinButtons() { + return [ + new ActionRowBuilder() + .addComponents( + new ButtonBuilder() + .setCustomId( + "cookieBossJoinScout" + ) + .setLabel( + "Scout — 100" + ) + .setEmoji( + "🍪" + ) + .setStyle( + ButtonStyle.Secondary + ), + + new ButtonBuilder() + .setCustomId( + "cookieBossJoinFighter" + ) + .setLabel( + "Fighter — 500" + ) + .setEmoji( + "⚔️" + ) + .setStyle( + ButtonStyle.Primary + ), + + new ButtonBuilder() + .setCustomId( + "cookieBossJoinChampion" + ) + .setLabel( + "Champion — 1000" + ) + .setEmoji( + "🔥" + ) + .setStyle( + ButtonStyle.Danger + ) + ), + + new ActionRowBuilder() + .addComponents( + new ButtonBuilder() + .setCustomId( + "cookieBossStart" + ) + .setLabel( + "Start Battle" + ) + .setEmoji( + "⚔️" + ) + .setStyle( + ButtonStyle.Success + ) + ) + ]; +} + + +function buildCombatButtons() { + return [ + new ActionRowBuilder() + .addComponents( + new ButtonBuilder() + .setCustomId( + "cookieBossStrike" + ) + .setLabel( + "Strike" + ) + .setEmoji( + "⚔️" + ) + .setStyle( + ButtonStyle.Primary + ), + + new ButtonBuilder() + .setCustomId( + "cookieBossBurst" + ) + .setLabel( + "Burst" + ) + .setEmoji( + "💥" + ) + .setStyle( + ButtonStyle.Danger + ), + + new ButtonBuilder() + .setCustomId( + "cookieBossLimit" + ) + .setLabel( + "Limit" + ) + .setEmoji( + "✨" + ) + .setStyle( + ButtonStyle.Success + ) + ) + ]; +} + + +async function getBossFromMessage( + interaction +) { + const footer = + interaction.message + .embeds[0] + ?.footer + ?.text; + + + const [ + type, + eventId + ] = + footer + ?.split(":") + ?? + []; + + + if ( + type !== + "cookieBoss" || + !eventId + ) { + return null; + } + + + const { + db + } = + getCollections(); + + + return db + .collection( + EVENT_COLLECTION + ) + .findOne({ + eventId + }); +} + + +function buildHpBar( + current, + max, + size = 20 +) { + const ratio = + Math.max( + 0, + + Math.min( + 1, + current / + max + ) + ); + + + const filled = + Math.round( + ratio * + size + ); + + + return ( + "█".repeat( + filled + ) + + + "░".repeat( + size - + filled + ) + ); +} + + +module.exports = { + joinBoss, + startBossBattle, + activateBoss, + performBossAction, + + resolveBossVictory, + failBoss, + cancelBossAndRefund, + + refreshBossMessage +}; \ No newline at end of file diff --git a/utils/cookieEconomy.js b/utils/cookieEconomy.js new file mode 100644 index 0000000..f7e144d --- /dev/null +++ b/utils/cookieEconomy.js @@ -0,0 +1,423 @@ +const { + MessageFlags +} = require("discord.js"); + +const { mClient } = require("../index"); + + +const DATABASE = "neo-db"; + +const COOKIE_COLLECTION = "items_cookies"; +const COOLDOWN_COLLECTION = "cooldowns_cookies"; + + +function getCollections() { + const db = mClient.db(DATABASE); + + return { + db, + cookies: db.collection(COOKIE_COLLECTION), + cooldowns: db.collection(COOLDOWN_COLLECTION) + }; +} + + +async function getCookieBalance(userId) { + const { cookies } = getCollections(); + + const result = await cookies.findOne( + { + userId + }, + { + projection: { + _id: 0, + cookies: 1 + } + } + ); + + return result?.cookies ?? 0; +} + + +async function changeCookies( + userId, + amount +) { + const { cookies } = getCollections(); + + return cookies.updateOne( + { + userId + }, + { + $inc: { + cookies: amount + }, + + $setOnInsert: { + userId + } + }, + { + upsert: true + } + ); +} + + +async function transferCookies( + fromUserId, + toUserId, + amount +) { + if ( + !Number.isInteger(amount) || + amount <= 0 + ) { + throw new Error( + "INVALID_COOKIE_AMOUNT" + ); + } + + + const { cookies } = getCollections(); + + + const debit = + await cookies.updateOne( + { + userId: fromUserId, + + cookies: { + $gte: amount + } + }, + { + $inc: { + cookies: -amount + } + } + ); + + + if ( + debit.modifiedCount !== 1 + ) { + throw new Error( + "INSUFFICIENT_COOKIES" + ); + } + + + try { + await cookies.updateOne( + { + userId: toUserId + }, + { + $inc: { + cookies: amount + }, + + $setOnInsert: { + userId: toUserId + } + }, + { + upsert: true + } + ); + + } catch (error) { + try { + await cookies.updateOne( + { + userId: fromUserId + }, + { + $inc: { + cookies: amount + } + } + ); + + } catch (rollbackError) { + console.error( + "CRITICAL: Cookie rollback failed:", + rollbackError + ); + } + + + throw error; + } +} + + +async function getCooldown( + userId, + guildId, + type +) { + const { cooldowns } = getCollections(); + + const result = + await cooldowns.findOne( + { + userId, + guildId + }, + { + projection: { + _id: 0, + [type]: 1 + } + } + ); + + return result?.[type] ?? 0; +} + + +async function setCooldown( + userId, + guildId, + type, + duration +) { + const { cooldowns } = getCollections(); + + const now = getUnixTime(); + const next = now + duration; + + + await cooldowns.updateOne( + { + userId, + guildId + }, + { + $set: { + [type]: next + }, + + $setOnInsert: { + userId, + guildId + } + }, + { + upsert: true + } + ); + + + return next; +} + + +/* + * Atomically claim a cooldown. + * + * Useful for button games where a double-click + * could otherwise resolve twice. + */ + +async function claimCooldown( + userId, + guildId, + type, + duration +) { + const { cooldowns } = getCollections(); + + const now = getUnixTime(); + const next = now + duration; + + + try { + const result = + await cooldowns.updateOne( + { + userId, + guildId, + + $or: [ + { + [type]: { + $lte: now + } + }, + { + [type]: { + $exists: false + } + } + ] + }, + { + $set: { + [type]: next + }, + + $setOnInsert: { + userId, + guildId + } + }, + { + upsert: true + } + ); + + + return { + claimed: + result.modifiedCount === 1 || + result.upsertedCount === 1, + + next + }; + + } catch (error) { + /* + * A unique {userId, guildId} index may cause + * a duplicate-key error if an active cooldown + * already exists. + */ + if (error?.code === 11000) { + const active = + await getCooldown( + userId, + guildId, + type + ); + + return { + claimed: false, + next: active + }; + } + + throw error; + } +} + + +async function temporaryEphemeralReply( + interaction, + options, + deleteAfter = 5000 +) { + await interaction.reply({ + ...options, + flags: MessageFlags.Ephemeral + }); + + + setTimeout( + async () => { + try { + await interaction.deleteReply(); + } catch {} + }, + deleteAfter + ); +} + + +async function temporaryEphemeralFollowUp( + interaction, + options, + deleteAfter = 8000 +) { + const message = + await interaction.followUp({ + ...options, + flags: MessageFlags.Ephemeral + }); + + + setTimeout( + async () => { + try { + await message.delete(); + } catch {} + }, + deleteAfter + ); + + + return message; +} + + +function getUnixTime() { + return Math.floor( + Date.now() / 1000 + ); +} + + +function randomInteger( + min, + max +) { + return Math.floor( + Math.random() * + (max - min + 1) + ) + min; +} + + +function randomFloat( + min, + max +) { + return ( + Math.random() * + (max - min) + ) + min; +} + + +function randomItem(items) { + return items[ + Math.floor( + Math.random() * + items.length + ) + ]; +} + + +function formatPercentage(value) { + return `${Math.round(value * 100)}%`; +} + + +module.exports = { + DATABASE, + COOKIE_COLLECTION, + COOLDOWN_COLLECTION, + + getCollections, + getCookieBalance, + changeCookies, + transferCookies, + + getCooldown, + setCooldown, + claimCooldown, + + temporaryEphemeralReply, + temporaryEphemeralFollowUp, + + getUnixTime, + randomInteger, + randomFloat, + randomItem, + formatPercentage +}; \ No newline at end of file diff --git a/utils/cookieHeist.js b/utils/cookieHeist.js new file mode 100644 index 0000000..3539005 --- /dev/null +++ b/utils/cookieHeist.js @@ -0,0 +1,836 @@ +const { + EmbedBuilder +} = require("discord.js"); + +const { + getCookieBalance, + transferCookies, + claimCooldown, + temporaryEphemeralReply, + randomFloat, + formatPercentage +} = require("./cookieEconomy"); + + +/* + * ============================================================ + * CONFIG + * ============================================================ + */ + +const HEIST_COOLDOWN = + 60 * 60; // 1 hour + + +/* + * Each approach has a different risk/reward profile. + * + * Sneak: + * High success chance + * Small reward + * Small failure penalty + * + * Hack: + * Medium success chance + * Medium reward + * Medium failure penalty + * + * Smash: + * Low success chance + * Large reward + * Large failure penalty + */ + +const HEIST_METHODS = { + + Sneak: { + baseChance: + 0.65, + + minStealPercent: + 0.01, + + maxStealPercent: + 0.03, + + maxStealAmount: + 1500, + + minPenaltyPercent: + 0.01, + + maxPenaltyPercent: + 0.04 + }, + + + Hack: { + baseChance: + 0.45, + + minStealPercent: + 0.03, + + maxStealPercent: + 0.06, + + maxStealAmount: + 3500, + + minPenaltyPercent: + 0.03, + + maxPenaltyPercent: + 0.07 + }, + + + Smash: { + baseChance: + 0.25, + + minStealPercent: + 0.06, + + maxStealPercent: + 0.12, + + maxStealAmount: + 7500, + + minPenaltyPercent: + 0.06, + + maxPenaltyPercent: + 0.12 + } +}; + + +/* + * ============================================================ + * RESOLVE HEIST + * ============================================================ + */ + +async function resolveCookieHeist( + interaction, + methodName +) { + + /* + * The original /cookies heist command stores + * the player ID inside the embed footer: + * + * cookieHeist:123456789 + * + * This prevents somebody else from pressing + * another player's heist buttons. + */ + + const footer = + interaction.message + .embeds[0] + ?.footer + ?.text; + + + const [ + type, + ownerId + ] = + footer + ?.split(":") + ?? + []; + + + if ( + type !== "cookieHeist" || + ownerId !== interaction.user.id + ) { + return temporaryEphemeralReply( + interaction, + { + content: + "This isn't your heist. Start your own with `/cookies heist`." + } + ); + } + + + /* + * Validate selected method. + */ + + const method = + HEIST_METHODS[ + methodName + ]; + + + if (!method) { + return temporaryEphemeralReply( + interaction, + { + content: + "That heist approach doesn't exist." + } + ); + } + + + /* + * ======================================================== + * CLAIM COOLDOWN + * ======================================================== + * + * This is atomic so rapid double-clicks can't + * resolve the same heist multiple times. + */ + + const cooldown = + await claimCooldown( + interaction.user.id, + interaction.guildId, + "heist", + HEIST_COOLDOWN + ); + + + if (!cooldown.claimed) { + return temporaryEphemeralReply( + interaction, + { + content: + `You can attempt another vault heist .` + } + ); + } + + + /* + * ======================================================== + * LOCK THE HEIST MESSAGE + * ======================================================== + * + * Remove all buttons immediately. + */ + + await interaction.update({ + embeds: [ + new EmbedBuilder() + .setTitle( + "🏦 Vault Heist in Progress..." + ) + .setDescription( + getHeistProgressText( + methodName + ) + ) + ], + + components: [] + }); + + + /* + * ======================================================== + * GET BALANCES + * ======================================================== + */ + + const playerId = + interaction.user.id; + + const vaultId = + interaction.client.user.id; + + + const [ + playerCookies, + vaultCookies + ] = + await Promise.all([ + + getCookieBalance( + playerId + ), + + getCookieBalance( + vaultId + ) + + ]); + + + /* + * Something may have changed between opening + * the heist menu and pressing the button. + */ + + if ( + playerCookies < 1 || + vaultCookies < 1 + ) { + return interaction.editReply({ + embeds: [ + new EmbedBuilder() + .setTitle( + "🏦 Heist Aborted" + ) + .setDescription( + "There weren't enough Cookies available to resolve the heist." + ) + ], + + components: [] + }); + } + + + /* + * Small dramatic pause. + * + * Only one edit happens after this, so this is + * comfortably within Discord rate limits. + */ + + await sleep( + 1200 + ); + + + /* + * ======================================================== + * VAULT DIFFICULTY + * ======================================================== + * + * The richer ShaiBot's vault becomes, + * the harder it is to rob. + * + * Penalty is capped at 15 percentage points. + */ + + const securityPenalty = + getVaultSecurityPenalty( + vaultCookies + ); + + + const successChance = + Math.max( + 0.10, + + method.baseChance - + securityPenalty + ); + + + const success = + Math.random() < + successChance; + + + /* + * ======================================================== + * SUCCESS + * ======================================================== + */ + + if (success) { + + /* + * Roll percentage of the current vault. + */ + + const percent = + randomFloat( + method.minStealPercent, + method.maxStealPercent + ); + + + /* + * Apply both: + * + * percentage roll + * AND + * hard maximum + * + * This prevents a massive vault from producing + * absurd payouts. + */ + + const amount = + Math.max( + 1, + + Math.min( + + Math.floor( + vaultCookies * + percent + ), + + method.maxStealAmount, + + vaultCookies + ) + ); + + + /* + * ShaiBot pays the robber. + */ + + await transferCookies( + vaultId, + playerId, + amount + ); + + + const remaining = + await getCookieBalance( + vaultId + ); + + + const actualPercentage = + ( + amount / + vaultCookies + ) * + 100; + + + const embed = + new EmbedBuilder() + .setTitle( + "💥 Vault Breached!" + ) + .setDescription( + getSuccessText( + methodName, + interaction.user, + amount + ) + ) + .addFields( + + { + name: + "Approach", + + value: + methodName, + + inline: + true + }, + + { + name: + "Success Chance", + + value: + `**${formatPercentage(successChance)}**`, + + inline: + true + }, + + { + name: + "Vault Taken", + + value: + `**${actualPercentage.toFixed(2)}%**`, + + inline: + true + }, + + { + name: + "Cookies Stolen", + + value: + `**${amount.toLocaleString()} 🍪**`, + + inline: + true + }, + + { + name: + "Vault Remaining", + + value: + `**${remaining.toLocaleString()} 🍪**`, + + inline: + true + }, + + { + name: + "Next Heist", + + value: + ``, + + inline: + true + } + + ); + + + return interaction.editReply({ + embeds: [ + embed + ], + + components: [] + }); + } + + + /* + * ======================================================== + * FAILURE + * ======================================================== + * + * Failure fines the player. + * + * Unlike normal player stealing, the fine goes + * directly into ShaiBot's vault. + */ + + const penaltyPercent = + randomFloat( + method.minPenaltyPercent, + method.maxPenaltyPercent + ); + + + const penalty = + Math.max( + 1, + + Math.min( + + Math.floor( + playerCookies * + penaltyPercent + ), + + playerCookies + ) + ); + + + await transferCookies( + playerId, + vaultId, + penalty + ); + + + const [ + playerAfter, + vaultAfter + ] = + await Promise.all([ + + getCookieBalance( + playerId + ), + + getCookieBalance( + vaultId + ) + + ]); + + + const embed = + new EmbedBuilder() + .setTitle( + "🚨 HEIST FAILED" + ) + .setDescription( + getFailureText( + methodName, + interaction.user, + penalty + ) + ) + .addFields( + + { + name: + "Approach", + + value: + methodName, + + inline: + true + }, + + { + name: + "Success Chance", + + value: + `**${formatPercentage(successChance)}**`, + + inline: + true + }, + + { + name: + "Fine", + + value: + `**${penalty.toLocaleString()} 🍪**`, + + inline: + true + }, + + { + name: + "Your Vault", + + value: + `**${playerAfter.toLocaleString()} 🍪**`, + + inline: + true + }, + + { + name: + "House Vault", + + value: + `**${vaultAfter.toLocaleString()} 🍪**`, + + inline: + true + }, + + { + name: + "Next Heist", + + value: + ``, + + inline: + true + } + + ); + + + return interaction.editReply({ + embeds: [ + embed + ], + + components: [] + }); +} + + +/* + * ============================================================ + * VAULT SECURITY + * ============================================================ + */ + +function getVaultSecurityPenalty( + vaultCookies +) { + + /* + * Logarithmic scaling means the vault gets harder + * as it becomes richer without becoming impossible. + * + * Examples approximately: + * + * 100 -> 5% + * 1,000 -> 7.5% + * 10,000 -> 10% + * 100,000 -> 12.5% + * 1,000,000 -> 15% + */ + + return Math.min( + + Math.log10( + vaultCookies + 1 + ) * + 0.025, + + 0.15 + ); +} + + +/* + * ============================================================ + * FLAVOR TEXT + * ============================================================ + */ + +function getHeistProgressText( + methodName +) { + + switch ( + methodName + ) { + + case "Sneak": + return [ + "🥷 You slip through the service entrance...", + "", + "The Cookie Vault guards haven't noticed you yet." + ].join("\n"); + + + case "Hack": + return [ + "💻 You connect to the vault terminal...", + "", + "Several extremely suspicious progress bars appear." + ].join("\n"); + + + case "Smash": + return [ + "💥 You charge directly at the vault...", + "", + "Subtlety has been abandoned." + ].join("\n"); + + + default: + return "The heist is underway..."; + } +} + + +function getSuccessText( + methodName, + user, + amount +) { + + const amountText = + `**${amount.toLocaleString()} Cookies**`; + + + switch ( + methodName + ) { + + case "Sneak": + return ( + `🥷 ${user} slipped through security and quietly escaped with ${amountText}.` + ); + + + case "Hack": + return ( + `💻 ${user} convinced the vault terminal that ${amountText} belonged to them.` + ); + + + case "Smash": + return ( + `💥 ${user} smashed through the Cookie Vault and escaped with ${amountText}!` + ); + + + default: + return ( + `${user} escaped with ${amountText}.` + ); + } +} + + +function getFailureText( + methodName, + user, + penalty +) { + + const penaltyText = + `**${penalty.toLocaleString()} Cookies**`; + + + switch ( + methodName + ) { + + case "Sneak": + return [ + `🚨 ${user} stepped directly on the world's loudest Cookie wrapper.`, + "", + `The ShaiBot Vault collected a ${penaltyText} fine.` + ].join("\n"); + + + case "Hack": + return [ + `🚨 ${user} entered the password incorrectly one too many times.`, + "", + `The ShaiBot Vault collected a ${penaltyText} security fee.` + ].join("\n"); + + + case "Smash": + return [ + `🚨 ${user} discovered that the vault door is significantly stronger than expected.`, + "", + `Repairs cost ${penaltyText}. Naturally, you're paying.` + ].join("\n"); + + + default: + return ( + `The heist failed and cost ${user} ${penaltyText}.` + ); + } +} + + +/* + * ============================================================ + * UTIL + * ============================================================ + */ + +function sleep(ms) { + return new Promise( + resolve => + setTimeout( + resolve, + ms + ) + ); +} + + +module.exports = { + resolveCookieHeist +}; \ No newline at end of file