From 0f48f09bd9389f81078fdca544c8f6c16ba81b85 Mon Sep 17 00:00:00 2001 From: DeSqBlocki Date: Tue, 18 Aug 2026 20:07:41 +0200 Subject: [PATCH] refactored and moved --- buttons/cookies/bossBurst.js | 18 - buttons/cookies/bossJoinChampion.js | 18 - buttons/cookies/bossJoinFighter.js | 18 - buttons/cookies/bossJoinScout.js | 18 - buttons/cookies/bossLimit.js | 18 - buttons/cookies/bossStart.js | 17 - buttons/cookies/bossStrike.js | 18 - buttons/crumbSaucer/heist.js | 99 +- buttons/{cookies => crumbSaucer}/heistHack.js | 0 .../{cookies => crumbSaucer}/heistSmash.js | 0 .../{cookies => crumbSaucer}/heistSneak.js | 0 buttons/crumbleStrike/jobAstrologian.js | 19 + buttons/crumbleStrike/jobBard.js | 19 + buttons/crumbleStrike/jobBlackMage.js | 19 + buttons/crumbleStrike/jobDarkKnight.js | 19 + buttons/crumbleStrike/jobDragoon.js | 19 + buttons/crumbleStrike/jobPaladin.js | 19 + buttons/crumbleStrike/jobScholar.js | 19 + buttons/crumbleStrike/jobWarrior.js | 19 + buttons/crumbleStrike/jobWhiteMage.js | 19 + buttons/crumbleStrike/roleDPS.js | 18 + buttons/crumbleStrike/roleHealer.js | 18 + buttons/crumbleStrike/roleTank.js | 18 + buttons/crumbleStrike/start.js | 17 + buttons/crumbleStrike/tierAdventurer.js | 18 + buttons/crumbleStrike/tierSprout.js | 18 + buttons/crumbleStrike/tierWarriorOfLight.js | 18 + commands/applications/cookies.js | 748 +------ commands/applications/crumbleStrike.js | 417 ++++ handlers/cookieBoss.js | 230 -- handlers/crumbleStrike.js | 200 ++ utils/cookieBoss.js | 1693 --------------- utils/cookieHeist.js | 459 ++-- utils/crumbleStrike.js | 1894 +++++++++++++++++ 34 files changed, 3218 insertions(+), 2943 deletions(-) delete mode 100644 buttons/cookies/bossBurst.js delete mode 100644 buttons/cookies/bossJoinChampion.js delete mode 100644 buttons/cookies/bossJoinFighter.js delete mode 100644 buttons/cookies/bossJoinScout.js delete mode 100644 buttons/cookies/bossLimit.js delete mode 100644 buttons/cookies/bossStart.js delete mode 100644 buttons/cookies/bossStrike.js rename buttons/{cookies => crumbSaucer}/heistHack.js (100%) rename buttons/{cookies => crumbSaucer}/heistSmash.js (100%) rename buttons/{cookies => crumbSaucer}/heistSneak.js (100%) create mode 100644 buttons/crumbleStrike/jobAstrologian.js create mode 100644 buttons/crumbleStrike/jobBard.js create mode 100644 buttons/crumbleStrike/jobBlackMage.js create mode 100644 buttons/crumbleStrike/jobDarkKnight.js create mode 100644 buttons/crumbleStrike/jobDragoon.js create mode 100644 buttons/crumbleStrike/jobPaladin.js create mode 100644 buttons/crumbleStrike/jobScholar.js create mode 100644 buttons/crumbleStrike/jobWarrior.js create mode 100644 buttons/crumbleStrike/jobWhiteMage.js create mode 100644 buttons/crumbleStrike/roleDPS.js create mode 100644 buttons/crumbleStrike/roleHealer.js create mode 100644 buttons/crumbleStrike/roleTank.js create mode 100644 buttons/crumbleStrike/start.js create mode 100644 buttons/crumbleStrike/tierAdventurer.js create mode 100644 buttons/crumbleStrike/tierSprout.js create mode 100644 buttons/crumbleStrike/tierWarriorOfLight.js create mode 100644 commands/applications/crumbleStrike.js delete mode 100644 handlers/cookieBoss.js create mode 100644 handlers/crumbleStrike.js delete mode 100644 utils/cookieBoss.js create mode 100644 utils/crumbleStrike.js diff --git a/buttons/cookies/bossBurst.js b/buttons/cookies/bossBurst.js deleted file mode 100644 index 9f088ef..0000000 --- a/buttons/cookies/bossBurst.js +++ /dev/null @@ -1,18 +0,0 @@ -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 deleted file mode 100644 index 3e96499..0000000 --- a/buttons/cookies/bossJoinChampion.js +++ /dev/null @@ -1,18 +0,0 @@ -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 deleted file mode 100644 index 6fbf3d7..0000000 --- a/buttons/cookies/bossJoinFighter.js +++ /dev/null @@ -1,18 +0,0 @@ -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 deleted file mode 100644 index fe968d6..0000000 --- a/buttons/cookies/bossJoinScout.js +++ /dev/null @@ -1,18 +0,0 @@ -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 deleted file mode 100644 index 2fdd8eb..0000000 --- a/buttons/cookies/bossLimit.js +++ /dev/null @@ -1,18 +0,0 @@ -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 deleted file mode 100644 index 950c3fd..0000000 --- a/buttons/cookies/bossStart.js +++ /dev/null @@ -1,17 +0,0 @@ -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 deleted file mode 100644 index 171f016..0000000 --- a/buttons/cookies/bossStrike.js +++ /dev/null @@ -1,18 +0,0 @@ -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/crumbSaucer/heist.js b/buttons/crumbSaucer/heist.js index 613ca4d..f8e239f 100644 --- a/buttons/crumbSaucer/heist.js +++ b/buttons/crumbSaucer/heist.js @@ -7,11 +7,19 @@ const { const { getCookieBalance, + getCooldown, + getUnixTime, temporaryEphemeralReply } = require( "../../utils/cookieEconomy" ); +const { + HEIST_COOLDOWN +} = require( + "../../utils/cookieHeist" +); + module.exports = { name: @@ -19,6 +27,10 @@ module.exports = { async execute(interaction) { + /* + * Verify ownership of the Crumb Saucer menu. + */ + const footer = interaction.message .embeds[0] @@ -30,17 +42,13 @@ module.exports = { type, ownerId ] = - footer - ?.split(":") - ?? - []; + footer?.split(":") + ?? []; if ( - type !== - "crumbSaucer" || - ownerId !== - interaction.user.id + type !== "crumbSaucer" || + ownerId !== interaction.user.id ) { return temporaryEphemeralReply( interaction, @@ -52,6 +60,43 @@ module.exports = { } + /* + * ==================================================== + * SHARED HEIST COOLDOWN PRE-CHECK + * ==================================================== + */ + + const now = + getUnixTime(); + + + const cooldown = + await getCooldown( + interaction.user.id, + interaction.guildId, + "heist" + ); + + + if ( + cooldown > + now + ) { + return temporaryEphemeralReply( + interaction, + { + content: + `🏦 The ShaiBot Vault is still on alert.\nYou can attempt another heist .` + }, + 6000 + ); + } + + + /* + * Balances + */ + const vaultId = interaction.client.user.id; @@ -72,7 +117,8 @@ module.exports = { if ( - player < 1 + player < + 1 ) { return temporaryEphemeralReply( interaction, @@ -85,7 +131,8 @@ module.exports = { if ( - vault < 1 + vault < + 1 ) { return temporaryEphemeralReply( interaction, @@ -97,11 +144,16 @@ module.exports = { } + /* + * Heist menu + */ + const embed = new EmbedBuilder() .setTitle( "🏦 ShaiBot Cookie Vault" ) + .setDescription( [ `**Vault Balance:** ${vault.toLocaleString()} 🍪`, @@ -119,6 +171,7 @@ module.exports = { "Low chance • Large haul • Large fine" ].join("\n") ) + .setFooter({ text: `cookieHeist:${interaction.user.id}` @@ -172,19 +225,43 @@ module.exports = { ); + const backButton = + new ActionRowBuilder() + .addComponents( + new ButtonBuilder() + .setCustomId( + "crumbSaucerBack" + ) + .setLabel( + "Back to Crumb Saucer" + ) + .setEmoji( + "◀️" + ) + .setStyle( + ButtonStyle.Secondary + ) + ); + + return interaction.update({ embeds: [ embed ], components: [ - heistButtons + heistButtons, + backButton ] }); } }; +/* + * SECURITY LABEL + */ + function getSecurityLabel( vault ) { diff --git a/buttons/cookies/heistHack.js b/buttons/crumbSaucer/heistHack.js similarity index 100% rename from buttons/cookies/heistHack.js rename to buttons/crumbSaucer/heistHack.js diff --git a/buttons/cookies/heistSmash.js b/buttons/crumbSaucer/heistSmash.js similarity index 100% rename from buttons/cookies/heistSmash.js rename to buttons/crumbSaucer/heistSmash.js diff --git a/buttons/cookies/heistSneak.js b/buttons/crumbSaucer/heistSneak.js similarity index 100% rename from buttons/cookies/heistSneak.js rename to buttons/crumbSaucer/heistSneak.js diff --git a/buttons/crumbleStrike/jobAstrologian.js b/buttons/crumbleStrike/jobAstrologian.js new file mode 100644 index 0000000..7f6059c --- /dev/null +++ b/buttons/crumbleStrike/jobAstrologian.js @@ -0,0 +1,19 @@ +const { + chooseCrumbleStrikeJob +} = require( + "../../utils/crumbleStrike" +); + + +module.exports = { + name: + "crumbleStrikeJobAstrologian", + + async execute(interaction) { + return chooseCrumbleStrikeJob( + interaction, + "Healer", + "Astrologian" + ); + } +}; \ No newline at end of file diff --git a/buttons/crumbleStrike/jobBard.js b/buttons/crumbleStrike/jobBard.js new file mode 100644 index 0000000..d9985cc --- /dev/null +++ b/buttons/crumbleStrike/jobBard.js @@ -0,0 +1,19 @@ +const { + chooseCrumbleStrikeJob +} = require( + "../../utils/crumbleStrike" +); + + +module.exports = { + name: + "crumbleStrikeJobBard", + + async execute(interaction) { + return chooseCrumbleStrikeJob( + interaction, + "DPS", + "Bard" + ); + } +}; \ No newline at end of file diff --git a/buttons/crumbleStrike/jobBlackMage.js b/buttons/crumbleStrike/jobBlackMage.js new file mode 100644 index 0000000..b131818 --- /dev/null +++ b/buttons/crumbleStrike/jobBlackMage.js @@ -0,0 +1,19 @@ +const { + chooseCrumbleStrikeJob +} = require( + "../../utils/crumbleStrike" +); + + +module.exports = { + name: + "crumbleStrikeJobBlackMage", + + async execute(interaction) { + return chooseCrumbleStrikeJob( + interaction, + "DPS", + "BlackMage" + ); + } +}; \ No newline at end of file diff --git a/buttons/crumbleStrike/jobDarkKnight.js b/buttons/crumbleStrike/jobDarkKnight.js new file mode 100644 index 0000000..6e1130d --- /dev/null +++ b/buttons/crumbleStrike/jobDarkKnight.js @@ -0,0 +1,19 @@ +const { + chooseCrumbleStrikeJob +} = require( + "../../utils/crumbleStrike" +); + + +module.exports = { + name: + "crumbleStrikeJobDarkKnight", + + async execute(interaction) { + return chooseCrumbleStrikeJob( + interaction, + "Tank", + "DarkKnight" + ); + } +}; \ No newline at end of file diff --git a/buttons/crumbleStrike/jobDragoon.js b/buttons/crumbleStrike/jobDragoon.js new file mode 100644 index 0000000..c82de1e --- /dev/null +++ b/buttons/crumbleStrike/jobDragoon.js @@ -0,0 +1,19 @@ +const { + chooseCrumbleStrikeJob +} = require( + "../../utils/crumbleStrike" +); + + +module.exports = { + name: + "crumbleStrikeJobDragoon", + + async execute(interaction) { + return chooseCrumbleStrikeJob( + interaction, + "DPS", + "Dragoon" + ); + } +}; \ No newline at end of file diff --git a/buttons/crumbleStrike/jobPaladin.js b/buttons/crumbleStrike/jobPaladin.js new file mode 100644 index 0000000..b299dda --- /dev/null +++ b/buttons/crumbleStrike/jobPaladin.js @@ -0,0 +1,19 @@ +const { + chooseCrumbleStrikeJob +} = require( + "../../utils/crumbleStrike" +); + + +module.exports = { + name: + "crumbleStrikeJobPaladin", + + async execute(interaction) { + return chooseCrumbleStrikeJob( + interaction, + "Tank", + "Paladin" + ); + } +}; \ No newline at end of file diff --git a/buttons/crumbleStrike/jobScholar.js b/buttons/crumbleStrike/jobScholar.js new file mode 100644 index 0000000..cf476f8 --- /dev/null +++ b/buttons/crumbleStrike/jobScholar.js @@ -0,0 +1,19 @@ +const { + chooseCrumbleStrikeJob +} = require( + "../../utils/crumbleStrike" +); + + +module.exports = { + name: + "crumbleStrikeJobScholar", + + async execute(interaction) { + return chooseCrumbleStrikeJob( + interaction, + "Healer", + "Scholar" + ); + } +}; \ No newline at end of file diff --git a/buttons/crumbleStrike/jobWarrior.js b/buttons/crumbleStrike/jobWarrior.js new file mode 100644 index 0000000..02c7901 --- /dev/null +++ b/buttons/crumbleStrike/jobWarrior.js @@ -0,0 +1,19 @@ +const { + chooseCrumbleStrikeJob +} = require( + "../../utils/crumbleStrike" +); + + +module.exports = { + name: + "crumbleStrikeJobWarrior", + + async execute(interaction) { + return chooseCrumbleStrikeJob( + interaction, + "Tank", + "Warrior" + ); + } +}; \ No newline at end of file diff --git a/buttons/crumbleStrike/jobWhiteMage.js b/buttons/crumbleStrike/jobWhiteMage.js new file mode 100644 index 0000000..1b470e7 --- /dev/null +++ b/buttons/crumbleStrike/jobWhiteMage.js @@ -0,0 +1,19 @@ +const { + chooseCrumbleStrikeJob +} = require( + "../../utils/crumbleStrike" +); + + +module.exports = { + name: + "crumbleStrikeJobWhiteMage", + + async execute(interaction) { + return chooseCrumbleStrikeJob( + interaction, + "Healer", + "WhiteMage" + ); + } +}; \ No newline at end of file diff --git a/buttons/crumbleStrike/roleDPS.js b/buttons/crumbleStrike/roleDPS.js new file mode 100644 index 0000000..3c79f41 --- /dev/null +++ b/buttons/crumbleStrike/roleDPS.js @@ -0,0 +1,18 @@ +const { + chooseCrumbleStrikeRole +} = require( + "../../utils/crumbleStrike" +); + + +module.exports = { + name: + "crumbleStrikeRoleDPS", + + async execute(interaction) { + return chooseCrumbleStrikeRole( + interaction, + "DPS" + ); + } +}; \ No newline at end of file diff --git a/buttons/crumbleStrike/roleHealer.js b/buttons/crumbleStrike/roleHealer.js new file mode 100644 index 0000000..0e8f3cc --- /dev/null +++ b/buttons/crumbleStrike/roleHealer.js @@ -0,0 +1,18 @@ +const { + chooseCrumbleStrikeRole +} = require( + "../../utils/crumbleStrike" +); + + +module.exports = { + name: + "crumbleStrikeRoleHealer", + + async execute(interaction) { + return chooseCrumbleStrikeRole( + interaction, + "Healer" + ); + } +}; \ No newline at end of file diff --git a/buttons/crumbleStrike/roleTank.js b/buttons/crumbleStrike/roleTank.js new file mode 100644 index 0000000..4ab3e54 --- /dev/null +++ b/buttons/crumbleStrike/roleTank.js @@ -0,0 +1,18 @@ +const { + chooseCrumbleStrikeRole +} = require( + "../../utils/crumbleStrike" +); + + +module.exports = { + name: + "crumbleStrikeRoleTank", + + async execute(interaction) { + return chooseCrumbleStrikeRole( + interaction, + "Tank" + ); + } +}; \ No newline at end of file diff --git a/buttons/crumbleStrike/start.js b/buttons/crumbleStrike/start.js new file mode 100644 index 0000000..1975049 --- /dev/null +++ b/buttons/crumbleStrike/start.js @@ -0,0 +1,17 @@ +const { + startCrumbleStrikeBattle +} = require( + "../../utils/crumbleStrike" +); + + +module.exports = { + name: + "crumbleStrikeStart", + + async execute(interaction) { + return startCrumbleStrikeBattle( + interaction + ); + } +}; \ No newline at end of file diff --git a/buttons/crumbleStrike/tierAdventurer.js b/buttons/crumbleStrike/tierAdventurer.js new file mode 100644 index 0000000..6269580 --- /dev/null +++ b/buttons/crumbleStrike/tierAdventurer.js @@ -0,0 +1,18 @@ +const { + confirmCrumbleStrikeJoin +} = require( + "../../utils/crumbleStrike" +); + + +module.exports = { + name: + "crumbleStrikeTierAdventurer", + + async execute(interaction) { + return confirmCrumbleStrikeJoin( + interaction, + "Adventurer" + ); + } +}; \ No newline at end of file diff --git a/buttons/crumbleStrike/tierSprout.js b/buttons/crumbleStrike/tierSprout.js new file mode 100644 index 0000000..b15f616 --- /dev/null +++ b/buttons/crumbleStrike/tierSprout.js @@ -0,0 +1,18 @@ +const { + confirmCrumbleStrikeJoin +} = require( + "../../utils/crumbleStrike" +); + + +module.exports = { + name: + "crumbleStrikeTierSprout", + + async execute(interaction) { + return confirmCrumbleStrikeJoin( + interaction, + "Sprout" + ); + } +}; \ No newline at end of file diff --git a/buttons/crumbleStrike/tierWarriorOfLight.js b/buttons/crumbleStrike/tierWarriorOfLight.js new file mode 100644 index 0000000..18cfaa9 --- /dev/null +++ b/buttons/crumbleStrike/tierWarriorOfLight.js @@ -0,0 +1,18 @@ +const { + confirmCrumbleStrikeJoin +} = require( + "../../utils/crumbleStrike" +); + + +module.exports = { + name: + "crumbleStrikeTierWarriorOfLight", + + async execute(interaction) { + return confirmCrumbleStrikeJoin( + interaction, + "WarriorOfLight" + ); + } +}; \ No newline at end of file diff --git a/commands/applications/cookies.js b/commands/applications/cookies.js index 2dd84fd..19ee780 100644 --- a/commands/applications/cookies.js +++ b/commands/applications/cookies.js @@ -1,14 +1,9 @@ const { SlashCommandBuilder, EmbedBuilder, - ActionRowBuilder, - ButtonBuilder, - ButtonStyle, MessageFlags } = require("discord.js"); -const { randomUUID } = require("node:crypto"); - const { getCollections, getCookieBalance, @@ -24,29 +19,12 @@ const { } = require("../../utils/cookieEconomy"); -/* - * CONFIG - */ - const COLLECT_COOLDOWN = - 60 * 60; // 1 hour + 60 * 60; const STEAL_COOLDOWN = - 15 * 60; // 15 minutes + 15 * 60; -const BOSS_SPAWN_COST = - 500; - -const BOSS_JOIN_DURATION = - 30 * 60; // 30 minutes - -const BOSS_MINIMUM_PLAYERS = - 3; - - -/* - * Legacy Abra bonus - */ const ABRA_ID = "378135175059603457"; @@ -55,38 +33,26 @@ const ABRA_MULTIPLIER = 1.5; -/* - * COMMAND - */ - module.exports = { data: new SlashCommandBuilder() .setName("cookies") .setDescription( - "The ShaiBot cookie economy." + "The ShaiBot Cookie economy." ) - /* - * /cookies collect - */ - .addSubcommand(subcommand => subcommand .setName("collect") .setDescription( - "Collect a fresh batch of cookies." + "Collect a fresh batch of Cookies." ) ) - /* - * /cookies steal - */ - .addSubcommand(subcommand => subcommand .setName("steal") .setDescription( - "Attempt to steal another user's cookies." + "Attempt to steal another user's Cookies." ) .addUserOption(option => @@ -99,55 +65,27 @@ module.exports = { ) ) - /* - * /cookies boss spawn - */ - - .addSubcommandGroup(group => - group - .setName("boss") - .setDescription( - "Cookie Boss commands." - ) - - .addSubcommand(subcommand => - subcommand - .setName("spawn") - .setDescription( - `Spawn a Cookie Boss for ${BOSS_SPAWN_COST} Cookies.` - ) - ) - ) - - /* - * /cookies check - */ - .addSubcommand(subcommand => subcommand .setName("check") .setDescription( - "Check a cookie balance." + "Check a Cookie balance." ) .addUserOption(option => option .setName("target") .setDescription( - "The user whose cookies you want to check." + "The user whose Cookies you want to check." ) ) ) - /* - * /cookies give - */ - .addSubcommand(subcommand => subcommand .setName("give") .setDescription( - "Give some of your cookies to another user." + "Give some of your Cookies to another user." ) .addUserOption(option => @@ -163,55 +101,26 @@ module.exports = { option .setName("amount") .setDescription( - "How many cookies to give." + "How many Cookies to give." ) .setRequired(true) .setMinValue(1) ) ) - /* - * /cookies leaderboard - */ - .addSubcommand(subcommand => subcommand .setName("leaderboard") .setDescription( - "Show the richest cookie collectors." + "Show the richest Cookie collectors." ) ), async execute(interaction) { - const group = - interaction.options.getSubcommandGroup( - false - ); - - const subcommand = - interaction.options.getSubcommand(); - - - /* - * Subcommand groups - */ - - if ( - group === "boss" && - subcommand === "spawn" + switch ( + interaction.options.getSubcommand() ) { - return spawnBoss( - interaction - ); - } - - - /* - * Normal subcommands - */ - - switch (subcommand) { case "collect": return collect( interaction @@ -240,7 +149,7 @@ module.exports = { default: return interaction.reply({ content: - "Unknown cookies command.", + "Unknown Cookies command.", flags: MessageFlags.Ephemeral }); @@ -249,12 +158,6 @@ module.exports = { }; -/* - * ============================================================ - * /cookies collect - * ============================================================ - */ - async function collect(interaction) { const userId = interaction.user.id; @@ -294,10 +197,6 @@ async function collect(interaction) { ); - /* - * Legacy Abra bonus - */ - if ( userId === ABRA_ID @@ -336,12 +235,14 @@ async function collect(interaction) { .setTitle( "🍪 Fresh Batch" ) + .setDescription( randomCollectResponse( amount, interaction.user ) ) + .addFields( { name: @@ -376,6 +277,7 @@ async function collect(interaction) { false } ) + .setThumbnail( interaction.user.displayAvatarURL({ size: 256 @@ -391,16 +293,11 @@ async function collect(interaction) { } -/* - * ============================================================ - * /cookies steal - * ============================================================ - */ - async function steal(interaction) { const user = interaction.user; + const target = interaction.options.getUser( "target", @@ -408,10 +305,6 @@ async function steal(interaction) { ); - /* - * Validation - */ - if ( target.id === user.id @@ -425,11 +318,6 @@ async function steal(interaction) { } - /* - * ShaiBot itself is reserved for - * The Crumb Saucer Vault Heist. - */ - if ( target.id === interaction.client.user.id @@ -453,10 +341,6 @@ async function steal(interaction) { } - /* - * Cooldown - */ - const now = getUnixTime(); @@ -482,10 +366,6 @@ async function steal(interaction) { } - /* - * Balances - */ - const [ yourCookies, theirCookies @@ -527,10 +407,6 @@ async function steal(interaction) { } - /* - * Dynamic success chance - */ - const successChance = getStealChance( yourCookies, @@ -546,12 +422,6 @@ async function steal(interaction) { let embed; - /* - * ======================================================== - * SUCCESS - * ======================================================== - */ - if (success) { const { amount @@ -581,6 +451,7 @@ async function steal(interaction) { }); } + throw error; } @@ -613,12 +484,14 @@ async function steal(interaction) { .setTitle( "🥷 Cookie Robbery Successful" ) + .setDescription( randomStealSuccessResponse( amount, target ) ) + .addFields( { name: @@ -675,6 +548,7 @@ async function steal(interaction) { true } ) + .setThumbnail( target.displayAvatarURL({ size: 256 @@ -683,12 +557,6 @@ async function steal(interaction) { } - /* - * ======================================================== - * FAILURE - * ======================================================== - */ - else { const penaltyPercentage = randomFloat( @@ -713,11 +581,6 @@ async function steal(interaction) { try { - /* - * Normal steal failures reward - * the victim. - */ - await transferCookies( user.id, target.id, @@ -737,6 +600,7 @@ async function steal(interaction) { }); } + throw error; } @@ -769,12 +633,14 @@ async function steal(interaction) { .setTitle( "🚨 Cookie Robbery Failed" ) + .setDescription( randomStealFailResponse( penalty, target ) ) + .addFields( { name: @@ -831,6 +697,7 @@ async function steal(interaction) { true } ) + .setThumbnail( target.displayAvatarURL({ size: 256 @@ -839,10 +706,6 @@ async function steal(interaction) { } - /* - * Apply cooldown - */ - const nextSteal = await setCooldown( user.id, @@ -872,389 +735,6 @@ async function steal(interaction) { } -/* - * ============================================================ - * /cookies boss spawn - * ============================================================ - */ - -async function spawnBoss(interaction) { - if (!interaction.guild) { - return interaction.reply({ - content: - "Cookie Bosses can only be summoned inside a server.", - flags: - MessageFlags.Ephemeral - }); - } - - - const { - db - } = - getCollections(); - - - const events = - db.collection( - "items_events" - ); - - - /* - * Only one unresolved Cookie Boss per guild. - */ - - const existing = - await events.findOne({ - eventType: - "CookieBoss", - - guildId: - interaction.guildId, - - state: { - $in: [ - "Joining", - "Starting", - "Active", - "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.toLocaleString()} Cookies** to summon the boss.`, - flags: - MessageFlags.Ephemeral - }); - } - - - /* - * Unique boss encounter. - */ - - const eventId = - randomUUID(); - - - /* - * This virtual account holds all committed - * Cookie Boss currency until the event resolves. - */ - - const escrowId = - `boss:${eventId}`; - - - const now = - getUnixTime(); - - - const joinUntil = - now + - BOSS_JOIN_DURATION; - - - /* - * Spawner cost seeds the reward pool. - */ - - try { - await transferCookies( - interaction.user.id, - escrowId, - BOSS_SPAWN_COST - ); - - } catch (error) { - if ( - error.message === - "INSUFFICIENT_COOKIES" - ) { - return interaction.reply({ - content: - "Your Cookie balance changed before the boss could be summoned.", - flags: - MessageFlags.Ephemeral - }); - } - - throw error; - } - - - /* - * Initial recruitment embed - */ - - 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}`, - `**Participants:** 0/${BOSS_MINIMUM_PLAYERS}`, - `**Reward Pool:** ${BOSS_SPAWN_COST.toLocaleString()} 🍪`, - "", - "Choose how many Cookies you want to commit to the fight.", - "Your tier determines your temporary HP and Power.", - "", - "If enough players join, the summoner may start the battle early." - ].join("\n") - ) - .setFooter({ - text: - `cookieBoss:${eventId}` - }); - - - /* - * Join tier buttons - */ - - 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 — 1,000" - ) - .setEmoji( - "🔥" - ) - .setStyle( - ButtonStyle.Danger - ) - ); - - - /* - * Summoner early-start button. - */ - - const startRow = - new ActionRowBuilder() - .addComponents( - new ButtonBuilder() - .setCustomId( - "cookieBossStart" - ) - .setLabel( - "Start Battle" - ) - .setEmoji( - "⚔️" - ) - .setStyle( - ButtonStyle.Success - ) - ); - - - let message; - - - try { - message = - await interaction.reply({ - embeds: [ - embed - ], - - components: [ - joinRow, - startRow - ], - - fetchReply: - true - }); - - } catch (error) { - /* - * Refund spawn cost if Discord message creation fails. - */ - - try { - await transferCookies( - escrowId, - interaction.user.id, - BOSS_SPAWN_COST - ); - } catch (refundError) { - console.error( - "CRITICAL: Failed to refund boss spawn after message failure:", - refundError - ); - } - - throw error; - } - - - /* - * Persist encounter. - */ - - try { - 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: - {} - }); - - } catch (error) { - /* - * If Mongo fails after the message was posted, - * remove the message and refund the creator. - */ - - try { - await message.delete(); - } catch {} - - - try { - await transferCookies( - escrowId, - interaction.user.id, - BOSS_SPAWN_COST - ); - } catch (refundError) { - console.error( - "CRITICAL: Failed to refund boss spawn after database failure:", - refundError - ); - } - - - throw error; - } -} - - -/* - * ============================================================ - * /cookies check - * ============================================================ - */ - async function check(interaction) { const target = interaction.options.getUser( @@ -1282,11 +762,13 @@ async function check(interaction) { ? "🏦 ShaiBot Cookie Vault" : "🍪 Cookie Vault" ) + .setDescription( isHouse ? `The house currently holds **${balance.toLocaleString()} Cookies**.` : `${target} currently has **${balance.toLocaleString()} Cookie${balance === 1 ? "" : "s"}**.` ) + .setThumbnail( target.displayAvatarURL({ size: 256 @@ -1302,12 +784,6 @@ async function check(interaction) { } -/* - * ============================================================ - * /cookies give - * ============================================================ - */ - async function give(interaction) { const user = interaction.user; @@ -1340,10 +816,6 @@ async function give(interaction) { } - /* - * Keep bot deposits tied to the game systems. - */ - if (target.bot) { return interaction.reply({ content: @@ -1374,6 +846,7 @@ async function give(interaction) { }); } + throw error; } @@ -1398,9 +871,11 @@ async function give(interaction) { .setTitle( "🍪 Cookies Shared" ) + .setDescription( `${user} gave ${target} **${amount.toLocaleString()} Cookie${amount === 1 ? "" : "s"}**.` ) + .addFields( { name: @@ -1424,6 +899,7 @@ async function give(interaction) { true } ) + .setThumbnail( target.displayAvatarURL({ size: 256 @@ -1439,12 +915,6 @@ async function give(interaction) { } -/* - * ============================================================ - * /cookies leaderboard - * ============================================================ - */ - async function leaderboard(interaction) { const { cookies @@ -1452,17 +922,6 @@ async function leaderboard(interaction) { getCollections(); - /* - * Restrict to Discord snowflake-style user IDs. - * - * This prevents temporary boss escrow accounts - * such as: - * - * boss: - * - * from appearing on the leaderboard. - */ - const results = await cookies .find({ @@ -1476,19 +935,16 @@ async function leaderboard(interaction) { 0 } }) + .sort({ cookies: -1 }) - .limit(15) + + .limit(25) .toArray(); - /* - * Exclude ShaiBot itself from the player - * leaderboard. - */ - const playerResults = results .filter( @@ -1496,6 +952,7 @@ async function leaderboard(interaction) { record.userId !== interaction.client.user.id ) + .slice( 0, 10 @@ -1509,13 +966,12 @@ async function leaderboard(interaction) { ( record, index - ) => { - return ( + ) => + ( `${getLeaderboardMedal(index)} ` + `<@${record.userId}> — ` + `**${record.cookies.toLocaleString()}** 🍪` - ); - } + ) ) .join("\n") @@ -1533,9 +989,11 @@ async function leaderboard(interaction) { .setTitle( "🍪 Cookie Leaderboard" ) + .setDescription( description ) + .addFields({ name: "🏦 ShaiBot House Vault", @@ -1543,6 +1001,7 @@ async function leaderboard(interaction) { value: `**${houseBalance.toLocaleString()} 🍪**` }) + .setTimestamp(); @@ -1565,12 +1024,6 @@ async function leaderboard(interaction) { } -/* - * ============================================================ - * STEAL HELPERS - * ============================================================ - */ - function getStealChance( attackerCookies, targetCookies @@ -1585,10 +1038,6 @@ function getStealChance( 0.50; - /* - * Wealthier targets become harder to rob. - */ - const targetPenalty = Math.log10( targetCookies + @@ -1598,10 +1047,6 @@ function getStealChance( 0.06; - /* - * Compare attacker wealth to target wealth. - */ - const wealthRatio = attackerCookies / Math.max( @@ -1610,11 +1055,6 @@ function getStealChance( ); - /* - * Poorer attackers receive a modest - * catch-up bonus. - */ - const povertyBonus = wealthRatio < 1 @@ -1628,11 +1068,6 @@ function getStealChance( : 0; - /* - * Rich attackers targeting poorer users - * receive an anti-bullying penalty. - */ - const bullyPenalty = wealthRatio > 1 @@ -1713,11 +1148,6 @@ function getStealPercentage() { Math.random(); - /* - * 50%: - * 1–3% - */ - if ( roll < 0.50 @@ -1729,11 +1159,6 @@ function getStealPercentage() { } - /* - * 30%: - * 3–5% - */ - if ( roll < 0.80 @@ -1745,11 +1170,6 @@ function getStealPercentage() { } - /* - * 15%: - * 5–8% - */ - if ( roll < 0.95 @@ -1761,11 +1181,6 @@ function getStealPercentage() { } - /* - * 5% jackpot: - * 8–10% - */ - return randomFloat( 0.08, 0.10 @@ -1773,12 +1188,6 @@ function getStealPercentage() { } -/* - * ============================================================ - * COLLECT RESPONSES - * ============================================================ - */ - function randomCollectResponse( amount, user @@ -1787,7 +1196,7 @@ function randomCollectResponse( `**${amount.toLocaleString()} Cookie${amount === 1 ? "" : "s"}**`; - const responses = [ + return randomItem([ `${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`, @@ -1795,28 +1204,14 @@ function randomCollectResponse( `${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.`, + `${user}, your ${cookies} are fresh out of the oven.`, + `${user}, I bribed the Cookie Monster to spare these ${cookies}.`, `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 - ); + `${user}, Abra said these ${cookies} are "totally not stolen"... 🤔` + ]); } -/* - * ============================================================ - * STEAL SUCCESS RESPONSES - * ============================================================ - */ - function randomStealSuccessResponse( amount, target @@ -1825,32 +1220,16 @@ function randomStealSuccessResponse( `**${amount.toLocaleString()} Cookie${amount === 1 ? "" : "s"}**`; - const responses = [ + return randomItem([ `You've successfully stolen ${cookies} from ${target}!`, - `Ninja mode activated. You snatched ${cookies} from ${target} without leaving a crumb behind.`, + `Ninja mode activated. You snatched ${cookies} from ${target}.`, `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 - ); + `Clean getaway! ${target} won't notice the missing ${cookies} until it's too late.` + ]); } -/* - * ============================================================ - * STEAL FAILURE RESPONSES - * ============================================================ - */ - function randomStealFailResponse( amount, target @@ -1859,31 +1238,16 @@ function randomStealFailResponse( `**${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.`, + return randomItem([ + `You were caught red-handed by ${target} and lost ${cookies}!`, + `${target} caught you mid-bite — you dropped ${cookies}.`, + `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 - ); + `Caught by the Cookie Police! ${cookies} were given to ${target}.` + ]); } -/* - * ============================================================ - * LEADERBOARD - * ============================================================ - */ - function getLeaderboardMedal( index ) { diff --git a/commands/applications/crumbleStrike.js b/commands/applications/crumbleStrike.js new file mode 100644 index 0000000..e25d8dc --- /dev/null +++ b/commands/applications/crumbleStrike.js @@ -0,0 +1,417 @@ +const { + SlashCommandBuilder, + EmbedBuilder, + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + MessageFlags +} = require("discord.js"); + +const { + randomUUID +} = require("node:crypto"); + +const { + getCollections, + getCookieBalance, + transferCookies, + getUnixTime +} = require("../../utils/cookieEconomy"); + + +const SPAWN_COST = + 500; + +const JOIN_DURATION = + 30 * 60; + +const MINIMUM_PLAYERS = + 5; + + +module.exports = { + data: new SlashCommandBuilder() + .setName("crumblestrike") + .setDescription( + "Cooperative Cookie boss encounters." + ) + + .addSubcommand(subcommand => + subcommand + .setName("spawn") + .setDescription( + `Summon the Crumbbound Behemoth for ${SPAWN_COST} Cookies.` + ) + ), + + + async execute(interaction) { + switch ( + interaction.options.getSubcommand() + ) { + case "spawn": + return spawnCrumbleStrike( + interaction + ); + + default: + return interaction.reply({ + content: + "Unknown Crumble Strike command.", + flags: + MessageFlags.Ephemeral + }); + } + } +}; + + +async function spawnCrumbleStrike( + interaction +) { + if (!interaction.guild) { + return interaction.reply({ + content: + "Crumble Strike can only be started inside a server.", + flags: + MessageFlags.Ephemeral + }); + } + + + const { + db + } = + getCollections(); + + + const events = + db.collection( + "items_events" + ); + + + const existing = + await events.findOne({ + eventType: + "CrumbleStrike", + + guildId: + interaction.guildId, + + state: { + $in: [ + "Joining", + "Starting", + "Active", + "Resolving" + ] + } + }); + + + if (existing) { + return interaction.reply({ + content: + "There is already an active Crumble Strike encounter in this server.", + flags: + MessageFlags.Ephemeral + }); + } + + + const balance = + await getCookieBalance( + interaction.user.id + ); + + + if ( + balance < + SPAWN_COST + ) { + return interaction.reply({ + content: + `You need **${SPAWN_COST.toLocaleString()} Cookies** to summon the boss.`, + flags: + MessageFlags.Ephemeral + }); + } + + + const eventId = + randomUUID(); + + + const escrowId = + `crumblestrike:${eventId}`; + + + const now = + getUnixTime(); + + + const joinUntil = + now + + JOIN_DURATION; + + + try { + await transferCookies( + interaction.user.id, + escrowId, + SPAWN_COST + ); + + } catch (error) { + if ( + error.message === + "INSUFFICIENT_COOKIES" + ) { + return interaction.reply({ + content: + "Your Cookie balance changed before the boss could be summoned.", + flags: + MessageFlags.Ephemeral + }); + } + + + throw error; + } + + + const embed = + new EmbedBuilder() + .setTitle( + "🍪 Crumble Strike" + ) + + .setDescription( + [ + "## The Crumbbound Behemoth", + "", + "*Drawn by the scent of an irresponsibly large Cookie Vault,*", + "*the beast has lumbered into Shaiwase.*", + "", + `**Summoner:** ${interaction.user}`, + `**Recruitment ends:** `, + `**Minimum adventurers:** ${MINIMUM_PLAYERS}`, + `**Participants:** 0/${MINIMUM_PLAYERS}`, + `**Reward Pool:** ${SPAWN_COST.toLocaleString()} 🍪`, + "", + "Choose your role below to begin preparing for battle.", + "", + "⚔️ **DPS** — Specializes in offensive actions.", + "🛡️ **Tank** — Specializes in mitigating boss attacks.", + "❤️ **Healer** — Specializes in restoring party HP.", + "", + "Once enough players have joined, the summoner may start the battle early." + ].join("\n") + ) + + .setFooter({ + text: + `crumbleStrike:${eventId}` + }); + + + const roles = + new ActionRowBuilder() + .addComponents( + new ButtonBuilder() + .setCustomId( + "crumbleStrikeRoleDPS" + ) + .setLabel( + "DPS" + ) + .setEmoji( + "⚔️" + ) + .setStyle( + ButtonStyle.Danger + ), + + new ButtonBuilder() + .setCustomId( + "crumbleStrikeRoleTank" + ) + .setLabel( + "Tank" + ) + .setEmoji( + "🛡️" + ) + .setStyle( + ButtonStyle.Primary + ), + + new ButtonBuilder() + .setCustomId( + "crumbleStrikeRoleHealer" + ) + .setLabel( + "Healer" + ) + .setEmoji( + "❤️" + ) + .setStyle( + ButtonStyle.Success + ) + ); + + + const start = + new ActionRowBuilder() + .addComponents( + new ButtonBuilder() + .setCustomId( + "crumbleStrikeStart" + ) + .setLabel( + "Start Battle" + ) + .setEmoji( + "⚔️" + ) + .setStyle( + ButtonStyle.Secondary + ) + ); + + + let message; + + + try { + message = + await interaction.reply({ + embeds: [ + embed + ], + + components: [ + roles, + start + ], + + fetchReply: + true + }); + + } catch (error) { + await safeRefund( + escrowId, + interaction.user.id, + SPAWN_COST + ); + + + throw error; + } + + + try { + await events.insertOne({ + eventType: + "CrumbleStrike", + + 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: + MINIMUM_PLAYERS, + + rewardPool: + SPAWN_COST, + + boss: { + bossId: + "crumbbound-behemoth", + + name: + "The Crumbbound Behemoth", + + health: + 15000, + + maxHealth: + 15000, + + limitGauge: + 0, + + actionGauge: + 0 + }, + + participants: + {} + }); + + } catch (error) { + try { + await message.delete(); + } catch {} + + + await safeRefund( + escrowId, + interaction.user.id, + SPAWN_COST + ); + + + throw error; + } +} + + +async function safeRefund( + from, + to, + amount +) { + try { + await transferCookies( + from, + to, + amount + ); + + } catch (error) { + console.error( + "CRITICAL: Crumble Strike refund failed:", + error + ); + } +} \ No newline at end of file diff --git a/handlers/cookieBoss.js b/handlers/cookieBoss.js deleted file mode 100644 index bd24b6c..0000000 --- a/handlers/cookieBoss.js +++ /dev/null @@ -1,230 +0,0 @@ -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/handlers/crumbleStrike.js b/handlers/crumbleStrike.js new file mode 100644 index 0000000..483e57c --- /dev/null +++ b/handlers/crumbleStrike.js @@ -0,0 +1,200 @@ +const { + getCollections, + getUnixTime +} = require( + "../utils/cookieEconomy" +); + +const { + activateCrumbleStrike, + failCrumbleStrike, + cancelCrumbleStrikeAndRefund +} = require( + "../utils/crumbleStrike" +); + + +const CHECK_INTERVAL = + 60 * 1000; + + +module.exports = client => { + + setTimeout( + () => { + processCrumbleStrikeEvents( + client + ); + }, + 5000 + ); + + + setInterval( + () => { + processCrumbleStrikeEvents( + client + ); + }, + CHECK_INTERVAL + ); + + + console.log( + "[CrumbleStrike] Event watcher initialized" + ); +}; + + +async function processCrumbleStrikeEvents( + client +) { + try { + const { + db + } = + getCollections(); + + + const events = + db.collection( + "items_events" + ); + + + const now = + getUnixTime(); + + + /* + * Recruitment expired. + */ + + const joiningEvents = + await events + .find({ + eventType: + "CrumbleStrike", + + state: + "Joining", + + joinUntil: { + $lte: + now + } + }) + .toArray(); + + + for ( + const event + of joiningEvents + ) { + const participantCount = + Object.keys( + event.participants ?? + {} + ).length; + + + console.log( + `[CrumbleStrike] Recruitment expired for ${event.eventId} ` + + `(${participantCount}/${event.minimumParticipants})` + ); + + + if ( + participantCount >= + event.minimumParticipants + ) { + const started = + await activateCrumbleStrike( + client, + event.eventId + ); + + + if (started) { + console.log( + `[CrumbleStrike] ${event.eventId} started automatically` + ); + } + + + continue; + } + + + const cancelled = + await cancelCrumbleStrikeAndRefund( + client, + event.eventId + ); + + + if (cancelled) { + console.log( + `[CrumbleStrike] ${event.eventId} cancelled and refunded` + ); + } + } + + + /* + * Battle expired. + */ + + const activeEvents = + await events + .find({ + eventType: + "CrumbleStrike", + + state: + "Active", + + battleEndsAt: { + $lte: + now + }, + + "boss.health": { + $gt: + 0 + } + }) + .toArray(); + + + for ( + const event + of activeEvents + ) { + console.log( + `[CrumbleStrike] ${event.eventId} expired with ` + + `${event.boss.health}/${event.boss.maxHealth} HP remaining` + ); + + + const failed = + await failCrumbleStrike( + client, + event.eventId + ); + + + if (failed) { + console.log( + `[CrumbleStrike] ${event.eventId} failed` + ); + } + } + + } catch (error) { + console.error( + "[CrumbleStrike] Watcher error:", + error + ); + } +} \ No newline at end of file diff --git a/utils/cookieBoss.js b/utils/cookieBoss.js deleted file mode 100644 index 8c0dc6a..0000000 --- a/utils/cookieBoss.js +++ /dev/null @@ -1,1693 +0,0 @@ -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/cookieHeist.js b/utils/cookieHeist.js index 3539005..f533849 100644 --- a/utils/cookieHeist.js +++ b/utils/cookieHeist.js @@ -1,5 +1,8 @@ const { - EmbedBuilder + EmbedBuilder, + ActionRowBuilder, + ButtonBuilder, + ButtonStyle } = require("discord.js"); const { @@ -23,86 +26,47 @@ const HEIST_COOLDOWN = /* - * Each approach has a different risk/reward profile. + * All three approaches use the SAME cooldown: * - * Sneak: - * High success chance - * Small reward - * Small failure penalty + * cooldowns_cookies.heist * - * Hack: - * Medium success chance - * Medium reward - * Medium failure penalty - * - * Smash: - * Low success chance - * Large reward - * Large failure penalty + * Sneak does NOT have its own cooldown. + * Hack does NOT have its own cooldown. + * Smash does NOT have its own cooldown. */ const HEIST_METHODS = { - Sneak: { - baseChance: - 0.65, + baseChance: 0.65, - minStealPercent: - 0.01, + minStealPercent: 0.01, + maxStealPercent: 0.03, + maxStealAmount: 1500, - maxStealPercent: - 0.03, - - maxStealAmount: - 1500, - - minPenaltyPercent: - 0.01, - - maxPenaltyPercent: - 0.04 + minPenaltyPercent: 0.01, + maxPenaltyPercent: 0.04 }, - Hack: { - baseChance: - 0.45, + baseChance: 0.45, - minStealPercent: - 0.03, + minStealPercent: 0.03, + maxStealPercent: 0.06, + maxStealAmount: 3500, - maxStealPercent: - 0.06, - - maxStealAmount: - 3500, - - minPenaltyPercent: - 0.03, - - maxPenaltyPercent: - 0.07 + minPenaltyPercent: 0.03, + maxPenaltyPercent: 0.07 }, - Smash: { - baseChance: - 0.25, + baseChance: 0.25, - minStealPercent: - 0.06, + minStealPercent: 0.06, + maxStealPercent: 0.12, + maxStealAmount: 7500, - maxStealPercent: - 0.12, - - maxStealAmount: - 7500, - - minPenaltyPercent: - 0.06, - - maxPenaltyPercent: - 0.12 + minPenaltyPercent: 0.06, + maxPenaltyPercent: 0.12 } }; @@ -117,15 +81,12 @@ async function resolveCookieHeist( interaction, methodName ) { - /* - * The original /cookies heist command stores - * the player ID inside the embed footer: + * Validate ownership of this Heist menu. * - * cookieHeist:123456789 + * Footer: * - * This prevents somebody else from pressing - * another player's heist buttons. + * cookieHeist: */ const footer = @@ -139,10 +100,8 @@ async function resolveCookieHeist( type, ownerId ] = - footer - ?.split(":") - ?? - []; + footer?.split(":") + ?? []; if ( @@ -153,14 +112,14 @@ async function resolveCookieHeist( interaction, { content: - "This isn't your heist. Start your own with `/cookies heist`." + "This isn't your heist. Open your own from `/crumbsaucer`." } ); } /* - * Validate selected method. + * Validate method. */ const method = @@ -182,11 +141,18 @@ async function resolveCookieHeist( /* * ======================================================== - * CLAIM COOLDOWN + * SHARED HEIST COOLDOWN * ======================================================== * - * This is atomic so rapid double-clicks can't - * resolve the same heist multiple times. + * IMPORTANT: + * + * This is intentionally always "heist". + * + * It does NOT use: + * + * heistSneak + * heistHack + * heistSmash */ const cooldown = @@ -203,18 +169,18 @@ async function resolveCookieHeist( interaction, { content: - `You can attempt another vault heist .` - } + `🏦 The vault is still on high alert.\nYou can attempt another heist .` + }, + 6000 ); } /* - * ======================================================== - * LOCK THE HEIST MESSAGE - * ======================================================== + * Lock this Heist interaction immediately. * - * Remove all buttons immediately. + * Prevents selecting another approach from + * the same message. */ await interaction.update({ @@ -234,12 +200,6 @@ async function resolveCookieHeist( }); - /* - * ======================================================== - * GET BALANCES - * ======================================================== - */ - const playerId = interaction.user.id; @@ -247,12 +207,16 @@ async function resolveCookieHeist( interaction.client.user.id; + /* + * Re-fetch balances because they may have + * changed since the menu was opened. + */ + const [ playerCookies, vaultCookies ] = await Promise.all([ - getCookieBalance( playerId ), @@ -260,15 +224,9 @@ async function resolveCookieHeist( getCookieBalance( vaultId ) - ]); - /* - * Something may have changed between opening - * the heist menu and pressing the button. - */ - if ( playerCookies < 1 || vaultCookies < 1 @@ -282,18 +240,23 @@ async function resolveCookieHeist( .setDescription( "There weren't enough Cookies available to resolve the heist." ) + .addFields({ + name: + "Next Heist", + + value: + `` + }) ], - components: [] + components: + buildReturnButton() }); } /* - * Small dramatic pause. - * - * Only one edit happens after this, so this is - * comfortably within Discord rate limits. + * Short theatrical delay. */ await sleep( @@ -303,13 +266,8 @@ async function resolveCookieHeist( /* * ======================================================== - * VAULT DIFFICULTY + * DYNAMIC VAULT SECURITY * ======================================================== - * - * The richer ShaiBot's vault becomes, - * the harder it is to rob. - * - * Penalty is capped at 15 percentage points. */ const securityPenalty = @@ -339,38 +297,21 @@ async function resolveCookieHeist( */ if (success) { - - /* - * Roll percentage of the current vault. - */ - - const percent = + const rolledPercent = 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 + rolledPercent ), method.maxStealAmount, @@ -380,28 +321,67 @@ async function resolveCookieHeist( ); - /* - * ShaiBot pays the robber. - */ - - await transferCookies( - vaultId, - playerId, - amount - ); - - - const remaining = - await getCookieBalance( - vaultId + try { + await transferCookies( + vaultId, + playerId, + amount ); + } catch (error) { + if ( + error.message === + "INSUFFICIENT_COOKIES" + ) { + return interaction.editReply({ + embeds: [ + new EmbedBuilder() + .setTitle( + "🏦 Heist Interrupted" + ) + .setDescription( + "The ShaiBot Vault changed before you could complete the robbery." + ) + .addFields({ + name: + "Next Heist", + + value: + `` + }) + ], + + components: + buildReturnButton() + }); + } + + + throw error; + } + + + const [ + playerAfter, + vaultAfter + ] = + await Promise.all([ + getCookieBalance( + playerId + ), + + getCookieBalance( + vaultId + ) + ]); + const actualPercentage = ( amount / vaultCookies - ) * + ) + * 100; @@ -410,6 +390,7 @@ async function resolveCookieHeist( .setTitle( "💥 Vault Breached!" ) + .setDescription( getSuccessText( methodName, @@ -417,8 +398,8 @@ async function resolveCookieHeist( amount ) ) - .addFields( + .addFields( { name: "Approach", @@ -465,10 +446,21 @@ async function resolveCookieHeist( { name: - "Vault Remaining", + "Your Vault", value: - `**${remaining.toLocaleString()} 🍪**`, + `**${playerAfter.toLocaleString()} 🍪**`, + + inline: + true + }, + + { + name: + "House Vault", + + value: + `**${vaultAfter.toLocaleString()} 🍪**`, inline: true @@ -482,9 +474,8 @@ async function resolveCookieHeist( ``, inline: - true + false } - ); @@ -493,7 +484,8 @@ async function resolveCookieHeist( embed ], - components: [] + components: + buildReturnButton() }); } @@ -502,11 +494,6 @@ async function resolveCookieHeist( * ======================================================== * FAILURE * ======================================================== - * - * Failure fines the player. - * - * Unlike normal player stealing, the fine goes - * directly into ShaiBot's vault. */ const penaltyPercent = @@ -521,7 +508,6 @@ async function resolveCookieHeist( 1, Math.min( - Math.floor( playerCookies * penaltyPercent @@ -532,11 +518,49 @@ async function resolveCookieHeist( ); - await transferCookies( - playerId, - vaultId, - penalty - ); + try { + /* + * Heist fines always go into + * ShaiBot's central vault. + */ + + await transferCookies( + playerId, + vaultId, + penalty + ); + + } catch (error) { + if ( + error.message === + "INSUFFICIENT_COOKIES" + ) { + return interaction.editReply({ + embeds: [ + new EmbedBuilder() + .setTitle( + "🚨 Heist Failed" + ) + .setDescription( + "Your Cookie balance changed before the fine could be collected." + ) + .addFields({ + name: + "Next Heist", + + value: + `` + }) + ], + + components: + buildReturnButton() + }); + } + + + throw error; + } const [ @@ -544,7 +568,6 @@ async function resolveCookieHeist( vaultAfter ] = await Promise.all([ - getCookieBalance( playerId ), @@ -552,15 +575,24 @@ async function resolveCookieHeist( getCookieBalance( vaultId ) - ]); + const actualPenaltyPercentage = + ( + penalty / + playerCookies + ) + * + 100; + + const embed = new EmbedBuilder() .setTitle( "🚨 HEIST FAILED" ) + .setDescription( getFailureText( methodName, @@ -568,8 +600,8 @@ async function resolveCookieHeist( penalty ) ) - .addFields( + .addFields( { name: "Approach", @@ -603,6 +635,17 @@ async function resolveCookieHeist( true }, + { + name: + "Vault Lost", + + value: + `**${actualPenaltyPercentage.toFixed(1)}%**`, + + inline: + true + }, + { name: "Your Vault", @@ -633,9 +676,8 @@ async function resolveCookieHeist( ``, inline: - true + false } - ); @@ -644,26 +686,51 @@ async function resolveCookieHeist( embed ], - components: [] + components: + buildReturnButton() }); } /* * ============================================================ - * VAULT SECURITY + * RETURN TO CRUMB SAUCER + * ============================================================ + */ + +function buildReturnButton() { + return [ + new ActionRowBuilder() + .addComponents( + new ButtonBuilder() + .setCustomId( + "crumbSaucerBack" + ) + .setLabel( + "Back to Crumb Saucer" + ) + .setEmoji( + "◀️" + ) + .setStyle( + ButtonStyle.Secondary + ) + ) + ]; +} + + +/* + * ============================================================ + * SECURITY * ============================================================ */ function getVaultSecurityPenalty( vaultCookies ) { - /* - * Logarithmic scaling means the vault gets harder - * as it becomes richer without becoming impossible. - * - * Examples approximately: + * Approximately: * * 100 -> 5% * 1,000 -> 7.5% @@ -673,10 +740,11 @@ function getVaultSecurityPenalty( */ return Math.min( - Math.log10( - vaultCookies + 1 - ) * + vaultCookies + + 1 + ) + * 0.025, 0.15 @@ -686,18 +754,16 @@ function getVaultSecurityPenalty( /* * ============================================================ - * FLAVOR TEXT + * PROGRESS TEXT * ============================================================ */ function getHeistProgressText( methodName ) { - switch ( methodName ) { - case "Sneak": return [ "🥷 You slip through the service entrance...", @@ -728,65 +794,73 @@ function getHeistProgressText( } +/* + * ============================================================ + * SUCCESS TEXT + * ============================================================ + */ + function getSuccessText( methodName, user, amount ) { - - const amountText = + const cookies = `**${amount.toLocaleString()} Cookies**`; switch ( methodName ) { - case "Sneak": return ( - `🥷 ${user} slipped through security and quietly escaped with ${amountText}.` + `🥷 ${user} slipped through security and quietly escaped with ${cookies}.` ); case "Hack": return ( - `💻 ${user} convinced the vault terminal that ${amountText} belonged to them.` + `💻 ${user} convinced the vault terminal that ${cookies} belonged to them.` ); case "Smash": return ( - `💥 ${user} smashed through the Cookie Vault and escaped with ${amountText}!` + `💥 ${user} smashed through the Cookie Vault and escaped with ${cookies}!` ); default: return ( - `${user} escaped with ${amountText}.` + `${user} escaped with ${cookies}.` ); } } +/* + * ============================================================ + * FAILURE TEXT + * ============================================================ + */ + function getFailureText( methodName, user, penalty ) { - - const penaltyText = + const cookies = `**${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.` + `The ShaiBot Vault collected a ${cookies} fine.` ].join("\n"); @@ -794,7 +868,7 @@ function getFailureText( return [ `🚨 ${user} entered the password incorrectly one too many times.`, "", - `The ShaiBot Vault collected a ${penaltyText} security fee.` + `The ShaiBot Vault collected a ${cookies} security fee.` ].join("\n"); @@ -802,13 +876,13 @@ function getFailureText( return [ `🚨 ${user} discovered that the vault door is significantly stronger than expected.`, "", - `Repairs cost ${penaltyText}. Naturally, you're paying.` + `Repairs cost ${cookies}. Naturally, you're paying.` ].join("\n"); default: return ( - `The heist failed and cost ${user} ${penaltyText}.` + `The heist failed and cost ${user} ${cookies}.` ); } } @@ -832,5 +906,6 @@ function sleep(ms) { module.exports = { - resolveCookieHeist + resolveCookieHeist, + HEIST_COOLDOWN }; \ No newline at end of file diff --git a/utils/crumbleStrike.js b/utils/crumbleStrike.js new file mode 100644 index 0000000..bd17bca --- /dev/null +++ b/utils/crumbleStrike.js @@ -0,0 +1,1894 @@ +const { + EmbedBuilder, + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + MessageFlags +} = require("discord.js"); + +const { + getCollections, + getCookieBalance, + transferCookies, + temporaryEphemeralReply, + getUnixTime +} = require("./cookieEconomy"); + + +const EVENT_COLLECTION = + "items_events"; + +const BATTLE_DURATION = + 60 * 60; + + +/* + * JOBS + */ + +const JOBS = { + DPS: { + BlackMage: { + name: + "Black Mage", + + class: + "Thaumaturge", + + emoji: + "🔥" + }, + + Dragoon: { + name: + "Dragoon", + + class: + "Lancer", + + emoji: + "🐉" + }, + + Bard: { + name: + "Bard", + + class: + "Archer", + + emoji: + "🏹" + } + }, + + + Tank: { + Paladin: { + name: + "Paladin", + + class: + "Gladiator", + + emoji: + "⚔️" + }, + + Warrior: { + name: + "Warrior", + + class: + "Marauder", + + emoji: + "🪓" + }, + + DarkKnight: { + name: + "Dark Knight", + + class: + null, + + emoji: + "🌑" + } + }, + + + Healer: { + WhiteMage: { + name: + "White Mage", + + class: + "Conjurer", + + emoji: + "✨" + }, + + Scholar: { + name: + "Scholar", + + class: + "Arcanist", + + emoji: + "📖" + }, + + Astrologian: { + name: + "Astrologian", + + class: + null, + + emoji: + "🌟" + } + } +}; + + +/* + * INVESTMENT TIERS + */ + +const TIERS = { + Sprout: { + name: + "Sprout", + + emoji: + "🌱", + + cost: + 100, + + health: + 180, + + power: + 20 + }, + + + Adventurer: { + name: + "Adventurer", + + emoji: + "⚔️", + + cost: + 500, + + health: + 280, + + power: + 35 + }, + + + WarriorOfLight: { + name: + "Warrior of Light", + + emoji: + "✨", + + cost: + 1000, + + health: + 380, + + power: + 50 + } +}; + + +/* + * ROLE SELECTION + */ + +async function chooseCrumbleStrikeRole( + interaction, + role +) { + const event = + await getCrumbleStrikeFromPublicMessage( + interaction + ); + + + if (!event) { + return temporaryEphemeralReply( + interaction, + { + content: + "This Crumble Strike encounter 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." + } + ); + } + + + const participant = + event.participants?.[ + interaction.user.id + ]; + + + if (participant) { + return temporaryEphemeralReply( + interaction, + { + content: + `You're already participating as **${participant.job}**.` + } + ); + } + + + if (!JOBS[role]) { + return temporaryEphemeralReply( + interaction, + { + content: + "That role doesn't exist." + } + ); + } + + + return interaction.reply({ + embeds: [ + buildJobSelectionEmbed( + event, + interaction.user, + role + ) + ], + + components: + buildJobButtons( + role + ), + + flags: + MessageFlags.Ephemeral + }); +} + + +/* + * JOB SELECTION + */ + +async function chooseCrumbleStrikeJob( + interaction, + role, + jobKey +) { + const metadata = + parseSelectionFooter( + interaction + ); + + + if ( + metadata.type !== + "crumbleStrikeJob" || + metadata.userId !== + interaction.user.id || + metadata.role !== + role + ) { + return temporaryEphemeralReply( + interaction, + { + content: + "This character selection is no longer valid." + } + ); + } + + + const job = + JOBS[role]?.[ + jobKey + ]; + + + if (!job) { + return temporaryEphemeralReply( + interaction, + { + content: + "That Job isn't available." + } + ); + } + + + const event = + await getCrumbleStrikeByEventId( + metadata.eventId + ); + + + if ( + !event || + event.state !== + "Joining" || + event.joinUntil <= + getUnixTime() + ) { + return interaction.update({ + embeds: [ + new EmbedBuilder() + .setTitle( + "🍪 Recruitment Closed" + ) + .setDescription( + "This encounter is no longer accepting adventurers." + ) + ], + + components: [] + }); + } + + + return interaction.update({ + embeds: [ + buildTierSelectionEmbed( + event, + interaction.user, + role, + jobKey, + job + ) + ], + + components: + buildTierButtons() + }); +} + + +/* + * CONFIRM JOIN + */ + +async function confirmCrumbleStrikeJoin( + interaction, + tierKey +) { + const metadata = + parseSelectionFooter( + interaction + ); + + + if ( + metadata.type !== + "crumbleStrikeTier" || + metadata.userId !== + interaction.user.id + ) { + return temporaryEphemeralReply( + interaction, + { + content: + "This character selection is no longer valid." + } + ); + } + + + const role = + metadata.role; + + + const jobKey = + metadata.job; + + + const job = + JOBS[role]?.[ + jobKey + ]; + + + const tier = + TIERS[ + tierKey + ]; + + + if ( + !job || + !tier + ) { + return temporaryEphemeralReply( + interaction, + { + content: + "That character setup is invalid." + } + ); + } + + + const { + db + } = + getCollections(); + + + const events = + db.collection( + EVENT_COLLECTION + ); + + + const event = + await events.findOne({ + eventId: + metadata.eventId, + + eventType: + "CrumbleStrike" + }); + + + if ( + !event || + event.state !== + "Joining" || + event.joinUntil <= + getUnixTime() + ) { + return interaction.update({ + embeds: [ + new EmbedBuilder() + .setTitle( + "🍪 Recruitment Closed" + ) + .setDescription( + "The battle can no longer be joined." + ) + ], + + components: [] + }); + } + + + if ( + event.participants?.[ + interaction.user.id + ] + ) { + return interaction.update({ + embeds: [ + new EmbedBuilder() + .setTitle( + "🍪 Already Joined" + ) + .setDescription( + "You're already participating." + ) + ], + + components: [] + }); + } + + + const balance = + await getCookieBalance( + interaction.user.id + ); + + + if ( + balance < + tier.cost + ) { + return temporaryEphemeralReply( + interaction, + { + content: + `You need **${tier.cost.toLocaleString()} Cookies** to join as a ${tier.name}.` + } + ); + } + + + try { + await transferCookies( + interaction.user.id, + event.escrowId, + tier.cost + ); + + } catch (error) { + if ( + error.message === + "INSUFFICIENT_COOKIES" + ) { + return temporaryEphemeralReply( + interaction, + { + content: + "Your Cookie balance changed before you could join." + } + ); + } + + + throw error; + } + + + const now = + getUnixTime(); + + + const participant = { + role, + + job: + job.name, + + class: + job.class, + + tier: + tierKey, + + investment: + tier.cost, + + health: + tier.health, + + maxHealth: + tier.health, + + power: + tier.power, + + energy: + 0, + + maxEnergy: + 100, + + damage: + 0, + + healing: + 0, + + mitigation: + 0, + + defense: { + charges: + 0, + + reduction: + 0 + }, + + knockedOut: + false, + + joinedAt: + now, + + cooldowns: { + Strike: + 0, + + Defend: + 0, + + Heal: + 0 + } + }; + + + const result = + await events.updateOne( + { + eventId: + event.eventId, + + eventType: + "CrumbleStrike", + + state: + "Joining", + + [`participants.${interaction.user.id}`]: { + $exists: + false + } + }, + { + $set: { + [`participants.${interaction.user.id}`]: + participant + }, + + $inc: { + rewardPool: + tier.cost + } + } + ); + + + if ( + result.modifiedCount !== + 1 + ) { + await safeTransfer( + event.escrowId, + interaction.user.id, + tier.cost + ); + + + return interaction.update({ + embeds: [ + new EmbedBuilder() + .setTitle( + "🍪 Unable to Join" + ) + .setDescription( + "Your entry could not be registered. Your Cookies were refunded." + ) + ], + + components: [] + }); + } + + + await refreshCrumbleStrikeMessage( + interaction.client, + event.eventId + ); + + + return interaction.update({ + embeds: [ + new EmbedBuilder() + .setTitle( + "⚔️ You Joined Crumble Strike!" + ) + + .setDescription( + [ + `### ${job.emoji} ${formatJobProgression(job)}`, + "", + `${getRoleEmoji(role)} **${role}**`, + `${tier.emoji} **${tier.name}**`, + "", + `🍪 Investment: **${tier.cost.toLocaleString()}**`, + `❤️ HP: **${tier.health}**`, + `💪 Power: **${tier.power}**`, + `⚡ Energy: **0/100**` + ].join("\n") + ) + ], + + components: [] + }); +} + + +/* + * START BATTLE + */ + +async function startCrumbleStrikeBattle( + interaction +) { + const event = + await getCrumbleStrikeFromPublicMessage( + interaction + ); + + + if (!event) { + return temporaryEphemeralReply( + interaction, + { + content: + "This encounter 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(); + + + return activateCrumbleStrike( + interaction.client, + event.eventId + ); +} + + +/* + * ACTIVATE + */ + +async function activateCrumbleStrike( + client, + eventId +) { + const { + db + } = + getCollections(); + + + const events = + db.collection( + EVENT_COLLECTION + ); + + + const event = + await events.findOne({ + eventId, + + eventType: + "CrumbleStrike" + }); + + + if ( + !event || + event.state !== + "Joining" + ) { + return false; + } + + + const participantCount = + Object.keys( + event.participants ?? + {} + ).length; + + + if ( + participantCount < + event.minimumParticipants + ) { + return false; + } + + + const now = + getUnixTime(); + + + const maxHealth = + 15000 + + ( + participantCount * + 5000 + ); + + + const result = + await events.updateOne( + { + eventId, + + eventType: + "CrumbleStrike", + + state: + "Joining" + }, + { + $set: { + state: + "Active", + + battleStartedAt: + now, + + battleEndsAt: + now + + BATTLE_DURATION, + + "boss.health": + maxHealth, + + "boss.maxHealth": + maxHealth, + + "boss.limitGauge": + 0, + + "boss.actionGauge": + 0 + } + } + ); + + + if ( + result.modifiedCount !== + 1 + ) { + return false; + } + + + await refreshCrumbleStrikeMessage( + client, + eventId + ); + + + return true; +} + + +/* + * FAILURE + */ + +async function failCrumbleStrike( + client, + eventId +) { + const { + db + } = + getCollections(); + + + const events = + db.collection( + EVENT_COLLECTION + ); + + + const event = + await events.findOneAndUpdate( + { + eventId, + + eventType: + "CrumbleStrike", + + state: + "Active" + }, + { + $set: { + state: + "Failed", + + failedAt: + getUnixTime() + } + }, + { + returnDocument: + "after" + } + ); + + + if (!event) + return false; + + + const escrow = + await getCookieBalance( + event.escrowId + ); + + + if ( + escrow > + 0 + ) { + await safeTransfer( + event.escrowId, + client.user.id, + escrow + ); + } + + + await refreshCrumbleStrikeMessage( + client, + eventId + ); + + + return true; +} + + +/* + * CANCEL + REFUND + */ + +async function cancelCrumbleStrikeAndRefund( + client, + eventId +) { + const { + db + } = + getCollections(); + + + const events = + db.collection( + EVENT_COLLECTION + ); + + + const event = + await events.findOneAndUpdate( + { + eventId, + + eventType: + "CrumbleStrike", + + state: + "Joining" + }, + { + $set: { + state: + "Cancelled", + + cancelledAt: + getUnixTime() + } + }, + { + returnDocument: + "after" + } + ); + + + if (!event) + return false; + + + for ( + const [ + userId, + participant + ] + of Object.entries( + event.participants ?? + {} + ) + ) { + if ( + participant.investment > + 0 + ) { + await safeTransfer( + event.escrowId, + userId, + participant.investment + ); + } + } + + + const remaining = + await getCookieBalance( + event.escrowId + ); + + + if ( + remaining > + 0 + ) { + await safeTransfer( + event.escrowId, + event.creatorId, + remaining + ); + } + + + await refreshCrumbleStrikeMessage( + client, + eventId + ); + + + return true; +} + + +/* + * REFRESH PUBLIC MESSAGE + */ + +async function refreshCrumbleStrikeMessage( + client, + eventId +) { + const event = + await getCrumbleStrikeByEventId( + 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 + } = + buildCrumbleStrikeMessage( + event + ); + + + await message.edit({ + embeds: [ + embed + ], + + components + }); +} + + +/* + * PUBLIC MESSAGE + */ + +function buildCrumbleStrikeMessage( + event +) { + const participants = + Object.values( + event.participants ?? + {} + ); + + + const participantCount = + participants.length; + + + if ( + event.state === + "Joining" + ) { + const roleCounts = + getRoleCounts( + participants + ); + + + return { + embed: + new EmbedBuilder() + .setTitle( + "🍪 Crumble Strike" + ) + + .setDescription( + [ + "## The Crumbbound Behemoth", + "", + "*A creature of hunger, crumbs, and extremely questionable dietary choices.*", + "", + `**Recruitment ends:** `, + `**Participants:** ${participantCount}/${event.minimumParticipants} required`, + `**Reward Pool:** ${event.rewardPool.toLocaleString()} 🍪`, + "", + `⚔️ DPS: **${roleCounts.DPS}**`, + `🛡️ Tanks: **${roleCounts.Tank}**`, + `❤️ Healers: **${roleCounts.Healer}**`, + "", + participantCount >= + event.minimumParticipants + ? "✅ The summoner may start the battle early." + : "Choose your role below to join." + ].join("\n") + ) + + .setFooter({ + text: + `crumbleStrike:${event.eventId}` + }), + + components: + buildRecruitmentButtons() + }; + } + + + if ( + event.state === + "Active" + ) { + const hp = + Math.max( + 0, + event.boss.health + ); + + + return { + embed: + new EmbedBuilder() + .setTitle( + "⚔️ Crumble Strike" + ) + + .setDescription( + [ + "## The Crumbbound Behemoth", + "", + buildHpBar( + hp, + event.boss.maxHealth + ), + `**${hp.toLocaleString()} / ${event.boss.maxHealth.toLocaleString()} HP**`, + "", + `✨ Limit Gauge: **${event.boss.limitGauge ?? 0}/100**`, + `👹 Boss Gauge: **${event.boss.actionGauge ?? 0}/10**`, + "", + `**Participants:** ${participantCount}`, + `**Reward Pool:** ${event.rewardPool.toLocaleString()} 🍪`, + `**Battle ends:** `, + "", + "Combat actions are being upgraded next." + ].join("\n") + ) + + .setFooter({ + text: + `crumbleStrike:${event.eventId}` + }), + + components: [] + }; + } + + + if ( + event.state === + "Failed" + ) { + return { + embed: + new EmbedBuilder() + .setTitle( + "💀 Crumble Strike Failed" + ) + + .setDescription( + [ + "The Crumbbound Behemoth survived the encounter.", + "", + `**${event.rewardPool.toLocaleString()} Cookies** were claimed by the ShaiBot Vault.` + ].join("\n") + ) + + .setFooter({ + text: + `crumbleStrike:${event.eventId}` + }), + + components: [] + }; + } + + + if ( + event.state === + "Cancelled" + ) { + return { + embed: + new EmbedBuilder() + .setTitle( + "🍪 Crumble Strike Cancelled" + ) + + .setDescription( + "Not enough adventurers joined. All committed Cookies were refunded." + ) + + .setFooter({ + text: + `crumbleStrike:${event.eventId}` + }), + + components: [] + }; + } + + + return { + embed: + new EmbedBuilder() + .setTitle( + "🍪 Crumble Strike" + ) + + .setDescription( + `Encounter state: **${event.state}**` + ) + + .setFooter({ + text: + `crumbleStrike:${event.eventId}` + }), + + components: [] + }; +} + + +/* + * RECRUITMENT BUTTONS + */ + +function buildRecruitmentButtons() { + return [ + new ActionRowBuilder() + .addComponents( + new ButtonBuilder() + .setCustomId( + "crumbleStrikeRoleDPS" + ) + .setLabel( + "DPS" + ) + .setEmoji( + "⚔️" + ) + .setStyle( + ButtonStyle.Danger + ), + + new ButtonBuilder() + .setCustomId( + "crumbleStrikeRoleTank" + ) + .setLabel( + "Tank" + ) + .setEmoji( + "🛡️" + ) + .setStyle( + ButtonStyle.Primary + ), + + new ButtonBuilder() + .setCustomId( + "crumbleStrikeRoleHealer" + ) + .setLabel( + "Healer" + ) + .setEmoji( + "❤️" + ) + .setStyle( + ButtonStyle.Success + ) + ), + + new ActionRowBuilder() + .addComponents( + new ButtonBuilder() + .setCustomId( + "crumbleStrikeStart" + ) + .setLabel( + "Start Battle" + ) + .setEmoji( + "⚔️" + ) + .setStyle( + ButtonStyle.Secondary + ) + ) + ]; +} + + +/* + * JOB MENU + */ + +function buildJobSelectionEmbed( + event, + user, + role +) { + const jobs = + JOBS[ + role + ]; + + + const description = + Object.values( + jobs + ) + .map( + job => + `${job.emoji} **${formatJobProgression(job)}**` + ) + .join("\n"); + + + return new EmbedBuilder() + .setTitle( + `${getRoleEmoji(role)} Choose Your ${role} Job` + ) + + .setDescription( + [ + description, + "", + "Choose the Job you want to bring into battle." + ].join("\n") + ) + + .setFooter({ + text: + `crumbleStrikeJob:${event.eventId}:${user.id}:${role}` + }); +} + + +function buildJobButtons( + role +) { + return [ + new ActionRowBuilder() + .addComponents( + ...Object.entries( + JOBS[ + role + ] + ) + .map( + ( + [ + key, + job + ] + ) => + new ButtonBuilder() + .setCustomId( + `crumbleStrikeJob${key}` + ) + .setLabel( + job.name + ) + .setEmoji( + job.emoji + ) + .setStyle( + ButtonStyle.Primary + ) + ) + ) + ]; +} + + +/* + * TIER MENU + */ + +function buildTierSelectionEmbed( + event, + user, + role, + jobKey, + job +) { + return new EmbedBuilder() + .setTitle( + `${job.emoji} ${formatJobProgression(job)}` + ) + + .setDescription( + [ + `${getRoleEmoji(role)} **${role}**`, + "", + "Choose your commitment.", + "", + "🌱 **Sprout — 100 🍪**", + "Entry-level HP and Power.", + "", + "⚔️ **Adventurer — 500 🍪**", + "Improved HP and Power.", + "", + "✨ **Warrior of Light — 1,000 🍪**", + "Highest starting HP and Power." + ].join("\n") + ) + + .setFooter({ + text: + `crumbleStrikeTier:${event.eventId}:${user.id}:${role}:${jobKey}` + }); +} + + +function buildTierButtons() { + return [ + new ActionRowBuilder() + .addComponents( + new ButtonBuilder() + .setCustomId( + "crumbleStrikeTierSprout" + ) + .setLabel( + "Sprout — 100" + ) + .setEmoji( + "🌱" + ) + .setStyle( + ButtonStyle.Secondary + ), + + new ButtonBuilder() + .setCustomId( + "crumbleStrikeTierAdventurer" + ) + .setLabel( + "Adventurer — 500" + ) + .setEmoji( + "⚔️" + ) + .setStyle( + ButtonStyle.Primary + ), + + new ButtonBuilder() + .setCustomId( + "crumbleStrikeTierWarriorOfLight" + ) + .setLabel( + "Warrior of Light — 1,000" + ) + .setEmoji( + "✨" + ) + .setStyle( + ButtonStyle.Success + ) + ) + ]; +} + + +/* + * LOOKUPS + */ + +async function getCrumbleStrikeFromPublicMessage( + interaction +) { + const footer = + interaction.message + .embeds[0] + ?.footer + ?.text; + + + const [ + type, + eventId + ] = + footer?.split(":") + ?? []; + + + if ( + type !== + "crumbleStrike" || + !eventId + ) { + return null; + } + + + return getCrumbleStrikeByEventId( + eventId + ); +} + + +async function getCrumbleStrikeByEventId( + eventId +) { + const { + db + } = + getCollections(); + + + return db + .collection( + EVENT_COLLECTION + ) + .findOne({ + eventId, + + eventType: + "CrumbleStrike" + }); +} + + +function parseSelectionFooter( + interaction +) { + const footer = + interaction.message + .embeds[0] + ?.footer + ?.text; + + + const parts = + footer?.split(":") + ?? []; + + + if ( + parts[0] === + "crumbleStrikeJob" + ) { + return { + type: + parts[0], + + eventId: + parts[1], + + userId: + parts[2], + + role: + parts[3] + }; + } + + + if ( + parts[0] === + "crumbleStrikeTier" + ) { + return { + type: + parts[0], + + eventId: + parts[1], + + userId: + parts[2], + + role: + parts[3], + + job: + parts[4] + }; + } + + + return {}; +} + + +/* + * DISPLAY + */ + +function formatJobProgression( + job +) { + if ( + job.class + ) { + return ( + `${job.class} → ${job.name}` + ); + } + + + return job.name; +} + + +function getRoleEmoji( + role +) { + switch (role) { + case "DPS": + return "⚔️"; + + case "Tank": + return "🛡️"; + + case "Healer": + return "❤️"; + + default: + return "🍪"; + } +} + + +function getRoleCounts( + participants +) { + const counts = { + DPS: 0, + Tank: 0, + Healer: 0 + }; + + + for ( + const participant + of participants + ) { + if ( + counts[ + participant.role + ] + !== + undefined + ) { + counts[ + participant.role + ]++; + } + } + + + return counts; +} + + +function buildHpBar( + current, + max, + size = 20 +) { + if ( + max <= + 0 + ) { + return "░".repeat( + size + ); + } + + + const ratio = + Math.max( + 0, + + Math.min( + 1, + current / + max + ) + ); + + + const filled = + Math.round( + ratio * + size + ); + + + return ( + "█".repeat( + filled + ) + + + "░".repeat( + size - + filled + ) + ); +} + + +/* + * SAFE COOKIE TRANSFER + */ + +async function safeTransfer( + from, + to, + amount +) { + try { + await transferCookies( + from, + to, + amount + ); + + + return true; + + } catch (error) { + console.error( + `Crumble Strike Cookie transfer failed (${from} -> ${to}, ${amount}):`, + error + ); + + + return false; + } +} + + +module.exports = { + JOBS, + TIERS, + + chooseCrumbleStrikeRole, + chooseCrumbleStrikeJob, + confirmCrumbleStrikeJoin, + + startCrumbleStrikeBattle, + activateCrumbleStrike, + + failCrumbleStrike, + cancelCrumbleStrikeAndRefund, + + refreshCrumbleStrikeMessage +}; \ No newline at end of file