diff --git a/buttons/crumbleStrike/defend.js b/buttons/crumbleStrike/defend.js new file mode 100644 index 0000000..6e97a2a --- /dev/null +++ b/buttons/crumbleStrike/defend.js @@ -0,0 +1,18 @@ +const { + performCrumbleStrikeAction +} = require( + "../../utils/crumbleStrike" +); + + +module.exports = { + name: + "crumbleStrikeDefend", + + async execute(interaction) { + return performCrumbleStrikeAction( + interaction, + "Defend" + ); + } +}; \ No newline at end of file diff --git a/buttons/crumbleStrike/heal.js b/buttons/crumbleStrike/heal.js new file mode 100644 index 0000000..6a66676 --- /dev/null +++ b/buttons/crumbleStrike/heal.js @@ -0,0 +1,18 @@ +const { + performCrumbleStrikeAction +} = require( + "../../utils/crumbleStrike" +); + + +module.exports = { + name: + "crumbleStrikeHeal", + + async execute(interaction) { + return performCrumbleStrikeAction( + interaction, + "Heal" + ); + } +}; \ No newline at end of file diff --git a/buttons/crumbleStrike/limitBreak.js b/buttons/crumbleStrike/limitBreak.js new file mode 100644 index 0000000..94bb80b --- /dev/null +++ b/buttons/crumbleStrike/limitBreak.js @@ -0,0 +1,18 @@ +const { + performCrumbleStrikeAction +} = require( + "../../utils/crumbleStrike" +); + + +module.exports = { + name: + "crumbleStrikeLimitBreak", + + async execute(interaction) { + return performCrumbleStrikeAction( + interaction, + "LimitBreak" + ); + } +}; \ No newline at end of file diff --git a/buttons/crumbleStrike/revive.js b/buttons/crumbleStrike/revive.js new file mode 100644 index 0000000..a9eccb1 --- /dev/null +++ b/buttons/crumbleStrike/revive.js @@ -0,0 +1,18 @@ +const { + performCrumbleStrikeAction +} = require( + "../../utils/crumbleStrike" +); + + +module.exports = { + name: + "crumbleStrikeRevive", + + async execute(interaction) { + return performCrumbleStrikeAction( + interaction, + "Revive" + ); + } +}; \ No newline at end of file diff --git a/buttons/crumbleStrike/strike.js b/buttons/crumbleStrike/strike.js new file mode 100644 index 0000000..cb17498 --- /dev/null +++ b/buttons/crumbleStrike/strike.js @@ -0,0 +1,18 @@ +const { + performCrumbleStrikeAction +} = require( + "../../utils/crumbleStrike" +); + + +module.exports = { + name: + "crumbleStrikeStrike", + + async execute(interaction) { + return performCrumbleStrikeAction( + interaction, + "Strike" + ); + } +}; \ No newline at end of file diff --git a/commands/applications/crumbleStrike.js b/commands/applications/crumbleStrike.js index e25d8dc..e717762 100644 --- a/commands/applications/crumbleStrike.js +++ b/commands/applications/crumbleStrike.js @@ -1,5 +1,4 @@ const { - SlashCommandBuilder, EmbedBuilder, ActionRowBuilder, ButtonBuilder, @@ -7,78 +6,691 @@ const { MessageFlags } = require("discord.js"); -const { - randomUUID -} = require("node:crypto"); - const { getCollections, getCookieBalance, transferCookies, - getUnixTime + temporaryEphemeralReply, + temporaryEphemeralFollowUp, + getUnixTime, + randomInteger } = require("../../utils/cookieEconomy"); -const SPAWN_COST = - 500; - -const JOIN_DURATION = - 30 * 60; - -const MINIMUM_PLAYERS = - 5; +const EVENT_COLLECTION = + "items_events"; -module.exports = { - data: new SlashCommandBuilder() - .setName("crumblestrike") - .setDescription( - "Cooperative Cookie boss encounters." - ) +/* + * ============================================================ + * GENERAL CONFIG + * ============================================================ + */ - .addSubcommand(subcommand => - subcommand - .setName("spawn") - .setDescription( - `Summon the Crumbbound Behemoth for ${SPAWN_COST} Cookies.` - ) - ), +const BATTLE_DURATION = + 60 * 60; + +const BOSS_ACTION_THRESHOLD = + 10; + +const LIMIT_GAUGE_MAX = + 100; + +const REVIVE_REQUIRED = + 3; - async execute(interaction) { - switch ( - interaction.options.getSubcommand() - ) { - case "spawn": - return spawnCrumbleStrike( - interaction - ); +/* + * HP restored when a player is revived. + * + * This is based on the TARGET'S role. + */ +const REVIVE_HP = { + DPS: + 0.65, - default: - return interaction.reply({ - content: - "Unknown Crumble Strike command.", - flags: - MessageFlags.Ephemeral - }); + 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 } } }; -async function spawnCrumbleStrike( - interaction +/* + * ============================================================ + * 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 ) { - if (!interaction.guild) { - return interaction.reply({ - content: - "Crumble Strike can only be started inside a server.", - flags: - MessageFlags.Ephemeral + 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 } = @@ -87,35 +699,63 @@ async function spawnCrumbleStrike( const events = db.collection( - "items_events" + EVENT_COLLECTION ); - const existing = + const event = await events.findOne({ + eventId: + metadata.eventId, + eventType: - "CrumbleStrike", - - guildId: - interaction.guildId, - - state: { - $in: [ - "Joining", - "Starting", - "Active", - "Resolving" - ] - } + "CrumbleStrike" }); - if (existing) { - return interaction.reply({ - content: - "There is already an active Crumble Strike encounter in this server.", - flags: - MessageFlags.Ephemeral + 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: [] }); } @@ -128,39 +768,23 @@ async function spawnCrumbleStrike( if ( balance < - SPAWN_COST + tier.cost ) { - return interaction.reply({ - content: - `You need **${SPAWN_COST.toLocaleString()} Cookies** to summon the boss.`, - flags: - MessageFlags.Ephemeral - }); + return temporaryEphemeralReply( + interaction, + { + content: + `You need **${tier.cost.toLocaleString()} Cookies** to join as a ${tier.name}.` + } + ); } - const eventId = - randomUUID(); - - - const escrowId = - `crumblestrike:${eventId}`; - - - const now = - getUnixTime(); - - - const joinUntil = - now + - JOIN_DURATION; - - try { await transferCookies( interaction.user.id, - escrowId, - SPAWN_COST + event.escrowId, + tier.cost ); } catch (error) { @@ -168,12 +792,13 @@ async function spawnCrumbleStrike( error.message === "INSUFFICIENT_COOKIES" ) { - return interaction.reply({ - content: - "Your Cookie balance changed before the boss could be summoned.", - flags: - MessageFlags.Ephemeral - }); + return temporaryEphemeralReply( + interaction, + { + content: + "Your Cookie balance changed before you could join." + } + ); } @@ -181,42 +806,3678 @@ async function spawnCrumbleStrike( } + 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( - "🍪 Crumble Strike" + "🌟 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( [ - "## The Crumbbound Behemoth", + `<@${targetId}> has returned to the fight!`, "", - "*Drawn by the scent of an irresponsibly large Cookie Vault,*", - "*the beast has lumbered into Shaiwase.*", + `❤️ HP restored: **${restoredHealth}/${target.maxHealth}**`, + "⚡ Energy: **0/100**", "", - `**Summoner:** ${interaction.user}`, - `**Recruitment ends:** `, - `**Minimum adventurers:** ${MINIMUM_PLAYERS}`, - `**Participants:** 0/${MINIMUM_PLAYERS}`, - `**Reward Pool:** ${SPAWN_COST.toLocaleString()} 🍪`, - "", - "Choose your role below to begin preparing for battle.", - "", - "⚔️ **DPS** — Specializes in offensive actions.", - "🛡️ **Tank** — Specializes in mitigating boss attacks.", - "❤️ **Healer** — Specializes in restoring party HP.", - "", - "Once enough players have joined, the summoner may start the battle early." + `${getRoleEmoji(player.role)} ${player.job} contributed **+${revivePower} Revival Progress**.` ].join("\n") + ); + } + + + else { + resultEmbed + .setTitle( + "🌟 Revival Assist" ) - .setFooter({ - text: - `crumbleStrike:${eventId}` - }); + .setDescription( + [ + `You assist <@${targetId}>.`, + "", + `Revival Progress: **${newProgress}/${REVIVE_REQUIRED}**`, + "", + `${getRoleEmoji(player.role)} ${player.job} contributed **+${revivePower}**.` + ].join("\n") + ); + } - const roles = + 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() @@ -260,10 +4521,8 @@ async function spawnCrumbleStrike( .setStyle( ButtonStyle.Success ) - ); + ), - - const start = new ActionRowBuilder() .addComponents( new ButtonBuilder() @@ -279,124 +4538,504 @@ async function spawnCrumbleStrike( .setStyle( ButtonStyle.Secondary ) - ); + ) + ]; +} - let message; +/* + * ============================================================ + * JOB SELECTION + * ============================================================ + */ + +function buildJobSelectionEmbed( + event, + user, + role +) { + const jobs = + JOBS[ + role + ]; - try { - message = - await interaction.reply({ - embeds: [ - embed - ], - - components: [ - roles, - start - ], - - fetchReply: - true - }); - - } catch (error) { - await safeRefund( - escrowId, - interaction.user.id, - SPAWN_COST - ); + const description = + Object.values( + jobs + ) + .map( + job => + `${job.emoji} **${formatJobProgression(job)}**` + ) + .join("\n"); - throw error; + 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; } - try { - await events.insertOne({ - eventType: - "CrumbleStrike", + return getCrumbleStrikeByEventId( + eventId + ); +} + +async function getCrumbleStrikeByEventId( + eventId +) { + const { + db + } = + getCollections(); + + + return db + .collection( + EVENT_COLLECTION + ) + .findOne({ eventId, - escrowId, - - guildId: - interaction.guildId, - - channelId: - interaction.channelId, - - messageId: - message.id, - - creatorId: - interaction.user.id, - - state: - "Joining", - - createdAt: - now, - - joinUntil, - - battleStartedAt: - null, - - battleEndsAt: - null, - - minimumParticipants: - MINIMUM_PLAYERS, - - rewardPool: - SPAWN_COST, - - boss: { - bossId: - "crumbbound-behemoth", - - name: - "The Crumbbound Behemoth", - - health: - 15000, - - maxHealth: - 15000, - - limitGauge: - 0, - - actionGauge: - 0 - }, - - participants: - {} + eventType: + "CrumbleStrike" }); - - } catch (error) { - try { - await message.delete(); - } catch {} +} - await safeRefund( - escrowId, - interaction.user.id, - SPAWN_COST +/* + * ============================================================ + * 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}` ); + } - throw error; + return job.name; +} + + +/* + * ============================================================ + * DISPLAY HELPERS + * ============================================================ + */ + +function getRoleEmoji( + role +) { + switch (role) { + case "DPS": + return "⚔️"; + + case "Tank": + return "🛡️"; + + case "Healer": + return "❤️"; + + default: + return "🍪"; } } -async function safeRefund( +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 @@ -408,10 +5047,44 @@ async function safeRefund( amount ); + + return true; + } catch (error) { console.error( - "CRITICAL: Crumble Strike refund failed:", + `Crumble Strike Cookie transfer failed (${from} -> ${to}, ${amount}):`, error ); + + + return false; } -} \ No newline at end of file +} + + +/* + * ============================================================ + * EXPORTS + * ============================================================ + */ + +module.exports = { + JOBS, + TIERS, + + chooseCrumbleStrikeRole, + chooseCrumbleStrikeJob, + confirmCrumbleStrikeJoin, + + startCrumbleStrikeBattle, + activateCrumbleStrike, + + performCrumbleStrikeAction, + resolveCrumbleStrikeRevive, + + resolveCrumbleStrikeVictory, + failCrumbleStrike, + cancelCrumbleStrikeAndRefund, + + refreshCrumbleStrikeMessage +}; \ No newline at end of file diff --git a/utils/cookieEconomy.js b/utils/cookieEconomy.js index f7e144d..d98307f 100644 --- a/utils/cookieEconomy.js +++ b/utils/cookieEconomy.js @@ -2,79 +2,166 @@ const { MessageFlags } = require("discord.js"); -const { mClient } = require("../index"); +const { + mClient +} = require("../index"); -const DATABASE = "neo-db"; +/* + * ============================================================ + * DATABASE CONFIG + * ============================================================ + * + * Set M_DB in .env to the name of your Mongo database. + * + * Example: + * + * M_DB=ShaiBot + */ -const COOKIE_COLLECTION = "items_cookies"; -const COOLDOWN_COLLECTION = "cooldowns_cookies"; +const DATABASE_NAME = + process.env.M_DB; +/* + * Cookie balances: + * + * { + * userId: "123456789", + * cookies: 2500 + * } + */ + +const COOKIE_COLLECTION = + "items_cookies"; + + +/* + * Cooldowns: + * + * { + * userId: "123456789", + * guildId: "987654321", + * + * collect: 1787000000, + * steal: 1787000000, + * heist: 1787000000 + * } + * + * Values are Unix timestamps representing when + * the action becomes available again. + * + * No createdAt / updatedAt fields. + */ + +const COOLDOWN_COLLECTION = + "cooldown_cookies"; + + +/* + * ============================================================ + * COLLECTION ACCESS + * ============================================================ + */ + function getCollections() { - const db = mClient.db(DATABASE); + if (!DATABASE_NAME) { + throw new Error( + "M_DB is not configured in .env" + ); + } + + + const db = + mClient.db( + DATABASE_NAME + ); + return { db, - cookies: db.collection(COOKIE_COLLECTION), - cooldowns: db.collection(COOLDOWN_COLLECTION) + + cookies: + db.collection( + COOKIE_COLLECTION + ), + + cooldowns: + db.collection( + COOLDOWN_COLLECTION + ) }; } -async function getCookieBalance(userId) { - const { cookies } = getCollections(); +/* + * ============================================================ + * UNIX TIME + * ============================================================ + */ - const result = await cookies.findOne( - { - userId - }, - { - projection: { - _id: 0, - cookies: 1 - } - } +function getUnixTime() { + return Math.floor( + Date.now() / + 1000 ); - - return result?.cookies ?? 0; } +/* + * ============================================================ + * COOKIE BALANCE + * ============================================================ + */ + +async function getCookieBalance( + userId +) { + const { + cookies + } = + getCollections(); + + + const document = + await cookies.findOne({ + userId + }); + + + return Math.max( + 0, + document?.cookies ?? + 0 + ); +} + + +/* + * ============================================================ + * CHANGE COOKIE BALANCE + * ============================================================ + * + * Positive: + * + * changeCookies(userId, 50) + * + * Negative: + * + * changeCookies(userId, -50) + * + * Negative changes are protected against taking a + * balance below zero. + */ + async function changeCookies( userId, amount -) { - const { cookies } = getCollections(); - - return cookies.updateOne( - { - userId - }, - { - $inc: { - cookies: amount - }, - - $setOnInsert: { - userId - } - }, - { - upsert: true - } - ); -} - - -async function transferCookies( - fromUserId, - toUserId, - amount ) { if ( - !Number.isInteger(amount) || - amount <= 0 + !Number.isSafeInteger( + amount + ) ) { throw new Error( "INVALID_COOKIE_AMOUNT" @@ -82,28 +169,92 @@ async function transferCookies( } - const { cookies } = getCollections(); + if ( + amount === + 0 + ) { + return getCookieBalance( + userId + ); + } - const debit = + const { + cookies + } = + getCollections(); + + + /* + * Adding Cookies. + */ + + if ( + amount > + 0 + ) { await cookies.updateOne( { - userId: fromUserId, + userId + }, + { + $inc: { + cookies: + amount + }, + + $setOnInsert: { + userId + } + }, + { + upsert: + true + } + ); + + + return getCookieBalance( + userId + ); + } + + + /* + * Removing Cookies. + * + * Atomic balance check prevents the account + * from becoming negative. + */ + + const removeAmount = + Math.abs( + amount + ); + + + const result = + await cookies.updateOne( + { + userId, cookies: { - $gte: amount + $gte: + removeAmount } }, { $inc: { - cookies: -amount + cookies: + -removeAmount } } ); if ( - debit.modifiedCount !== 1 + result.modifiedCount !== + 1 ) { throw new Error( "INSUFFICIENT_COOKIES" @@ -111,59 +262,222 @@ async function transferCookies( } - try { + return getCookieBalance( + userId + ); +} + + +/* + * ============================================================ + * COOKIE TRANSFER + * ============================================================ + * + * This intentionally DOES NOT use Mongo transactions. + * + * Your Mongo deployment does not support transactions / + * retryable transactional writes. + * + * The debit is atomic. + * + * If crediting the destination fails after the debit, + * we attempt to compensate by returning the Cookies + * to the source account. + */ + +async function transferCookies( + fromUserId, + toUserId, + amount +) { + if ( + fromUserId === + toUserId + ) { + throw new Error( + "SAME_ACCOUNT" + ); + } + + + if ( + !Number.isSafeInteger( + amount + ) + || + amount <= + 0 + ) { + throw new Error( + "INVALID_COOKIE_AMOUNT" + ); + } + + + const { + cookies + } = + getCollections(); + + + /* + * ======================================================== + * DEBIT SOURCE + * ======================================================== + * + * This is the important atomic operation. + * + * Two simultaneous attempts cannot both spend the + * same Cookies unless the balance actually covers both. + */ + + const debit = await cookies.updateOne( { - userId: toUserId - }, - { - $inc: { - cookies: amount - }, + userId: + fromUserId, - $setOnInsert: { - userId: toUserId + cookies: { + $gte: + amount } }, { - upsert: true + $inc: { + cookies: + -amount + } + } + ); + + + if ( + debit.modifiedCount !== + 1 + ) { + throw new Error( + "INSUFFICIENT_COOKIES" + ); + } + + + /* + * ======================================================== + * CREDIT DESTINATION + * ======================================================== + */ + + try { + await cookies.updateOne( + { + userId: + toUserId + }, + { + $inc: { + cookies: + amount + }, + + $setOnInsert: { + userId: + toUserId + } + }, + { + upsert: + true } ); } catch (error) { + /* + * Best-effort compensation. + * + * If destination credit fails, return the + * Cookies we already removed. + */ + try { await cookies.updateOne( { - userId: fromUserId + userId: + fromUserId }, { $inc: { - cookies: amount + cookies: + amount + }, + + $setOnInsert: { + userId: + fromUserId } + }, + { + upsert: + true } ); - } catch (rollbackError) { + } catch (refundError) { console.error( - "CRITICAL: Cookie rollback failed:", - rollbackError + "CRITICAL: Cookie transfer compensation failed:", + { + fromUserId, + toUserId, + amount, + refundError + } ); } throw error; } + + + return { + amount, + + from: + fromUserId, + + to: + toUserId + }; } +/* + * ============================================================ + * GET COOLDOWN + * ============================================================ + * + * Returns the Unix timestamp when the cooldown becomes + * available again. + * + * Returns 0 when no cooldown exists. + */ + async function getCooldown( userId, guildId, - type + cooldownName ) { - const { cooldowns } = getCollections(); + validateCooldownName( + cooldownName + ); - const result = + + const { + cooldowns + } = + getCollections(); + + + const document = await cooldowns.findOne( { userId, @@ -171,26 +485,58 @@ async function getCooldown( }, { projection: { - _id: 0, - [type]: 1 + [cooldownName]: + 1 } } ); - return result?.[type] ?? 0; + + return Number( + document?.[ + cooldownName + ] + ?? + 0 + ); } +/* + * ============================================================ + * SET COOLDOWN + * ============================================================ + * + * durationSeconds is added to the current Unix timestamp. + * + * Returns the timestamp when the action becomes available. + */ + async function setCooldown( userId, guildId, - type, - duration + cooldownName, + durationSeconds ) { - const { cooldowns } = getCollections(); + validateCooldownName( + cooldownName + ); - const now = getUnixTime(); - const next = now + duration; + + validateCooldownDuration( + durationSeconds + ); + + + const { + cooldowns + } = + getCollections(); + + + const next = + getUnixTime() + + durationSeconds; await cooldowns.updateOne( @@ -200,7 +546,8 @@ async function setCooldown( }, { $set: { - [type]: next + [cooldownName]: + next }, $setOnInsert: { @@ -209,7 +556,8 @@ async function setCooldown( } }, { - upsert: true + upsert: + true } ); @@ -219,104 +567,393 @@ async function setCooldown( /* - * Atomically claim a cooldown. + * ============================================================ + * CLAIM COOLDOWN + * ============================================================ * - * Useful for button games where a double-click - * could otherwise resolve twice. + * Used for actions where rapid double clicks must not be + * allowed to resolve twice. + * + * Vault Heist uses: + * + * claimCooldown( + * userId, + * guildId, + * "heist", + * HEIST_COOLDOWN + * ) + * + * Sneak / Hack / Smash therefore all share ONE cooldown. + * + * Returns: + * + * { + * claimed: true, + * next: 1787000000 + * } + * + * or: + * + * { + * claimed: false, + * next: 1787000000 + * } */ async function claimCooldown( userId, guildId, - type, - duration + cooldownName, + durationSeconds ) { - const { cooldowns } = getCollections(); - - const now = getUnixTime(); - const next = now + duration; + validateCooldownName( + cooldownName + ); - try { - const result = - await cooldowns.updateOne( - { - userId, - guildId, + validateCooldownDuration( + durationSeconds + ); - $or: [ - { - [type]: { - $lte: now - } - }, - { - [type]: { - $exists: false - } + + const { + cooldowns + } = + getCollections(); + + + const now = + getUnixTime(); + + + const next = + now + + durationSeconds; + + + /* + * First attempt: + * + * Update an existing cooldown document only if + * this particular cooldown is absent or expired. + * + * This is atomic. + */ + + const existing = + await cooldowns.updateOne( + { + userId, + guildId, + + $or: [ + { + [cooldownName]: { + $exists: + false } - ] - }, - { - $set: { - [type]: next }, - $setOnInsert: { - userId, - guildId + { + [cooldownName]: { + $lte: + now + } } - }, - { - upsert: true + ] + }, + { + $set: { + [cooldownName]: + next } - ); + } + ); + + + if ( + existing.modifiedCount === + 1 + ) { + return { + claimed: + true, + + next + }; + } + + + /* + * The document might not exist at all yet. + * + * Try inserting one. + * + * If another interaction created it at the exact + * same moment, the unique index will reject one + * side and we'll simply read the winning cooldown. + */ + + try { + await cooldowns.insertOne({ + userId, + guildId, + + [cooldownName]: + next + }); return { claimed: - result.modifiedCount === 1 || - result.upsertedCount === 1, + true, next }; } catch (error) { /* - * A unique {userId, guildId} index may cause - * a duplicate-key error if an active cooldown - * already exists. + * 11000 = duplicate key. + * + * That is expected when two interactions race + * to create the same cooldown record. */ - if (error?.code === 11000) { - const active = - await getCooldown( + + if ( + error?.code !== + 11000 + ) { + throw error; + } + } + + + /* + * Someone else already owns the active cooldown. + */ + + const current = + await getCooldown( + userId, + guildId, + cooldownName + ); + + + /* + * Rare boundary case: + * + * the cooldown expired between our previous write + * attempt and this read. + * + * Try one more atomic claim. + */ + + if ( + current <= + getUnixTime() + ) { + const retry = + await cooldowns.updateOne( + { userId, guildId, - type - ); + [cooldownName]: { + $lte: + getUnixTime() + } + }, + { + $set: { + [cooldownName]: + next + } + } + ); + + + if ( + retry.modifiedCount === + 1 + ) { return { - claimed: false, - next: active + claimed: + true, + + next }; } - - throw error; } + + + return { + claimed: + false, + + next: + current + }; } +/* + * ============================================================ + * CLEAR COOLDOWN + * ============================================================ + * + * Useful during testing/admin work. + */ + +async function clearCooldown( + userId, + guildId, + cooldownName +) { + validateCooldownName( + cooldownName + ); + + + const { + cooldowns + } = + getCollections(); + + + await cooldowns.updateOne( + { + userId, + guildId + }, + { + $unset: { + [cooldownName]: + "" + } + } + ); +} + + +/* + * ============================================================ + * TEMPORARY EPHEMERAL REPLY + * ============================================================ + * + * Usage: + * + * await temporaryEphemeralReply( + * interaction, + * { + * content: "Nope." + * }, + * 6000 + * ); + * + * If the interaction was already acknowledged, + * this automatically falls back to an ephemeral follow-up. + */ + async function temporaryEphemeralReply( interaction, options, - deleteAfter = 5000 + deleteAfter = + 6000 ) { + if ( + interaction.replied || + interaction.deferred + ) { + return temporaryEphemeralFollowUp( + interaction, + options, + deleteAfter + ); + } + + await interaction.reply({ ...options, - flags: MessageFlags.Ephemeral + + flags: + MessageFlags.Ephemeral }); + scheduleDeleteReply( + interaction, + deleteAfter + ); +} + + +/* + * ============================================================ + * TEMPORARY EPHEMERAL FOLLOW-UP + * ============================================================ + * + * Primarily used after deferUpdate(). + */ + +async function temporaryEphemeralFollowUp( + interaction, + options, + deleteAfter = + 6000 +) { + const message = + await interaction.followUp({ + ...options, + + flags: + MessageFlags.Ephemeral, + + fetchReply: + true + }); + + + if ( + deleteAfter > + 0 + ) { + setTimeout( + async () => { + try { + await interaction.webhook.deleteMessage( + message.id + ); + } catch {} + }, + deleteAfter + ); + } + + + return message; +} + + +/* + * ============================================================ + * TEMPORARY REPLY DELETE + * ============================================================ + */ + +function scheduleDeleteReply( + interaction, + deleteAfter +) { + if ( + !deleteAfter || + deleteAfter <= + 0 + ) { + return; + } + + setTimeout( async () => { try { @@ -328,62 +965,90 @@ async function temporaryEphemeralReply( } -async function temporaryEphemeralFollowUp( - interaction, - options, - deleteAfter = 8000 -) { - const message = - await interaction.followUp({ - ...options, - flags: MessageFlags.Ephemeral - }); - - - setTimeout( - async () => { - try { - await message.delete(); - } catch {} - }, - deleteAfter - ); - - - return message; -} - - -function getUnixTime() { - return Math.floor( - Date.now() / 1000 - ); -} - +/* + * ============================================================ + * RANDOM INTEGER + * ============================================================ + * + * Inclusive: + * + * randomInteger(1, 10) + * + * can return 1 through 10. + */ function randomInteger( min, max ) { + const minimum = + Math.ceil( + min + ); + + + const maximum = + Math.floor( + max + ); + + return Math.floor( Math.random() * - (max - min + 1) - ) + min; + ( + maximum - + minimum + + 1 + ) + ) + + + minimum; } +/* + * ============================================================ + * RANDOM FLOAT + * ============================================================ + */ + function randomFloat( min, max ) { return ( Math.random() * - (max - min) - ) + min; + ( + max - + min + ) + ) + + + min; } -function randomItem(items) { +/* + * ============================================================ + * RANDOM ITEM + * ============================================================ + */ + +function randomItem( + items +) { + if ( + !Array.isArray( + items + ) + || + items.length === + 0 + ) { + return undefined; + } + + return items[ Math.floor( Math.random() * @@ -393,17 +1058,134 @@ function randomItem(items) { } -function formatPercentage(value) { - return `${Math.round(value * 100)}%`; +/* + * ============================================================ + * FORMAT PERCENTAGE + * ============================================================ + * + * formatPercentage(0.523) + * + * => "52.3%" + */ + +function formatPercentage( + value, + decimals = + 1 +) { + return ( + ( + value * + 100 + ) + .toFixed( + decimals + ) + + + "%" + ); } -module.exports = { - DATABASE, - COOKIE_COLLECTION, - COOLDOWN_COLLECTION, +/* + * ============================================================ + * VALIDATION + * ============================================================ + */ +function validateCooldownName( + cooldownName +) { + if ( + typeof cooldownName !== + "string" + || + !/^[A-Za-z0-9_]+$/.test( + cooldownName + ) + ) { + throw new Error( + "INVALID_COOLDOWN_NAME" + ); + } +} + + +function validateCooldownDuration( + durationSeconds +) { + if ( + !Number.isSafeInteger( + durationSeconds + ) + || + durationSeconds < + 0 + ) { + throw new Error( + "INVALID_COOLDOWN_DURATION" + ); + } +} + + +/* + * ============================================================ + * DATABASE INDEXES + * ============================================================ + * + * Call this once during startup if desired. + * + * The unique cooldown index is particularly useful for + * claimCooldown() because it prevents duplicate + * userId + guildId cooldown records. + */ + +async function ensureCookieIndexes() { + const { + cookies, + cooldowns + } = + getCollections(); + + + await cookies.createIndex( + { + userId: + 1 + }, + { + unique: + true + } + ); + + + await cooldowns.createIndex( + { + userId: + 1, + + guildId: + 1 + }, + { + unique: + true + } + ); +} + + +/* + * ============================================================ + * EXPORTS + * ============================================================ + */ + +module.exports = { getCollections, + getCookieBalance, changeCookies, transferCookies, @@ -411,13 +1193,17 @@ module.exports = { getCooldown, setCooldown, claimCooldown, + clearCooldown, temporaryEphemeralReply, temporaryEphemeralFollowUp, getUnixTime, + randomInteger, randomFloat, randomItem, - formatPercentage + formatPercentage, + + ensureCookieIndexes }; \ No newline at end of file diff --git a/utils/crumbleStrike.js b/utils/crumbleStrike.js index bd17bca..ea50b7d 100644 --- a/utils/crumbleStrike.js +++ b/utils/crumbleStrike.js @@ -11,19 +11,99 @@ const { getCookieBalance, transferCookies, temporaryEphemeralReply, - getUnixTime + 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 = { @@ -36,9 +116,19 @@ const JOBS = { "Thaumaturge", emoji: - "🔥" + "🔥", + + strikeMultiplier: + 1.40, + + strikeEnergy: + 10, + + strikeLimit: + 5 }, + Dragoon: { name: "Dragoon", @@ -47,9 +137,19 @@ const JOBS = { "Lancer", emoji: - "🐉" + "🐉", + + strikeMultiplier: + 1.30, + + strikeEnergy: + 12, + + strikeLimit: + 7 }, + Bard: { name: "Bard", @@ -58,7 +158,16 @@ const JOBS = { "Archer", emoji: - "🏹" + "🏹", + + strikeMultiplier: + 1.15, + + strikeEnergy: + 14, + + strikeLimit: + 9 } }, @@ -72,9 +181,22 @@ const JOBS = { "Gladiator", emoji: - "⚔️" + "⚔️", + + strikeMultiplier: + 0.90, + + defendCost: + 2, + + defendReduction: + 0.55, + + defendCharges: + 3 }, + Warrior: { name: "Warrior", @@ -83,9 +205,22 @@ const JOBS = { "Marauder", emoji: - "🪓" + "🪓", + + strikeMultiplier: + 1.05, + + defendCost: + 3, + + defendReduction: + 0.45, + + defendCharges: + 2 }, + DarkKnight: { name: "Dark Knight", @@ -94,7 +229,19 @@ const JOBS = { null, emoji: - "🌑" + "🌑", + + strikeMultiplier: + 1.10, + + defendCost: + 3, + + defendReduction: + 0.50, + + defendCharges: + 2 } }, @@ -108,9 +255,22 @@ const JOBS = { "Conjurer", emoji: - "✨" + "✨", + + strikeMultiplier: + 0.75, + + healCost: + 5, + + healMultiplier: + 1.55, + + healLimit: + 3 }, + Scholar: { name: "Scholar", @@ -119,9 +279,22 @@ const JOBS = { "Arcanist", emoji: - "📖" + "📖", + + strikeMultiplier: + 0.80, + + healCost: + 6, + + healMultiplier: + 1.35, + + healLimit: + 4 }, + Astrologian: { name: "Astrologian", @@ -130,14 +303,28 @@ const JOBS = { null, emoji: - "🌟" + "🌟", + + strikeMultiplier: + 0.80, + + healCost: + 6, + + healMultiplier: + 1.25, + + healLimit: + 6 } } }; /* + * ============================================================ * INVESTMENT TIERS + * ============================================================ */ const TIERS = { @@ -197,7 +384,12 @@ const TIERS = { /* + * ============================================================ * ROLE SELECTION + * ============================================================ + * + * Joining is allowed both during recruitment + * and during an active battle. */ async function chooseCrumbleStrikeRole( @@ -222,20 +414,32 @@ async function chooseCrumbleStrikeRole( if ( - event.state !== - "Joining" + ![ + "Joining", + "Active" + ].includes( + event.state + ) ) { return temporaryEphemeralReply( interaction, { content: - "The recruitment phase has already ended." + "This encounter can no longer be joined." } ); } + /* + * Recruitment timer only matters before + * combat has started. + */ + if ( + event.state === + "Joining" + && event.joinUntil <= getUnixTime() ) { @@ -251,7 +455,7 @@ async function chooseCrumbleStrikeRole( const participant = event.participants?.[ - interaction.user.id + interaction.user.id ]; @@ -298,7 +502,9 @@ async function chooseCrumbleStrikeRole( /* + * ============================================================ * JOB SELECTION + * ============================================================ */ async function chooseCrumbleStrikeJob( @@ -314,11 +520,13 @@ async function chooseCrumbleStrikeJob( if ( metadata.type !== - "crumbleStrikeJob" || + "crumbleStrikeJob" + || metadata.userId !== - interaction.user.id || + interaction.user.id + || metadata.role !== - role + role ) { return temporaryEphemeralReply( interaction, @@ -332,7 +540,7 @@ async function chooseCrumbleStrikeJob( const job = JOBS[role]?.[ - jobKey + jobKey ]; @@ -354,17 +562,20 @@ async function chooseCrumbleStrikeJob( if ( - !event || - event.state !== - "Joining" || - event.joinUntil <= - getUnixTime() + !event + || + ![ + "Joining", + "Active" + ].includes( + event.state + ) ) { return interaction.update({ embeds: [ new EmbedBuilder() .setTitle( - "🍪 Recruitment Closed" + "🍪 Entry Closed" ) .setDescription( "This encounter is no longer accepting adventurers." @@ -394,7 +605,9 @@ async function chooseCrumbleStrikeJob( /* + * ============================================================ * CONFIRM JOIN + * ============================================================ */ async function confirmCrumbleStrikeJoin( @@ -409,9 +622,10 @@ async function confirmCrumbleStrikeJoin( if ( metadata.type !== - "crumbleStrikeTier" || + "crumbleStrikeTier" + || metadata.userId !== - interaction.user.id + interaction.user.id ) { return temporaryEphemeralReply( interaction, @@ -426,20 +640,17 @@ async function confirmCrumbleStrikeJoin( const role = metadata.role; - const jobKey = metadata.job; - const job = JOBS[role]?.[ - jobKey + jobKey ]; - const tier = TIERS[ - tierKey + tierKey ]; @@ -480,17 +691,20 @@ async function confirmCrumbleStrikeJoin( if ( - !event || - event.state !== - "Joining" || - event.joinUntil <= - getUnixTime() + !event + || + ![ + "Joining", + "Active" + ].includes( + event.state + ) ) { return interaction.update({ embeds: [ new EmbedBuilder() .setTitle( - "🍪 Recruitment Closed" + "🍪 Entry Closed" ) .setDescription( "The battle can no longer be joined." @@ -504,7 +718,7 @@ async function confirmCrumbleStrikeJoin( if ( event.participants?.[ - interaction.user.id + interaction.user.id ] ) { return interaction.update({ @@ -579,6 +793,8 @@ async function confirmCrumbleStrikeJoin( job: job.name, + jobKey, + class: job.class, @@ -648,8 +864,12 @@ async function confirmCrumbleStrikeJoin( eventType: "CrumbleStrike", - state: - "Joining", + state: { + $in: [ + "Joining", + "Active" + ] + }, [`participants.${interaction.user.id}`]: { $exists: @@ -670,6 +890,10 @@ async function confirmCrumbleStrikeJoin( ); + /* + * Race-condition protection. + */ + if ( result.modifiedCount !== 1 @@ -703,11 +927,18 @@ async function confirmCrumbleStrikeJoin( ); + const lateJoin = + event.state === + "Active"; + + return interaction.update({ embeds: [ new EmbedBuilder() .setTitle( - "⚔️ You Joined Crumble Strike!" + lateJoin + ? "⚔️ Reinforcements Arrive!" + : "⚔️ You Joined Crumble Strike!" ) .setDescription( @@ -720,7 +951,11 @@ async function confirmCrumbleStrikeJoin( `🍪 Investment: **${tier.cost.toLocaleString()}**`, `❤️ HP: **${tier.health}**`, `💪 Power: **${tier.power}**`, - `⚡ Energy: **0/100**` + `⚡ Energy: **0/100**`, + "", + lateJoin + ? "You've joined the battle already in progress." + : "You're ready for battle." ].join("\n") ) ], @@ -731,7 +966,9 @@ async function confirmCrumbleStrikeJoin( /* + * ============================================================ * START BATTLE + * ============================================================ */ async function startCrumbleStrikeBattle( @@ -800,7 +1037,9 @@ async function startCrumbleStrikeBattle( /* - * ACTIVATE + * ============================================================ + * ACTIVATE BATTLE + * ============================================================ */ async function activateCrumbleStrike( @@ -831,7 +1070,7 @@ async function activateCrumbleStrike( if ( !event || event.state !== - "Joining" + "Joining" ) { return false; } @@ -856,11 +1095,17 @@ async function activateCrumbleStrike( getUnixTime(); + /* + * HP is locked when the battle begins. + * + * Late joiners do NOT increase boss HP. + */ + const maxHealth = - 15000 + + 12000 + ( participantCount * - 5000 + 4000 ); @@ -922,7 +1167,2250 @@ async function activateCrumbleStrike( /* + * ============================================================ + * 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( @@ -1001,7 +3489,9 @@ async function failCrumbleStrike( /* + * ============================================================ * CANCEL + REFUND + * ============================================================ */ async function cancelCrumbleStrikeAndRefund( @@ -1103,7 +3593,9 @@ async function cancelCrumbleStrikeAndRefund( /* - * REFRESH PUBLIC MESSAGE + * ============================================================ + * PUBLIC MESSAGE REFRESH + * ============================================================ */ async function refreshCrumbleStrikeMessage( @@ -1172,7 +3664,9 @@ async function refreshCrumbleStrikeMessage( /* - * PUBLIC MESSAGE + * ============================================================ + * PUBLIC EMBED + * ============================================================ */ function buildCrumbleStrikeMessage( @@ -1189,16 +3683,20 @@ function buildCrumbleStrikeMessage( participants.length; + const roleCounts = + getRoleCounts( + participants + ); + + + /* + * RECRUITMENT + */ + if ( event.state === "Joining" ) { - const roleCounts = - getRoleCounts( - participants - ); - - return { embed: new EmbedBuilder() @@ -1238,6 +3736,10 @@ function buildCrumbleStrikeMessage( } + /* + * ACTIVE + */ + if ( event.state === "Active" @@ -1249,6 +3751,19 @@ function buildCrumbleStrikeMessage( ); + const limit = + Math.min( + LIMIT_GAUGE_MAX, + event.boss.limitGauge ?? + 0 + ); + + + const bossGauge = + event.boss.actionGauge ?? + 0; + + return { embed: new EmbedBuilder() @@ -1260,20 +3775,119 @@ function buildCrumbleStrikeMessage( [ "## The Crumbbound Behemoth", "", - buildHpBar( + buildBar( hp, - event.boss.maxHealth + event.boss.maxHealth, + 20 ), `**${hp.toLocaleString()} / ${event.boss.maxHealth.toLocaleString()} HP**`, "", - `✨ Limit Gauge: **${event.boss.limitGauge ?? 0}/100**`, - `👹 Boss Gauge: **${event.boss.actionGauge ?? 0}/10**`, + "### ✨ 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:** `, "", - "Combat actions are being upgraded next." + "### ⚔️ 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") ) @@ -1287,6 +3901,10 @@ function buildCrumbleStrikeMessage( } + /* + * FAILED + */ + if ( event.state === "Failed" @@ -1300,9 +3918,9 @@ function buildCrumbleStrikeMessage( .setDescription( [ - "The Crumbbound Behemoth survived the encounter.", + "The Crumbbound Behemoth survived.", "", - `**${event.rewardPool.toLocaleString()} Cookies** were claimed by the ShaiBot Vault.` + `The committed **${event.rewardPool.toLocaleString()} Cookies** were claimed by the ShaiBot Vault.` ].join("\n") ) @@ -1316,6 +3934,10 @@ function buildCrumbleStrikeMessage( } + /* + * CANCELLED + */ + if ( event.state === "Cancelled" @@ -1328,7 +3950,7 @@ function buildCrumbleStrikeMessage( ) .setDescription( - "Not enough adventurers joined. All committed Cookies were refunded." + "Not enough adventurers joined during recruitment. All committed Cookies were refunded." ) .setFooter({ @@ -1347,15 +3969,9 @@ function buildCrumbleStrikeMessage( .setTitle( "🍪 Crumble Strike" ) - .setDescription( `Encounter state: **${event.state}**` - ) - - .setFooter({ - text: - `crumbleStrike:${event.eventId}` - }), + ), components: [] }; @@ -1363,7 +3979,135 @@ function buildCrumbleStrikeMessage( /* + * ============================================================ + * 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() { @@ -1434,7 +4178,9 @@ function buildRecruitmentButtons() { /* - * JOB MENU + * ============================================================ + * JOB SELECTION + * ============================================================ */ function buildJobSelectionEmbed( @@ -1444,7 +4190,7 @@ function buildJobSelectionEmbed( ) { const jobs = JOBS[ - role + role ]; @@ -1487,7 +4233,7 @@ function buildJobButtons( .addComponents( ...Object.entries( JOBS[ - role + role ] ) .map( @@ -1517,7 +4263,9 @@ function buildJobButtons( /* - * TIER MENU + * ============================================================ + * TIER SELECTION + * ============================================================ */ function buildTierSelectionEmbed( @@ -1607,7 +4355,9 @@ function buildTierButtons() { /* - * LOOKUPS + * ============================================================ + * EVENT LOOKUPS + * ============================================================ */ async function getCrumbleStrikeFromPublicMessage( @@ -1630,7 +4380,8 @@ async function getCrumbleStrikeFromPublicMessage( if ( type !== - "crumbleStrike" || + "crumbleStrike" + || !eventId ) { return null; @@ -1665,6 +4416,12 @@ async function getCrumbleStrikeByEventId( } +/* + * ============================================================ + * SELECTION FOOTER + * ============================================================ + */ + function parseSelectionFooter( interaction ) { @@ -1728,9 +4485,67 @@ function parseSelectionFooter( /* - * DISPLAY + * ============================================================ + * 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 ) { @@ -1747,6 +4562,12 @@ function formatJobProgression( } +/* + * ============================================================ + * DISPLAY HELPERS + * ============================================================ + */ + function getRoleEmoji( role ) { @@ -1782,7 +4603,7 @@ function getRoleCounts( ) { if ( counts[ - participant.role + participant.role ] !== undefined @@ -1798,7 +4619,7 @@ function getRoleCounts( } -function buildHpBar( +function buildBar( current, max, size = 20 @@ -1846,7 +4667,9 @@ function buildHpBar( /* - * SAFE COOKIE TRANSFER + * ============================================================ + * SAFE TRANSFER + * ============================================================ */ async function safeTransfer( @@ -1876,6 +4699,12 @@ async function safeTransfer( } +/* + * ============================================================ + * EXPORTS + * ============================================================ + */ + module.exports = { JOBS, TIERS, @@ -1887,6 +4716,9 @@ module.exports = { startCrumbleStrikeBattle, activateCrumbleStrike, + performCrumbleStrikeAction, + + resolveCrumbleStrikeVictory, failCrumbleStrike, cancelCrumbleStrikeAndRefund,