const { EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle, MessageFlags } = require("discord.js"); const { getCollections, getCookieBalance, transferCookies, temporaryEphemeralReply, temporaryEphemeralFollowUp, getUnixTime, randomInteger } = require("./cookieEconomy"); const EVENT_COLLECTION = "items_events"; /* * ============================================================ * GENERAL CONFIG * ============================================================ */ const BATTLE_DURATION = 60 * 60; const BOSS_ACTION_THRESHOLD = 10; const LIMIT_GAUGE_MAX = 100; /* * ============================================================ * ACTION CONFIG * ============================================================ * * Roles / Jobs can override these values later. */ const ACTIONS = { Strike: { cooldown: 8, energyGain: 12, limitGain: 5, powerMultiplier: 1.0 }, Defend: { cooldown: 20, energyCost: 5, limitGain: 2, reduction: 0.25, charges: 2 }, Heal: { cooldown: 25, energyCost: 10, limitGain: 3, powerMultiplier: 1.0 } }; /* * ============================================================ * JOBS * ============================================================ * * Every job uses the same buttons. * * Job modifiers simply make certain actions * more efficient. */ const JOBS = { DPS: { BlackMage: { name: "Black Mage", class: "Thaumaturge", emoji: "🔥", strikeMultiplier: 1.40, strikeEnergy: 10, strikeLimit: 5 }, Dragoon: { name: "Dragoon", class: "Lancer", emoji: "🐉", strikeMultiplier: 1.30, strikeEnergy: 12, strikeLimit: 7 }, Bard: { name: "Bard", class: "Archer", emoji: "🏹", strikeMultiplier: 1.15, strikeEnergy: 14, strikeLimit: 9 } }, Tank: { Paladin: { name: "Paladin", class: "Gladiator", emoji: "⚔️", strikeMultiplier: 0.90, defendCost: 2, defendReduction: 0.55, defendCharges: 3 }, Warrior: { name: "Warrior", class: "Marauder", emoji: "🪓", strikeMultiplier: 1.05, defendCost: 3, defendReduction: 0.45, defendCharges: 2 }, DarkKnight: { name: "Dark Knight", class: null, emoji: "🌑", strikeMultiplier: 1.10, defendCost: 3, defendReduction: 0.50, defendCharges: 2 } }, Healer: { WhiteMage: { name: "White Mage", class: "Conjurer", emoji: "✨", strikeMultiplier: 0.75, healCost: 5, healMultiplier: 1.55, healLimit: 3 }, Scholar: { name: "Scholar", class: "Arcanist", emoji: "📖", strikeMultiplier: 0.80, healCost: 6, healMultiplier: 1.35, healLimit: 4 }, Astrologian: { name: "Astrologian", class: null, emoji: "🌟", strikeMultiplier: 0.80, healCost: 6, healMultiplier: 1.25, healLimit: 6 } } }; /* * ============================================================ * 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 * ============================================================ * * Joining is allowed both during recruitment * and during an active battle. */ async function chooseCrumbleStrikeRole( interaction, role ) { const event = await getCrumbleStrikeFromPublicMessage( interaction ); if (!event) { return temporaryEphemeralReply( interaction, { content: "This Crumble Strike encounter no longer exists." } ); } if ( ![ "Joining", "Active" ].includes( event.state ) ) { return temporaryEphemeralReply( interaction, { content: "This encounter can no longer be joined." } ); } /* * Recruitment timer only matters before * combat has started. */ if ( event.state === "Joining" && 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 || ![ "Joining", "Active" ].includes( event.state ) ) { return interaction.update({ embeds: [ new EmbedBuilder() .setTitle( "🍪 Entry 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 || ![ "Joining", "Active" ].includes( event.state ) ) { return interaction.update({ embeds: [ new EmbedBuilder() .setTitle( "🍪 Entry 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, jobKey, 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: { $in: [ "Joining", "Active" ] }, [`participants.${interaction.user.id}`]: { $exists: false } }, { $set: { [`participants.${interaction.user.id}`]: participant }, $inc: { rewardPool: tier.cost } } ); /* * Race-condition protection. */ 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 ); const lateJoin = event.state === "Active"; return interaction.update({ embeds: [ new EmbedBuilder() .setTitle( lateJoin ? "⚔️ Reinforcements Arrive!" : "⚔️ 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**`, "", lateJoin ? "You've joined the battle already in progress." : "You're ready for battle." ].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 BATTLE * ============================================================ */ 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(); /* * HP is locked when the battle begins. * * Late joiners do NOT increase boss HP. */ const maxHealth = 12000 + ( participantCount * 4000 ); 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; } /* * ============================================================ * COMBAT ACTION * ============================================================ */ async function performRevive( interaction, event, player ) { const now = getUnixTime(); const cooldown = player.cooldowns?.Revive ?? 0; if ( cooldown > now ) { return temporaryEphemeralReply( interaction, { content: `🌟 Revive is ready .` } ); } const knockedOutPlayers = Object.entries( event.participants ?? {} ) .filter( ( [ , participant ] ) => participant.knockedOut ); if ( !knockedOutPlayers.length ) { return temporaryEphemeralReply( interaction, { content: "🌟 Nobody currently needs reviving." } ); } const revivePower = player.role === "Healer" ? 2 : 1; await interaction.deferUpdate(); const { db } = getCollections(); const events = db.collection( EVENT_COLLECTION ); /* * Re-fetch immediately before applying * the party-wide revival pulse. */ const latest = await events.findOne({ eventId: event.eventId, eventType: "CrumbleStrike", state: "Active" }); if (!latest) { return; } const setOperations = { [`participants.${interaction.user.id}.cooldowns.Revive`]: now + ACTIONS.Revive.cooldown }; const incOperations = { "boss.actionGauge": 1, "boss.limitGauge": ACTIONS.Revive.limitGain, [`participants.${interaction.user.id}.reviveAssists`]: 0 }; let revivedCount = 0; let assistedCount = 0; let totalProgress = 0; const resultLines = []; for ( const [ targetId, target ] of Object.entries( latest.participants ?? {} ) ) { if ( !target.knockedOut ) { continue; } const currentProgress = target.revive?.progress ?? 0; const appliedProgress = Math.min( revivePower, REVIVE_REQUIRED - currentProgress ); const newProgress = Math.min( REVIVE_REQUIRED, currentProgress + revivePower ); totalProgress += appliedProgress; /* * Completed revival. */ if ( newProgress >= REVIVE_REQUIRED ) { const revivePercent = REVIVE_HP[ target.role ] ?? 0.75; const restoredHealth = Math.max( 1, Math.floor( target.maxHealth * revivePercent ) ); setOperations[ `participants.${targetId}.health` ] = restoredHealth; setOperations[ `participants.${targetId}.knockedOut` ] = false; setOperations[ `participants.${targetId}.energy` ] = 0; setOperations[ `participants.${targetId}.defense` ] = { charges: 0, reduction: 0 }; setOperations[ `participants.${targetId}.revive.progress` ] = 0; revivedCount++; resultLines.push( `🌟 <@${targetId}> — **REVIVED** (${restoredHealth}/${target.maxHealth} HP)` ); } /* * Still needs more revival progress. */ else { setOperations[ `participants.${targetId}.revive.progress` ] = newProgress; assistedCount++; resultLines.push( `💫 <@${targetId}> — **${newProgress}/${REVIVE_REQUIRED}**` ); } } /* * Reward contribution according to the * actual amount of progress applied. */ incOperations[ `participants.${interaction.user.id}.reviveAssists` ] = totalProgress; if ( revivedCount > 0 ) { incOperations[ `participants.${interaction.user.id}.revives` ] = revivedCount; } const updateResult = await events.updateOne( { eventId: event.eventId, eventType: "CrumbleStrike", state: "Active", [`participants.${interaction.user.id}.knockedOut`]: false, [`participants.${interaction.user.id}.cooldowns.Revive`]: { $lte: now } }, { $set: setOperations, $inc: incOperations } ); if ( updateResult.modifiedCount !== 1 ) { return temporaryEphemeralFollowUp( interaction, { content: "That revival action could not be resolved." } ); } await clampBossGauges( event.eventId ); const bossAttack = await resolveBossTurnIfReady( interaction.client, event.eventId ); await refreshCrumbleStrikeMessage( interaction.client, event.eventId ); const embed = new EmbedBuilder() .setTitle( "🌟 Party Revive" ) .setDescription( [ `${getJobEmoji(player)} **${player.job}** sends out a wave of revival energy.`, "", ...resultLines ].join("\n") ) .addFields( { name: "Revival Power", value: `**+${revivePower}** per KO'd player`, inline: true }, { name: "Revived", value: `**${revivedCount}**`, inline: true }, { name: "Progress Applied", value: `**${totalProgress}**`, inline: true } ); if (bossAttack) { embed.addFields({ name: "👹 Boss Turn", value: bossAttack.summary }); } return temporaryEphemeralFollowUp( interaction, { embeds: [ embed ] }, 9000 ); } async function performCrumbleStrikeAction( interaction, actionName ) { const event = await getCrumbleStrikeFromPublicMessage( interaction ); if ( !event || event.state !== "Active" ) { return temporaryEphemeralReply( interaction, { content: "There is no active battle here." } ); } const player = event.participants?.[ interaction.user.id ]; if (!player) { return temporaryEphemeralReply( interaction, { content: "You haven't joined this Crumble Strike yet." } ); } if (player.knockedOut) { return temporaryEphemeralReply( interaction, { content: "💀 You're knocked out and can no longer act." } ); } switch (actionName) { case "Strike": return performStrike( interaction, event, player ); case "Defend": return performDefend( interaction, event, player ); case "Heal": return performHeal( interaction, event, player ); case "Revive": return performRevive( interaction, event, player ); case "LimitBreak": return performLimitBreak( interaction, event, player ); } } /* * ============================================================ * STRIKE * ============================================================ */ async function performStrike( interaction, event, player ) { const now = getUnixTime(); const cooldown = player.cooldowns?.Strike ?? 0; if ( cooldown > now ) { return temporaryEphemeralReply( interaction, { content: `⚔️ Strike is ready .` } ); } const job = getParticipantJob( player ); const multiplier = job?.strikeMultiplier ?? ACTIONS.Strike.powerMultiplier; const variance = randomInteger( 90, 110 ) / 100; const damage = Math.max( 1, Math.floor( player.power * multiplier * variance ) ); const energyGain = job?.strikeEnergy ?? ACTIONS.Strike.energyGain; const limitGain = job?.strikeLimit ?? ACTIONS.Strike.limitGain; const newEnergy = Math.min( player.maxEnergy, player.energy + energyGain ); await interaction.deferUpdate(); const { db } = getCollections(); const events = db.collection( EVENT_COLLECTION ); const result = await events.updateOne( { eventId: event.eventId, state: "Active", "boss.health": { $gt: 0 }, [`participants.${interaction.user.id}.cooldowns.Strike`]: { $lte: now } }, { $inc: { "boss.health": -damage, "boss.actionGauge": 1, "boss.limitGauge": limitGain, [`participants.${interaction.user.id}.damage`]: damage }, $set: { [`participants.${interaction.user.id}.energy`]: newEnergy, [`participants.${interaction.user.id}.cooldowns.Strike`]: now + ACTIONS.Strike.cooldown } } ); if ( result.modifiedCount !== 1 ) { return temporaryEphemeralFollowUp( interaction, { content: "That action could not be resolved." } ); } await clampBossGauges( event.eventId ); const bossAttack = await resolveBossTurnIfReady( interaction.client, event.eventId ); const victory = await checkForVictory( interaction.client, event.eventId ); if (!victory) { await refreshCrumbleStrikeMessage( interaction.client, event.eventId ); } const resultEmbed = new EmbedBuilder() .setTitle( "⚔️ Strike" ) .setDescription( `${getJobEmoji(player)} **${player.job}** attacks the Crumbbound Behemoth!` ) .addFields( { name: "Damage", value: `**${damage.toLocaleString()}**`, inline: true }, { name: "Energy", value: `**${newEnergy}/${player.maxEnergy}**`, inline: true }, { name: "Limit Generated", value: `**+${limitGain}**`, inline: true } ); if (bossAttack) { resultEmbed.addFields({ name: "👹 Boss Turn", value: bossAttack.summary }); } return temporaryEphemeralFollowUp( interaction, { embeds: [ resultEmbed ] }, 8000 ); } /* * ============================================================ * DEFEND * ============================================================ */ async function performDefend( interaction, event, player ) { const now = getUnixTime(); const cooldown = player.cooldowns?.Defend ?? 0; if ( cooldown > now ) { return temporaryEphemeralReply( interaction, { content: `🛡️ Defend is ready .` } ); } const job = getParticipantJob( player ); const energyCost = job?.defendCost ?? ACTIONS.Defend.energyCost; const reduction = job?.defendReduction ?? ACTIONS.Defend.reduction; const charges = job?.defendCharges ?? ACTIONS.Defend.charges; if ( player.energy < energyCost ) { return temporaryEphemeralReply( interaction, { content: `🛡️ Defend requires **${energyCost} Energy**. You currently have **${player.energy}**.` } ); } await interaction.deferUpdate(); const { db } = getCollections(); const events = db.collection( EVENT_COLLECTION ); const result = await events.updateOne( { eventId: event.eventId, state: "Active", [`participants.${interaction.user.id}.energy`]: { $gte: energyCost }, [`participants.${interaction.user.id}.cooldowns.Defend`]: { $lte: now } }, { $inc: { "boss.actionGauge": 1, "boss.limitGauge": ACTIONS.Defend.limitGain, [`participants.${interaction.user.id}.energy`]: -energyCost }, $set: { [`participants.${interaction.user.id}.defense`]: { charges, reduction }, [`participants.${interaction.user.id}.cooldowns.Defend`]: now + ACTIONS.Defend.cooldown } } ); if ( result.modifiedCount !== 1 ) { return temporaryEphemeralFollowUp( interaction, { content: "That defensive action could not be resolved." } ); } await clampBossGauges( event.eventId ); const bossAttack = await resolveBossTurnIfReady( interaction.client, event.eventId ); await refreshCrumbleStrikeMessage( interaction.client, event.eventId ); const energyAfter = player.energy - energyCost; const resultEmbed = new EmbedBuilder() .setTitle( "🛡️ Defend" ) .setDescription( `${getJobEmoji(player)} **${player.job}** prepares for the next attack.` ) .addFields( { name: "Damage Reduction", value: `**${Math.round(reduction * 100)}%**`, inline: true }, { name: "Charges", value: `**${charges}** incoming attack${charges === 1 ? "" : "s"}`, inline: true }, { name: "Energy", value: `**${energyAfter}/${player.maxEnergy}**`, inline: true } ); if (bossAttack) { resultEmbed.addFields({ name: "👹 Boss Turn", value: bossAttack.summary }); } return temporaryEphemeralFollowUp( interaction, { embeds: [ resultEmbed ] }, 8000 ); } /* * ============================================================ * PARTY HEAL * ============================================================ */ async function performHeal( interaction, event, player ) { const now = getUnixTime(); const cooldown = player.cooldowns?.Heal ?? 0; if ( cooldown > now ) { return temporaryEphemeralReply( interaction, { content: `❤️ Heal is ready .` } ); } const job = getParticipantJob( player ); const energyCost = job?.healCost ?? ACTIONS.Heal.energyCost; const multiplier = job?.healMultiplier ?? ACTIONS.Heal.powerMultiplier; const limitGain = job?.healLimit ?? ACTIONS.Heal.limitGain; if ( player.energy < energyCost ) { return temporaryEphemeralReply( interaction, { content: `❤️ Heal requires **${energyCost} Energy**. You currently have **${player.energy}**.` } ); } const healPerPlayer = Math.max( 1, Math.floor( player.power * multiplier ) ); await interaction.deferUpdate(); const { db } = getCollections(); const events = db.collection( EVENT_COLLECTION ); /* * Re-fetch immediately before healing. */ const latest = await events.findOne({ eventId: event.eventId, state: "Active" }); if (!latest) { return; } let totalHealing = 0; let healedPlayers = 0; const setOperations = {}; for ( const [ userId, target ] of Object.entries( latest.participants ?? {} ) ) { /* * Heal does not revive KO'd players. */ if ( target.knockedOut || target.health <= 0 || target.health >= target.maxHealth ) { continue; } const newHealth = Math.min( target.maxHealth, target.health + healPerPlayer ); const actualHealing = newHealth - target.health; if ( actualHealing <= 0 ) { continue; } setOperations[ `participants.${userId}.health` ] = newHealth; totalHealing += actualHealing; healedPlayers++; } setOperations[ `participants.${interaction.user.id}.cooldowns.Heal` ] = now + ACTIONS.Heal.cooldown; const result = await events.updateOne( { eventId: event.eventId, state: "Active", [`participants.${interaction.user.id}.energy`]: { $gte: energyCost } }, { $set: setOperations, $inc: { "boss.actionGauge": 1, "boss.limitGauge": limitGain, [`participants.${interaction.user.id}.energy`]: -energyCost, [`participants.${interaction.user.id}.healing`]: totalHealing } } ); if ( result.modifiedCount !== 1 ) { return temporaryEphemeralFollowUp( interaction, { content: "That heal could not be resolved." } ); } await clampBossGauges( event.eventId ); const bossAttack = await resolveBossTurnIfReady( interaction.client, event.eventId ); await refreshCrumbleStrikeMessage( interaction.client, event.eventId ); const resultEmbed = new EmbedBuilder() .setTitle( "❤️ Party Heal" ) .setDescription( `${getJobEmoji(player)} **${player.job}** restores the party.` ) .addFields( { name: "Healing", value: `**${totalHealing.toLocaleString()} HP**`, inline: true }, { name: "Players Restored", value: `**${healedPlayers}**`, inline: true }, { name: "Energy", value: `**${player.energy - energyCost}/${player.maxEnergy}**`, inline: true }, { name: "Limit Generated", value: `**+${limitGain}**`, inline: true } ); if (bossAttack) { resultEmbed.addFields({ name: "👹 Boss Turn", value: bossAttack.summary }); } return temporaryEphemeralFollowUp( interaction, { embeds: [ resultEmbed ] }, 8000 ); } /* * ============================================================ * LIMIT BREAK * ============================================================ */ async function performLimitBreak( interaction, event, player ) { if ( event.boss.limitGauge < LIMIT_GAUGE_MAX ) { return temporaryEphemeralReply( interaction, { content: `✨ The Limit Gauge is only **${event.boss.limitGauge}/${LIMIT_GAUGE_MAX}**.` } ); } const livingPlayers = Object.values( event.participants ?? {} ) .filter( participant => !participant.knockedOut ); const totalPartyPower = livingPlayers.reduce( ( total, participant ) => total + participant.power, 0 ); /* * Huge scaling hit: * * 8% boss max HP * + * 8x total living party Power */ const damage = Math.floor( ( event.boss.maxHealth * 0.08 ) + ( totalPartyPower * 8 ) ); await interaction.deferUpdate(); const { db } = getCollections(); const events = db.collection( EVENT_COLLECTION ); /* * Gauge check is repeated atomically. * * Only one player can successfully spend it. */ const result = await events.updateOne( { eventId: event.eventId, state: "Active", "boss.limitGauge": { $gte: LIMIT_GAUGE_MAX }, "boss.health": { $gt: 0 } }, { $inc: { "boss.health": -damage, "boss.actionGauge": 1, [`participants.${interaction.user.id}.damage`]: damage }, $set: { "boss.limitGauge": 0 } } ); if ( result.modifiedCount !== 1 ) { return temporaryEphemeralFollowUp( interaction, { content: "Someone else used the Limit Break first!" } ); } const bossAttack = await resolveBossTurnIfReady( interaction.client, event.eventId ); const victory = await checkForVictory( interaction.client, event.eventId ); if (!victory) { await refreshCrumbleStrikeMessage( interaction.client, event.eventId ); } const resultEmbed = new EmbedBuilder() .setTitle( "✨ LIMIT BREAK!" ) .setDescription( [ `${getJobEmoji(player)} **${player.job}** unleashes`, "", "## ULTIMATE COOKIE CALAMITY", "", `💥 **${damage.toLocaleString()} DAMAGE!**` ].join("\n") ); if (bossAttack) { resultEmbed.addFields({ name: "👹 Boss Turn", value: bossAttack.summary }); } return temporaryEphemeralFollowUp( interaction, { embeds: [ resultEmbed ] }, 10000 ); } /* * ============================================================ * BOSS TURN * ============================================================ */ async function resolveBossTurnIfReady( client, eventId ) { const { db } = getCollections(); const events = db.collection( EVENT_COLLECTION ); const event = await events.findOne({ eventId, eventType: "CrumbleStrike", state: "Active", "boss.actionGauge": { $gte: BOSS_ACTION_THRESHOLD } }); if (!event) { return null; } /* * Claim the boss turn. */ const claim = await events.updateOne( { eventId, state: "Active", "boss.actionGauge": { $gte: BOSS_ACTION_THRESHOLD } }, { $inc: { "boss.actionGauge": -BOSS_ACTION_THRESHOLD } } ); if ( claim.modifiedCount !== 1 ) { return null; } const latest = await events.findOne({ eventId }); const setOperations = {}; const incOperations = {}; let totalDamage = 0; let totalMitigated = 0; let knockedOut = 0; const baseDamage = randomInteger( 45, 75 ); for ( const [ userId, participant ] of Object.entries( latest.participants ?? {} ) ) { if ( participant.knockedOut ) { continue; } let damage = baseDamage; const defense = participant.defense ?? { charges: 0, reduction: 0 }; if ( defense.charges > 0 ) { const reduced = Math.floor( damage * defense.reduction ); damage -= reduced; totalMitigated += reduced; incOperations[ `participants.${userId}.mitigation` ] = reduced; setOperations[ `participants.${userId}.defense.charges` ] = Math.max( 0, defense.charges - 1 ); } const health = Math.max( 0, participant.health - damage ); setOperations[ `participants.${userId}.health` ] = health; if ( health <= 0 ) { setOperations[ `participants.${userId}.knockedOut` ] = true; knockedOut++; } totalDamage += damage; } const update = { $set: setOperations }; if ( Object.keys( incOperations ).length ) { update.$inc = incOperations; } await events.updateOne( { eventId, state: "Active" }, update ); /* * If everybody is KO'd we don't immediately * fail the whole event. * * Late reinforcements may still join. */ return { damage: totalDamage, mitigation: totalMitigated, knockedOut, summary: [ `💥 **Crumbquake!**`, `Party Damage: **${totalDamage.toLocaleString()}**`, totalMitigated > 0 ? `Mitigated: **${totalMitigated.toLocaleString()}**` : null, knockedOut > 0 ? `💀 Knocked Out: **${knockedOut}**` : null ] .filter(Boolean) .join("\n") }; } /* * ============================================================ * LIMIT GAUGE CLAMP * ============================================================ */ async function clampBossGauges( eventId ) { const { db } = getCollections(); const events = db.collection( EVENT_COLLECTION ); await events.updateOne( { eventId, "boss.limitGauge": { $gt: LIMIT_GAUGE_MAX } }, { $set: { "boss.limitGauge": LIMIT_GAUGE_MAX } } ); } /* * ============================================================ * VICTORY CHECK * ============================================================ */ async function checkForVictory( client, eventId ) { const { db } = getCollections(); const events = db.collection( EVENT_COLLECTION ); const event = await events.findOne({ eventId, eventType: "CrumbleStrike", state: "Active" }); if ( !event || event.boss.health > 0 ) { return false; } await events.updateOne( { eventId, state: "Active" }, { $set: { "boss.health": 0 } } ); return resolveCrumbleStrikeVictory( client, eventId ); } /* * ============================================================ * VICTORY + REWARDS * ============================================================ */ async function resolveCrumbleStrikeVictory( client, eventId ) { const { db } = getCollections(); const events = db.collection( EVENT_COLLECTION ); /* * Atomically claim resolution. */ const event = await events.findOneAndUpdate( { eventId, eventType: "CrumbleStrike", state: "Active" }, { $set: { state: "Resolving", "boss.health": 0 } }, { returnDocument: "after" } ); if (!event) { return false; } const participants = Object.entries( event.participants ?? {} ); const rewardPool = await getCookieBalance( event.escrowId ); /* * 30% evenly distributed. * 70% based on combat contribution. */ const participationPool = Math.floor( rewardPool * 0.30 ); const contributionPool = rewardPool - participationPool; const evenShare = participants.length ? Math.floor( participationPool / participants.length ) : 0; const scored = participants.map( ( [ userId, participant ] ) => { const contribution = getContributionScore( participant ); return { userId, participant, contribution }; } ); const totalContribution = scored.reduce( ( total, player ) => total + player.contribution, 0 ); const rewards = []; let distributed = 0; for ( const player of scored ) { const contributionReward = totalContribution > 0 ? Math.floor( contributionPool * ( player.contribution / totalContribution ) ) : 0; const reward = evenShare + contributionReward; if ( reward > 0 ) { const paid = await safeTransfer( event.escrowId, player.userId, reward ); if (paid) { distributed += reward; } } rewards.push({ userId: player.userId, job: player.participant.job, role: player.participant.role, damage: player.participant.damage ?? 0, healing: player.participant.healing ?? 0, mitigation: player.participant.mitigation ?? 0, contribution: player.contribution, reward }); } /* * Rounding remainder goes to highest * contribution. */ const remaining = await getCookieBalance( event.escrowId ); if ( remaining > 0 && rewards.length ) { const top = rewards .slice() .sort( ( a, b ) => b.contribution - a.contribution )[0]; const paid = await safeTransfer( event.escrowId, top.userId, remaining ); if (paid) { top.reward += remaining; } } await events.updateOne( { eventId, state: "Resolving" }, { $set: { state: "Defeated", defeatedAt: getUnixTime(), rewards } } ); await refreshCrumbleStrikeMessage( client, eventId ); return true; } function getContributionScore( participant ) { return Math.floor( ( participant.damage ?? 0 ) + ( ( participant.healing ?? 0 ) * 0.80 ) + ( ( participant.mitigation ?? 0 ) * 0.80 ) ); } /* * ============================================================ * 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; } /* * ============================================================ * PUBLIC MESSAGE REFRESH * ============================================================ */ 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 EMBED * ============================================================ */ function buildCrumbleStrikeMessage( event ) { const participants = Object.values( event.participants ?? {} ); const participantCount = participants.length; const roleCounts = getRoleCounts( participants ); /* * RECRUITMENT */ if ( event.state === "Joining" ) { 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() }; } /* * ACTIVE */ if ( event.state === "Active" ) { const hp = Math.max( 0, event.boss.health ); const limit = Math.min( LIMIT_GAUGE_MAX, event.boss.limitGauge ?? 0 ); const bossGauge = event.boss.actionGauge ?? 0; return { embed: new EmbedBuilder() .setTitle( "⚔️ Crumble Strike" ) .setDescription( [ "## The Crumbbound Behemoth", "", buildBar( hp, event.boss.maxHealth, 20 ), `**${hp.toLocaleString()} / ${event.boss.maxHealth.toLocaleString()} HP**`, "", "### ✨ Limit Break", buildBar( limit, LIMIT_GAUGE_MAX, 20 ), `**${limit}/${LIMIT_GAUGE_MAX}**`, "", `👹 **Boss Turn:** ${bossGauge}/${BOSS_ACTION_THRESHOLD}`, "", `**Participants:** ${participantCount}`, `⚔️ ${roleCounts.DPS} DPS • 🛡️ ${roleCounts.Tank} Tanks • ❤️ ${roleCounts.Healer} Healers`, `**Reward Pool:** ${event.rewardPool.toLocaleString()} 🍪`, `**Battle ends:** `, "", "### ⚔️ Battle Actions", "**⚔️ Strike**", "Low damage • Generates Energy and Limit Gauge • 8s cooldown", "", "**🛡️ Defend**", "Reduces damage from upcoming boss attacks • Base cost: 5 Energy", "", "**❤️ Heal**", "Restores HP to all living party members • Base cost: 10 Energy", "", "**✨ Limit Break**", "Massive shared attack • Requires a full 100-point Limit Gauge", "", "*Your Job modifies how effective these actions are.*" ].join("\n") ) .setFooter({ text: `crumbleStrike:${event.eventId}` }), /* * Combat + late joining. */ components: [ ...buildCombatButtons(), ...buildLateJoinButtons() ] }; } /* * DEFEATED */ if ( event.state === "Defeated" ) { const rewards = ( event.rewards ?? [] ) .slice() .sort( ( a, b ) => b.contribution - a.contribution ); const leaderboard = rewards .slice( 0, 10 ) .map( ( player, index ) => [ `**${index + 1}.** <@${player.userId}> — ${player.job}`, `⚔️ ${player.damage.toLocaleString()} • ❤️ ${player.healing.toLocaleString()} • 🛡️ ${player.mitigation.toLocaleString()}`, `🍪 **${player.reward.toLocaleString()}**` ].join("\n") ) .join("\n\n"); return { embed: new EmbedBuilder() .setTitle( "🏆 Crumble Strike Complete!" ) .setDescription( [ "## The Crumbbound Behemoth Has Fallen!", "", leaderboard || "The beast was defeated." ].join("\n") ) .setFooter({ text: `crumbleStrike:${event.eventId}` }), components: [] }; } /* * FAILED */ if ( event.state === "Failed" ) { return { embed: new EmbedBuilder() .setTitle( "💀 Crumble Strike Failed" ) .setDescription( [ "The Crumbbound Behemoth survived.", "", `The committed **${event.rewardPool.toLocaleString()} Cookies** were claimed by the ShaiBot Vault.` ].join("\n") ) .setFooter({ text: `crumbleStrike:${event.eventId}` }), components: [] }; } /* * CANCELLED */ if ( event.state === "Cancelled" ) { return { embed: new EmbedBuilder() .setTitle( "🍪 Crumble Strike Cancelled" ) .setDescription( "Not enough adventurers joined during recruitment. All committed Cookies were refunded." ) .setFooter({ text: `crumbleStrike:${event.eventId}` }), components: [] }; } return { embed: new EmbedBuilder() .setTitle( "🍪 Crumble Strike" ) .setDescription( `Encounter state: **${event.state}**` ), components: [] }; } /* * ============================================================ * COMBAT BUTTONS * ============================================================ */ function buildCombatButtons() { return [ new ActionRowBuilder() .addComponents( new ButtonBuilder() .setCustomId( "crumbleStrikeStrike" ) .setLabel( "Strike" ) .setEmoji( "⚔️" ) .setStyle( ButtonStyle.Danger ), new ButtonBuilder() .setCustomId( "crumbleStrikeDefend" ) .setLabel( "Defend" ) .setEmoji( "🛡️" ) .setStyle( ButtonStyle.Primary ), new ButtonBuilder() .setCustomId( "crumbleStrikeHeal" ) .setLabel( "Heal" ) .setEmoji( "❤️" ) .setStyle( ButtonStyle.Success ), new ButtonBuilder() .setCustomId( "crumbleStrikeLimitBreak" ) .setLabel( "Limit Break" ) .setEmoji( "✨" ) .setStyle( ButtonStyle.Secondary ) ) ]; } /* * ============================================================ * LATE JOIN BUTTONS * ============================================================ */ function buildLateJoinButtons() { return [ new ActionRowBuilder() .addComponents( new ButtonBuilder() .setCustomId( "crumbleStrikeRoleDPS" ) .setLabel( "Join DPS" ) .setEmoji( "⚔️" ) .setStyle( ButtonStyle.Danger ), new ButtonBuilder() .setCustomId( "crumbleStrikeRoleTank" ) .setLabel( "Join Tank" ) .setEmoji( "🛡️" ) .setStyle( ButtonStyle.Primary ), new ButtonBuilder() .setCustomId( "crumbleStrikeRoleHealer" ) .setLabel( "Join Healer" ) .setEmoji( "❤️" ) .setStyle( ButtonStyle.Success ) ) ]; } /* * ============================================================ * 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 SELECTION * ============================================================ */ 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 SELECTION * ============================================================ */ 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 ) ) ]; } /* * ============================================================ * EVENT 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" }); } /* * ============================================================ * SELECTION FOOTER * ============================================================ */ 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 {}; } /* * ============================================================ * JOB HELPERS * ============================================================ */ function getParticipantJob( participant ) { if ( participant.jobKey && JOBS[ participant.role ]?.[ participant.jobKey ] ) { return JOBS[ participant.role ][ participant.jobKey ]; } /* * Compatibility with participants created * before jobKey was stored. */ return Object.values( JOBS[ participant.role ] ?? {} ) .find( job => job.name === participant.job ) ?? null; } function getJobEmoji( participant ) { return ( getParticipantJob( participant )?.emoji ?? getRoleEmoji( participant.role ) ); } function formatJobProgression( job ) { if ( job.class ) { return ( `${job.class} → ${job.name}` ); } return job.name; } /* * ============================================================ * DISPLAY HELPERS * ============================================================ */ 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 buildBar( 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 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; } } /* * ============================================================ * EXPORTS * ============================================================ */ module.exports = { JOBS, TIERS, chooseCrumbleStrikeRole, chooseCrumbleStrikeJob, confirmCrumbleStrikeJoin, startCrumbleStrikeBattle, activateCrumbleStrike, performCrumbleStrikeAction, resolveCrumbleStrikeVictory, failCrumbleStrike, cancelCrumbleStrikeAndRefund, refreshCrumbleStrikeMessage };