const { EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle, MessageFlags } = require("discord.js"); const { getCollections, getCookieBalance, transferCookies, temporaryEphemeralReply, temporaryEphemeralFollowUp, getUnixTime, randomInteger } = require("../../utils/cookieEconomy"); const EVENT_COLLECTION = "items_events"; /* * ============================================================ * GENERAL CONFIG * ============================================================ */ const BATTLE_DURATION = 60 * 60; const BOSS_ACTION_THRESHOLD = 10; const LIMIT_GAUGE_MAX = 100; const REVIVE_REQUIRED = 3; /* * HP restored when a player is revived. * * This is based on the TARGET'S role. */ const REVIVE_HP = { DPS: 0.65, Tank: 0.75, Healer: 0.85 }; /* * ============================================================ * ACTION CONFIG * ============================================================ */ 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 }, Revive: { cooldown: 12, /* * Revive costs no Energy. * * You're already giving up a combat action * and advancing the Boss Gauge. */ energyCost: 0, limitGain: 2 } }; /* * ============================================================ * JOBS * ============================================================ */ 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 * ============================================================ */ 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." } ); } 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, /* * Revive contribution statistics. */ reviveAssists: 0, revives: 0, /* * Progress toward this player's own revival. */ revive: { progress: 0 }, defense: { charges: 0, reduction: 0 }, knockedOut: false, joinedAt: now, cooldowns: { Strike: 0, Defend: 0, Heal: 0, Revive: 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 } } ); 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(); 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, "boss.turn": 0 } } ); if ( result.modifiedCount !== 1 ) { return false; } await refreshCrumbleStrikeMessage( client, eventId ); return true; } /* * ============================================================ * COMBAT ACTION ROUTER * ============================================================ */ 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." } ); } /* * Revive is special: * * a living player selects a KO'd teammate. */ if ( actionName === "Revive" ) { if (player.knockedOut) { return temporaryEphemeralReply( interaction, { content: "💀 You can't revive someone while you're knocked out." } ); } return showReviveTargets( interaction, event, player ); } /* * KO'd players cannot use normal combat actions. */ 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 "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, eventType: "CrumbleStrike", state: "Active", "boss.health": { $gt: 0 }, [`participants.${interaction.user.id}.knockedOut`]: false, [`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 victory = await checkForVictory( interaction.client, event.eventId ); let bossAttack = null; if (!victory) { bossAttack = await resolveBossTurnIfReady( interaction.client, event.eventId ); 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, eventType: "CrumbleStrike", state: "Active", [`participants.${interaction.user.id}.knockedOut`]: false, [`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 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: `**${player.energy - energyCost}/${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 ); const latest = await events.findOne({ eventId: event.eventId, eventType: "CrumbleStrike", state: "Active" }); if (!latest) { return; } let totalHealing = 0; let healedPlayers = 0; const setOperations = {}; for ( const [ userId, target ] of Object.entries( latest.participants ?? {} ) ) { /* * Normal Heal never revives. */ 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, eventType: "CrumbleStrike", state: "Active", [`participants.${interaction.user.id}.knockedOut`]: false, [`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 ); } /* * ============================================================ * SHOW REVIVE TARGETS * ============================================================ */ async function showReviveTargets( 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." } ); } /* * Discord String Select Menus allow up to 25 options. */ const options = knockedOutPlayers .slice( 0, 25 ) .map( ( [ userId, target ] ) => { const progress = target.revive?.progress ?? 0; return new StringSelectMenuOptionBuilder() .setLabel( `${target.job} — ${progress}/${REVIVE_REQUIRED}` ) .setDescription( `${target.role} • ${Math.max(0, target.health)}/${target.maxHealth} HP` ) .setValue( userId ); } ); const menu = new StringSelectMenuBuilder() .setCustomId( "crumbleStrikeReviveTarget" ) .setPlaceholder( "Choose a fallen adventurer..." ) .addOptions( options ); const row = new ActionRowBuilder() .addComponents( menu ); const assistPower = player.role === "Healer" ? 2 : 1; const embed = new EmbedBuilder() .setTitle( "🌟 Revive" ) .setDescription( [ "Choose a fallen party member to assist.", "", `Your role contributes **+${assistPower} Revival Progress**.`, "", `A player needs **${REVIVE_REQUIRED} total progress** to return to battle.`, "", "Using Revive counts as one combat action." ].join("\n") ) .setFooter({ text: `crumbleStrikeRevive:${event.eventId}:${interaction.user.id}` }); return interaction.reply({ embeds: [ embed ], components: [ row ], flags: MessageFlags.Ephemeral }); } /* * ============================================================ * RESOLVE REVIVE TARGET * ============================================================ */ async function resolveCrumbleStrikeRevive( interaction ) { const footer = interaction.message .embeds[0] ?.footer ?.text; const parts = footer?.split(":") ?? []; if ( parts[0] !== "crumbleStrikeRevive" ) { return temporaryEphemeralReply( interaction, { content: "This revive interaction is no longer valid." } ); } const eventId = parts[1]; const ownerId = parts[2]; if ( ownerId !== interaction.user.id ) { return temporaryEphemeralReply( interaction, { content: "This revive selection belongs to another player." } ); } const targetId = interaction.values?.[0]; if (!targetId) { return temporaryEphemeralReply( interaction, { content: "No revive target was selected." } ); } const { db } = getCollections(); const events = db.collection( EVENT_COLLECTION ); /* * Re-read immediately before resolving. */ const event = await events.findOne({ eventId, eventType: "CrumbleStrike", state: "Active" }); if (!event) { return interaction.update({ embeds: [ new EmbedBuilder() .setTitle( "🌟 Revive" ) .setDescription( "This battle is no longer active." ) ], components: [] }); } const player = event.participants?.[ interaction.user.id ]; const target = event.participants?.[ targetId ]; if ( !player || player.knockedOut ) { return interaction.update({ embeds: [ new EmbedBuilder() .setTitle( "🌟 Revive" ) .setDescription( "You are no longer able to perform this action." ) ], components: [] }); } if ( !target || !target.knockedOut ) { return interaction.update({ embeds: [ new EmbedBuilder() .setTitle( "🌟 Revive" ) .setDescription( "That player has already been revived." ) ], components: [] }); } const now = getUnixTime(); const cooldown = player.cooldowns?.Revive ?? 0; if ( cooldown > now ) { return interaction.update({ embeds: [ new EmbedBuilder() .setTitle( "🌟 Revive" ) .setDescription( `You can assist another revival .` ) ], components: [] }); } /* * Healers count double. */ const revivePower = player.role === "Healer" ? 2 : 1; const currentProgress = target.revive?.progress ?? 0; const newProgress = Math.min( REVIVE_REQUIRED, currentProgress + revivePower ); const completed = newProgress >= REVIVE_REQUIRED; 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`]: revivePower }; let restoredHealth = 0; if (completed) { const revivePercent = REVIVE_HP[ target.role ] ?? 0.75; 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; incOperations[ `participants.${interaction.user.id}.revives` ] = 1; } else { setOperations[ `participants.${targetId}.revive.progress` ] = newProgress; } /* * Atomic check: * * target must still be KO'd and reviver must * still be alive. */ const result = await events.updateOne( { eventId, eventType: "CrumbleStrike", state: "Active", [`participants.${interaction.user.id}.knockedOut`]: false, [`participants.${targetId}.knockedOut`]: true, [`participants.${interaction.user.id}.cooldowns.Revive`]: { $lte: now } }, { $set: setOperations, $inc: incOperations } ); if ( result.modifiedCount !== 1 ) { return interaction.update({ embeds: [ new EmbedBuilder() .setTitle( "🌟 Revive" ) .setDescription( "Someone else changed the battle state before your revival could complete." ) ], components: [] }); } await clampBossGauges( eventId ); /* * Reviving still advances the boss toward * its next attack. * * If this fills the gauge, the boss can attack * immediately after the revival action. */ const bossAttack = await resolveBossTurnIfReady( interaction.client, eventId ); await refreshCrumbleStrikeMessage( interaction.client, eventId ); const resultEmbed = new EmbedBuilder(); if (completed) { resultEmbed .setTitle( "🌟 REVIVED!" ) .setDescription( [ `<@${targetId}> has returned to the fight!`, "", `❤️ HP restored: **${restoredHealth}/${target.maxHealth}**`, "⚡ Energy: **0/100**", "", `${getRoleEmoji(player.role)} ${player.job} contributed **+${revivePower} Revival Progress**.` ].join("\n") ); } else { resultEmbed .setTitle( "🌟 Revival Assist" ) .setDescription( [ `You assist <@${targetId}>.`, "", `Revival Progress: **${newProgress}/${REVIVE_REQUIRED}**`, "", `${getRoleEmoji(player.role)} ${player.job} contributed **+${revivePower}**.` ].join("\n") ); } if (bossAttack) { resultEmbed.addFields({ name: "👹 Boss Turn", value: bossAttack.summary }); } await interaction.update({ embeds: [ resultEmbed ], components: [] }); /* * Remove the ephemeral result after a while. */ setTimeout( async () => { try { await interaction.deleteReply(); } catch {} }, 9000 ); } /* * ============================================================ * 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 ); const damage = Math.floor( ( event.boss.maxHealth * 0.08 ) + ( totalPartyPower * 8 ) ); await interaction.deferUpdate(); const { db } = getCollections(); const events = db.collection( EVENT_COLLECTION ); const result = await events.updateOne( { eventId: event.eventId, eventType: "CrumbleStrike", state: "Active", "boss.limitGauge": { $gte: LIMIT_GAUGE_MAX }, "boss.health": { $gt: 0 }, [`participants.${interaction.user.id}.knockedOut`]: false }, { $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 victory = await checkForVictory( interaction.client, event.eventId ); let bossAttack = null; if (!victory) { bossAttack = await resolveBossTurnIfReady( interaction.client, event.eventId ); 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 boss turn. * * Increment persistent boss turn counter too. */ const claim = await events.updateOne( { eventId, eventType: "CrumbleStrike", state: "Active", "boss.actionGauge": { $gte: BOSS_ACTION_THRESHOLD } }, { $inc: { "boss.actionGauge": -BOSS_ACTION_THRESHOLD, "boss.turn": 1 } } ); if ( claim.modifiedCount !== 1 ) { return null; } const latest = await events.findOne({ eventId, eventType: "CrumbleStrike" }); 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; /* * Multiple updates to the same participant * can be accumulated here. */ incOperations[ `participants.${userId}.mitigation` ] = ( incOperations[ `participants.${userId}.mitigation` ] ?? 0 ) + 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; /* * Reset their current revival progress * whenever they are newly knocked out. */ setOperations[ `participants.${userId}.revive.progress` ] = 0; knockedOut++; } totalDamage += damage; } const update = { $set: setOperations }; if ( Object.keys( incOperations ).length ) { update.$inc = incOperations; } await events.updateOne( { eventId, eventType: "CrumbleStrike", state: "Active" }, update ); 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, eventType: "CrumbleStrike", "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, eventType: "CrumbleStrike", 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 ); 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 ); 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 ] ) => { return { userId, participant, contribution: getContributionScore( participant ) }; } ); const totalContribution = scored.reduce( ( total, player ) => total + player.contribution, 0 ); const rewards = []; for ( const player of scored ) { const contributionReward = totalContribution > 0 ? Math.floor( contributionPool * ( player.contribution / totalContribution ) ) : 0; const reward = evenShare + contributionReward; if ( reward > 0 ) { await safeTransfer( event.escrowId, player.userId, 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, reviveAssists: player.participant.reviveAssists ?? 0, revives: player.participant.revives ?? 0, contribution: player.contribution, reward }); } /* * Give rounding leftovers to highest contributor. */ 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, eventType: "CrumbleStrike", state: "Resolving" }, { $set: { state: "Defeated", defeatedAt: getUnixTime(), rewards } } ); await refreshCrumbleStrikeMessage( client, eventId ); return true; } /* * ============================================================ * CONTRIBUTION * ============================================================ */ function getContributionScore( participant ) { return Math.floor( ( participant.damage ?? 0 ) + ( ( participant.healing ?? 0 ) * 0.80 ) + ( ( participant.mitigation ?? 0 ) * 0.80 ) + ( ( participant.reviveAssists ?? 0 ) * 75 ) + ( ( participant.revives ?? 0 ) * 150 ) ); } /* * ============================================================ * 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 ); const knockedOutCount = participants.filter( participant => participant.knockedOut ).length; /* * 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`, knockedOutCount > 0 ? `💀 **Knocked Out:** ${knockedOutCount}` : null, `**Reward Pool:** ${event.rewardPool.toLocaleString()} 🍪`, `**Battle ends:** `, "", "### ⚔️ Battle Actions", "**⚔️ Strike**", "Low damage • Generates Energy + 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", "", "**🌟 Revive**", "Assist a KO'd player • DPS/Tank +1 • Healer +2 • 3 required", "", "**✨ Limit Break**", "Massive shared attack • Requires a full 100-point Limit Gauge", "", "*Your Job modifies how effective your actions are.*" ] .filter(Boolean) .join("\n") ) .setFooter({ text: `crumbleStrike:${event.eventId}` }), 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.revives}`, `🍪 **${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( "crumbleStrikeRevive" ) .setLabel( "Revive" ) .setEmoji( "🌟" ) .setStyle( ButtonStyle.Secondary ), 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 ]; } 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, resolveCrumbleStrikeRevive, resolveCrumbleStrikeVictory, failCrumbleStrike, cancelCrumbleStrikeAndRefund, refreshCrumbleStrikeMessage };