Files
ShaiBot/utils/crumbleStrike.js
T
2026-08-18 20:31:03 +02:00

4726 lines
87 KiB
JavaScript

const {
EmbedBuilder,
ActionRowBuilder,
ButtonBuilder,
ButtonStyle,
MessageFlags
} = require("discord.js");
const {
getCollections,
getCookieBalance,
transferCookies,
temporaryEphemeralReply,
temporaryEphemeralFollowUp,
getUnixTime,
randomInteger
} = require("./cookieEconomy");
const EVENT_COLLECTION =
"items_events";
/*
* ============================================================
* GENERAL CONFIG
* ============================================================
*/
const BATTLE_DURATION =
60 * 60;
const BOSS_ACTION_THRESHOLD =
10;
const LIMIT_GAUGE_MAX =
100;
/*
* ============================================================
* ACTION CONFIG
* ============================================================
*
* Roles / Jobs can override these values later.
*/
const ACTIONS = {
Strike: {
cooldown:
8,
energyGain:
12,
limitGain:
5,
powerMultiplier:
1.0
},
Defend: {
cooldown:
20,
energyCost:
5,
limitGain:
2,
reduction:
0.25,
charges:
2
},
Heal: {
cooldown:
25,
energyCost:
10,
limitGain:
3,
powerMultiplier:
1.0
}
};
/*
* ============================================================
* JOBS
* ============================================================
*
* Every job uses the same buttons.
*
* Job modifiers simply make certain actions
* more efficient.
*/
const JOBS = {
DPS: {
BlackMage: {
name:
"Black Mage",
class:
"Thaumaturge",
emoji:
"🔥",
strikeMultiplier:
1.40,
strikeEnergy:
10,
strikeLimit:
5
},
Dragoon: {
name:
"Dragoon",
class:
"Lancer",
emoji:
"🐉",
strikeMultiplier:
1.30,
strikeEnergy:
12,
strikeLimit:
7
},
Bard: {
name:
"Bard",
class:
"Archer",
emoji:
"🏹",
strikeMultiplier:
1.15,
strikeEnergy:
14,
strikeLimit:
9
}
},
Tank: {
Paladin: {
name:
"Paladin",
class:
"Gladiator",
emoji:
"⚔️",
strikeMultiplier:
0.90,
defendCost:
2,
defendReduction:
0.55,
defendCharges:
3
},
Warrior: {
name:
"Warrior",
class:
"Marauder",
emoji:
"🪓",
strikeMultiplier:
1.05,
defendCost:
3,
defendReduction:
0.45,
defendCharges:
2
},
DarkKnight: {
name:
"Dark Knight",
class:
null,
emoji:
"🌑",
strikeMultiplier:
1.10,
defendCost:
3,
defendReduction:
0.50,
defendCharges:
2
}
},
Healer: {
WhiteMage: {
name:
"White Mage",
class:
"Conjurer",
emoji:
"✨",
strikeMultiplier:
0.75,
healCost:
5,
healMultiplier:
1.55,
healLimit:
3
},
Scholar: {
name:
"Scholar",
class:
"Arcanist",
emoji:
"📖",
strikeMultiplier:
0.80,
healCost:
6,
healMultiplier:
1.35,
healLimit:
4
},
Astrologian: {
name:
"Astrologian",
class:
null,
emoji:
"🌟",
strikeMultiplier:
0.80,
healCost:
6,
healMultiplier:
1.25,
healLimit:
6
}
}
};
/*
* ============================================================
* INVESTMENT TIERS
* ============================================================
*/
const TIERS = {
Sprout: {
name:
"Sprout",
emoji:
"🌱",
cost:
100,
health:
180,
power:
20
},
Adventurer: {
name:
"Adventurer",
emoji:
"⚔️",
cost:
500,
health:
280,
power:
35
},
WarriorOfLight: {
name:
"Warrior of Light",
emoji:
"✨",
cost:
1000,
health:
380,
power:
50
}
};
/*
* ============================================================
* ROLE SELECTION
* ============================================================
*
* Joining is allowed both during recruitment
* and during an active battle.
*/
async function chooseCrumbleStrikeRole(
interaction,
role
) {
const event =
await getCrumbleStrikeFromPublicMessage(
interaction
);
if (!event) {
return temporaryEphemeralReply(
interaction,
{
content:
"This Crumble Strike encounter no longer exists."
}
);
}
if (
![
"Joining",
"Active"
].includes(
event.state
)
) {
return temporaryEphemeralReply(
interaction,
{
content:
"This encounter can no longer be joined."
}
);
}
/*
* Recruitment timer only matters before
* combat has started.
*/
if (
event.state ===
"Joining"
&&
event.joinUntil <=
getUnixTime()
) {
return temporaryEphemeralReply(
interaction,
{
content:
"Recruitment has already expired."
}
);
}
const participant =
event.participants?.[
interaction.user.id
];
if (participant) {
return temporaryEphemeralReply(
interaction,
{
content:
`You're already participating as **${participant.job}**.`
}
);
}
if (!JOBS[role]) {
return temporaryEphemeralReply(
interaction,
{
content:
"That role doesn't exist."
}
);
}
return interaction.reply({
embeds: [
buildJobSelectionEmbed(
event,
interaction.user,
role
)
],
components:
buildJobButtons(
role
),
flags:
MessageFlags.Ephemeral
});
}
/*
* ============================================================
* JOB SELECTION
* ============================================================
*/
async function chooseCrumbleStrikeJob(
interaction,
role,
jobKey
) {
const metadata =
parseSelectionFooter(
interaction
);
if (
metadata.type !==
"crumbleStrikeJob"
||
metadata.userId !==
interaction.user.id
||
metadata.role !==
role
) {
return temporaryEphemeralReply(
interaction,
{
content:
"This character selection is no longer valid."
}
);
}
const job =
JOBS[role]?.[
jobKey
];
if (!job) {
return temporaryEphemeralReply(
interaction,
{
content:
"That Job isn't available."
}
);
}
const event =
await getCrumbleStrikeByEventId(
metadata.eventId
);
if (
!event
||
![
"Joining",
"Active"
].includes(
event.state
)
) {
return interaction.update({
embeds: [
new EmbedBuilder()
.setTitle(
"🍪 Entry Closed"
)
.setDescription(
"This encounter is no longer accepting adventurers."
)
],
components: []
});
}
return interaction.update({
embeds: [
buildTierSelectionEmbed(
event,
interaction.user,
role,
jobKey,
job
)
],
components:
buildTierButtons()
});
}
/*
* ============================================================
* CONFIRM JOIN
* ============================================================
*/
async function confirmCrumbleStrikeJoin(
interaction,
tierKey
) {
const metadata =
parseSelectionFooter(
interaction
);
if (
metadata.type !==
"crumbleStrikeTier"
||
metadata.userId !==
interaction.user.id
) {
return temporaryEphemeralReply(
interaction,
{
content:
"This character selection is no longer valid."
}
);
}
const role =
metadata.role;
const jobKey =
metadata.job;
const job =
JOBS[role]?.[
jobKey
];
const tier =
TIERS[
tierKey
];
if (
!job ||
!tier
) {
return temporaryEphemeralReply(
interaction,
{
content:
"That character setup is invalid."
}
);
}
const {
db
} =
getCollections();
const events =
db.collection(
EVENT_COLLECTION
);
const event =
await events.findOne({
eventId:
metadata.eventId,
eventType:
"CrumbleStrike"
});
if (
!event
||
![
"Joining",
"Active"
].includes(
event.state
)
) {
return interaction.update({
embeds: [
new EmbedBuilder()
.setTitle(
"🍪 Entry Closed"
)
.setDescription(
"The battle can no longer be joined."
)
],
components: []
});
}
if (
event.participants?.[
interaction.user.id
]
) {
return interaction.update({
embeds: [
new EmbedBuilder()
.setTitle(
"🍪 Already Joined"
)
.setDescription(
"You're already participating."
)
],
components: []
});
}
const balance =
await getCookieBalance(
interaction.user.id
);
if (
balance <
tier.cost
) {
return temporaryEphemeralReply(
interaction,
{
content:
`You need **${tier.cost.toLocaleString()} Cookies** to join as a ${tier.name}.`
}
);
}
try {
await transferCookies(
interaction.user.id,
event.escrowId,
tier.cost
);
} catch (error) {
if (
error.message ===
"INSUFFICIENT_COOKIES"
) {
return temporaryEphemeralReply(
interaction,
{
content:
"Your Cookie balance changed before you could join."
}
);
}
throw error;
}
const now =
getUnixTime();
const participant = {
role,
job:
job.name,
jobKey,
class:
job.class,
tier:
tierKey,
investment:
tier.cost,
health:
tier.health,
maxHealth:
tier.health,
power:
tier.power,
energy:
0,
maxEnergy:
100,
damage:
0,
healing:
0,
mitigation:
0,
defense: {
charges:
0,
reduction:
0
},
knockedOut:
false,
joinedAt:
now,
cooldowns: {
Strike:
0,
Defend:
0,
Heal:
0
}
};
const result =
await events.updateOne(
{
eventId:
event.eventId,
eventType:
"CrumbleStrike",
state: {
$in: [
"Joining",
"Active"
]
},
[`participants.${interaction.user.id}`]: {
$exists:
false
}
},
{
$set: {
[`participants.${interaction.user.id}`]:
participant
},
$inc: {
rewardPool:
tier.cost
}
}
);
/*
* Race-condition protection.
*/
if (
result.modifiedCount !==
1
) {
await safeTransfer(
event.escrowId,
interaction.user.id,
tier.cost
);
return interaction.update({
embeds: [
new EmbedBuilder()
.setTitle(
"🍪 Unable to Join"
)
.setDescription(
"Your entry could not be registered. Your Cookies were refunded."
)
],
components: []
});
}
await refreshCrumbleStrikeMessage(
interaction.client,
event.eventId
);
const lateJoin =
event.state ===
"Active";
return interaction.update({
embeds: [
new EmbedBuilder()
.setTitle(
lateJoin
? "⚔️ Reinforcements Arrive!"
: "⚔️ You Joined Crumble Strike!"
)
.setDescription(
[
`### ${job.emoji} ${formatJobProgression(job)}`,
"",
`${getRoleEmoji(role)} **${role}**`,
`${tier.emoji} **${tier.name}**`,
"",
`🍪 Investment: **${tier.cost.toLocaleString()}**`,
`❤️ HP: **${tier.health}**`,
`💪 Power: **${tier.power}**`,
`⚡ Energy: **0/100**`,
"",
lateJoin
? "You've joined the battle already in progress."
: "You're ready for battle."
].join("\n")
)
],
components: []
});
}
/*
* ============================================================
* START BATTLE
* ============================================================
*/
async function startCrumbleStrikeBattle(
interaction
) {
const event =
await getCrumbleStrikeFromPublicMessage(
interaction
);
if (!event) {
return temporaryEphemeralReply(
interaction,
{
content:
"This encounter no longer exists."
}
);
}
if (
interaction.user.id !==
event.creatorId
) {
return temporaryEphemeralReply(
interaction,
{
content:
"Only the player who summoned the boss can start the battle early."
}
);
}
const participantCount =
Object.keys(
event.participants ??
{}
).length;
if (
participantCount <
event.minimumParticipants
) {
return temporaryEphemeralReply(
interaction,
{
content:
`You need at least **${event.minimumParticipants} players**. Currently **${participantCount}/${event.minimumParticipants}**.`
}
);
}
await interaction.deferUpdate();
return activateCrumbleStrike(
interaction.client,
event.eventId
);
}
/*
* ============================================================
* ACTIVATE BATTLE
* ============================================================
*/
async function activateCrumbleStrike(
client,
eventId
) {
const {
db
} =
getCollections();
const events =
db.collection(
EVENT_COLLECTION
);
const event =
await events.findOne({
eventId,
eventType:
"CrumbleStrike"
});
if (
!event ||
event.state !==
"Joining"
) {
return false;
}
const participantCount =
Object.keys(
event.participants ??
{}
).length;
if (
participantCount <
event.minimumParticipants
) {
return false;
}
const now =
getUnixTime();
/*
* HP is locked when the battle begins.
*
* Late joiners do NOT increase boss HP.
*/
const maxHealth =
12000 +
(
participantCount *
4000
);
const result =
await events.updateOne(
{
eventId,
eventType:
"CrumbleStrike",
state:
"Joining"
},
{
$set: {
state:
"Active",
battleStartedAt:
now,
battleEndsAt:
now +
BATTLE_DURATION,
"boss.health":
maxHealth,
"boss.maxHealth":
maxHealth,
"boss.limitGauge":
0,
"boss.actionGauge":
0
}
}
);
if (
result.modifiedCount !==
1
) {
return false;
}
await refreshCrumbleStrikeMessage(
client,
eventId
);
return true;
}
/*
* ============================================================
* COMBAT ACTION
* ============================================================
*/
async function performRevive(
interaction,
event,
player
) {
const now =
getUnixTime();
const cooldown =
player.cooldowns?.Revive ??
0;
if (
cooldown >
now
) {
return temporaryEphemeralReply(
interaction,
{
content:
`🌟 Revive is ready <t:${cooldown}:R>.`
}
);
}
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 <t:${cooldown}:R>.`
}
);
}
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 <t:${cooldown}:R>.`
}
);
}
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 <t:${cooldown}:R>.`
}
);
}
const job =
getParticipantJob(
player
);
const energyCost =
job?.healCost ??
ACTIONS.Heal.energyCost;
const multiplier =
job?.healMultiplier ??
ACTIONS.Heal.powerMultiplier;
const limitGain =
job?.healLimit ??
ACTIONS.Heal.limitGain;
if (
player.energy <
energyCost
) {
return temporaryEphemeralReply(
interaction,
{
content:
`❤️ Heal requires **${energyCost} Energy**. You currently have **${player.energy}**.`
}
);
}
const healPerPlayer =
Math.max(
1,
Math.floor(
player.power *
multiplier
)
);
await interaction.deferUpdate();
const {
db
} =
getCollections();
const events =
db.collection(
EVENT_COLLECTION
);
/*
* Re-fetch immediately before healing.
*/
const latest =
await events.findOne({
eventId:
event.eventId,
state:
"Active"
});
if (!latest) {
return;
}
let totalHealing =
0;
let healedPlayers =
0;
const setOperations = {};
for (
const [
userId,
target
]
of Object.entries(
latest.participants ??
{}
)
) {
/*
* Heal does not revive KO'd players.
*/
if (
target.knockedOut ||
target.health <= 0 ||
target.health >=
target.maxHealth
) {
continue;
}
const newHealth =
Math.min(
target.maxHealth,
target.health +
healPerPlayer
);
const actualHealing =
newHealth -
target.health;
if (
actualHealing <=
0
) {
continue;
}
setOperations[
`participants.${userId}.health`
] =
newHealth;
totalHealing +=
actualHealing;
healedPlayers++;
}
setOperations[
`participants.${interaction.user.id}.cooldowns.Heal`
] =
now +
ACTIONS.Heal.cooldown;
const result =
await events.updateOne(
{
eventId:
event.eventId,
state:
"Active",
[`participants.${interaction.user.id}.energy`]: {
$gte:
energyCost
}
},
{
$set:
setOperations,
$inc: {
"boss.actionGauge":
1,
"boss.limitGauge":
limitGain,
[`participants.${interaction.user.id}.energy`]:
-energyCost,
[`participants.${interaction.user.id}.healing`]:
totalHealing
}
}
);
if (
result.modifiedCount !==
1
) {
return temporaryEphemeralFollowUp(
interaction,
{
content:
"That heal could not be resolved."
}
);
}
await clampBossGauges(
event.eventId
);
const bossAttack =
await resolveBossTurnIfReady(
interaction.client,
event.eventId
);
await refreshCrumbleStrikeMessage(
interaction.client,
event.eventId
);
const resultEmbed =
new EmbedBuilder()
.setTitle(
"❤️ Party Heal"
)
.setDescription(
`${getJobEmoji(player)} **${player.job}** restores the party.`
)
.addFields(
{
name:
"Healing",
value:
`**${totalHealing.toLocaleString()} HP**`,
inline:
true
},
{
name:
"Players Restored",
value:
`**${healedPlayers}**`,
inline:
true
},
{
name:
"Energy",
value:
`**${player.energy - energyCost}/${player.maxEnergy}**`,
inline:
true
},
{
name:
"Limit Generated",
value:
`**+${limitGain}**`,
inline:
true
}
);
if (bossAttack) {
resultEmbed.addFields({
name:
"👹 Boss Turn",
value:
bossAttack.summary
});
}
return temporaryEphemeralFollowUp(
interaction,
{
embeds: [
resultEmbed
]
},
8000
);
}
/*
* ============================================================
* LIMIT BREAK
* ============================================================
*/
async function performLimitBreak(
interaction,
event,
player
) {
if (
event.boss.limitGauge <
LIMIT_GAUGE_MAX
) {
return temporaryEphemeralReply(
interaction,
{
content:
`✨ The Limit Gauge is only **${event.boss.limitGauge}/${LIMIT_GAUGE_MAX}**.`
}
);
}
const livingPlayers =
Object.values(
event.participants ??
{}
)
.filter(
participant =>
!participant.knockedOut
);
const totalPartyPower =
livingPlayers.reduce(
(
total,
participant
) =>
total +
participant.power,
0
);
/*
* Huge scaling hit:
*
* 8% boss max HP
* +
* 8x total living party Power
*/
const damage =
Math.floor(
(
event.boss.maxHealth *
0.08
)
+
(
totalPartyPower *
8
)
);
await interaction.deferUpdate();
const {
db
} =
getCollections();
const events =
db.collection(
EVENT_COLLECTION
);
/*
* Gauge check is repeated atomically.
*
* Only one player can successfully spend it.
*/
const result =
await events.updateOne(
{
eventId:
event.eventId,
state:
"Active",
"boss.limitGauge": {
$gte:
LIMIT_GAUGE_MAX
},
"boss.health": {
$gt:
0
}
},
{
$inc: {
"boss.health":
-damage,
"boss.actionGauge":
1,
[`participants.${interaction.user.id}.damage`]:
damage
},
$set: {
"boss.limitGauge":
0
}
}
);
if (
result.modifiedCount !==
1
) {
return temporaryEphemeralFollowUp(
interaction,
{
content:
"Someone else used the Limit Break first!"
}
);
}
const bossAttack =
await resolveBossTurnIfReady(
interaction.client,
event.eventId
);
const victory =
await checkForVictory(
interaction.client,
event.eventId
);
if (!victory) {
await refreshCrumbleStrikeMessage(
interaction.client,
event.eventId
);
}
const resultEmbed =
new EmbedBuilder()
.setTitle(
"✨ LIMIT BREAK!"
)
.setDescription(
[
`${getJobEmoji(player)} **${player.job}** unleashes`,
"",
"## ULTIMATE COOKIE CALAMITY",
"",
`💥 **${damage.toLocaleString()} DAMAGE!**`
].join("\n")
);
if (bossAttack) {
resultEmbed.addFields({
name:
"👹 Boss Turn",
value:
bossAttack.summary
});
}
return temporaryEphemeralFollowUp(
interaction,
{
embeds: [
resultEmbed
]
},
10000
);
}
/*
* ============================================================
* BOSS TURN
* ============================================================
*/
async function resolveBossTurnIfReady(
client,
eventId
) {
const {
db
} =
getCollections();
const events =
db.collection(
EVENT_COLLECTION
);
const event =
await events.findOne({
eventId,
eventType:
"CrumbleStrike",
state:
"Active",
"boss.actionGauge": {
$gte:
BOSS_ACTION_THRESHOLD
}
});
if (!event) {
return null;
}
/*
* Claim the boss turn.
*/
const claim =
await events.updateOne(
{
eventId,
state:
"Active",
"boss.actionGauge": {
$gte:
BOSS_ACTION_THRESHOLD
}
},
{
$inc: {
"boss.actionGauge":
-BOSS_ACTION_THRESHOLD
}
}
);
if (
claim.modifiedCount !==
1
) {
return null;
}
const latest =
await events.findOne({
eventId
});
const setOperations = {};
const incOperations = {};
let totalDamage =
0;
let totalMitigated =
0;
let knockedOut =
0;
const baseDamage =
randomInteger(
45,
75
);
for (
const [
userId,
participant
]
of Object.entries(
latest.participants ??
{}
)
) {
if (
participant.knockedOut
) {
continue;
}
let damage =
baseDamage;
const defense =
participant.defense ??
{
charges: 0,
reduction: 0
};
if (
defense.charges >
0
) {
const reduced =
Math.floor(
damage *
defense.reduction
);
damage -=
reduced;
totalMitigated +=
reduced;
incOperations[
`participants.${userId}.mitigation`
] =
reduced;
setOperations[
`participants.${userId}.defense.charges`
] =
Math.max(
0,
defense.charges -
1
);
}
const health =
Math.max(
0,
participant.health -
damage
);
setOperations[
`participants.${userId}.health`
] =
health;
if (
health <=
0
) {
setOperations[
`participants.${userId}.knockedOut`
] =
true;
knockedOut++;
}
totalDamage +=
damage;
}
const update = {
$set:
setOperations
};
if (
Object.keys(
incOperations
).length
) {
update.$inc =
incOperations;
}
await events.updateOne(
{
eventId,
state:
"Active"
},
update
);
/*
* If everybody is KO'd we don't immediately
* fail the whole event.
*
* Late reinforcements may still join.
*/
return {
damage:
totalDamage,
mitigation:
totalMitigated,
knockedOut,
summary:
[
`💥 **Crumbquake!**`,
`Party Damage: **${totalDamage.toLocaleString()}**`,
totalMitigated > 0
? `Mitigated: **${totalMitigated.toLocaleString()}**`
: null,
knockedOut > 0
? `💀 Knocked Out: **${knockedOut}**`
: null
]
.filter(Boolean)
.join("\n")
};
}
/*
* ============================================================
* LIMIT GAUGE CLAMP
* ============================================================
*/
async function clampBossGauges(
eventId
) {
const {
db
} =
getCollections();
const events =
db.collection(
EVENT_COLLECTION
);
await events.updateOne(
{
eventId,
"boss.limitGauge": {
$gt:
LIMIT_GAUGE_MAX
}
},
{
$set: {
"boss.limitGauge":
LIMIT_GAUGE_MAX
}
}
);
}
/*
* ============================================================
* VICTORY CHECK
* ============================================================
*/
async function checkForVictory(
client,
eventId
) {
const {
db
} =
getCollections();
const events =
db.collection(
EVENT_COLLECTION
);
const event =
await events.findOne({
eventId,
eventType:
"CrumbleStrike",
state:
"Active"
});
if (
!event ||
event.boss.health >
0
) {
return false;
}
await events.updateOne(
{
eventId,
state:
"Active"
},
{
$set: {
"boss.health":
0
}
}
);
return resolveCrumbleStrikeVictory(
client,
eventId
);
}
/*
* ============================================================
* VICTORY + REWARDS
* ============================================================
*/
async function resolveCrumbleStrikeVictory(
client,
eventId
) {
const {
db
} =
getCollections();
const events =
db.collection(
EVENT_COLLECTION
);
/*
* Atomically claim resolution.
*/
const event =
await events.findOneAndUpdate(
{
eventId,
eventType:
"CrumbleStrike",
state:
"Active"
},
{
$set: {
state:
"Resolving",
"boss.health":
0
}
},
{
returnDocument:
"after"
}
);
if (!event) {
return false;
}
const participants =
Object.entries(
event.participants ??
{}
);
const rewardPool =
await getCookieBalance(
event.escrowId
);
/*
* 30% evenly distributed.
* 70% based on combat contribution.
*/
const participationPool =
Math.floor(
rewardPool *
0.30
);
const contributionPool =
rewardPool -
participationPool;
const evenShare =
participants.length
? Math.floor(
participationPool /
participants.length
)
: 0;
const scored =
participants.map(
(
[
userId,
participant
]
) => {
const contribution =
getContributionScore(
participant
);
return {
userId,
participant,
contribution
};
}
);
const totalContribution =
scored.reduce(
(
total,
player
) =>
total +
player.contribution,
0
);
const rewards =
[];
let distributed =
0;
for (
const player
of scored
) {
const contributionReward =
totalContribution >
0
? Math.floor(
contributionPool *
(
player.contribution /
totalContribution
)
)
: 0;
const reward =
evenShare +
contributionReward;
if (
reward >
0
) {
const paid =
await safeTransfer(
event.escrowId,
player.userId,
reward
);
if (paid) {
distributed +=
reward;
}
}
rewards.push({
userId:
player.userId,
job:
player.participant.job,
role:
player.participant.role,
damage:
player.participant.damage ??
0,
healing:
player.participant.healing ??
0,
mitigation:
player.participant.mitigation ??
0,
contribution:
player.contribution,
reward
});
}
/*
* Rounding remainder goes to highest
* contribution.
*/
const remaining =
await getCookieBalance(
event.escrowId
);
if (
remaining >
0 &&
rewards.length
) {
const top =
rewards
.slice()
.sort(
(
a,
b
) =>
b.contribution -
a.contribution
)[0];
const paid =
await safeTransfer(
event.escrowId,
top.userId,
remaining
);
if (paid) {
top.reward +=
remaining;
}
}
await events.updateOne(
{
eventId,
state:
"Resolving"
},
{
$set: {
state:
"Defeated",
defeatedAt:
getUnixTime(),
rewards
}
}
);
await refreshCrumbleStrikeMessage(
client,
eventId
);
return true;
}
function getContributionScore(
participant
) {
return Math.floor(
(
participant.damage ??
0
)
+
(
(
participant.healing ??
0
)
*
0.80
)
+
(
(
participant.mitigation ??
0
)
*
0.80
)
);
}
/*
* ============================================================
* FAILURE
* ============================================================
*/
async function failCrumbleStrike(
client,
eventId
) {
const {
db
} =
getCollections();
const events =
db.collection(
EVENT_COLLECTION
);
const event =
await events.findOneAndUpdate(
{
eventId,
eventType:
"CrumbleStrike",
state:
"Active"
},
{
$set: {
state:
"Failed",
failedAt:
getUnixTime()
}
},
{
returnDocument:
"after"
}
);
if (!event)
return false;
const escrow =
await getCookieBalance(
event.escrowId
);
if (
escrow >
0
) {
await safeTransfer(
event.escrowId,
client.user.id,
escrow
);
}
await refreshCrumbleStrikeMessage(
client,
eventId
);
return true;
}
/*
* ============================================================
* CANCEL + REFUND
* ============================================================
*/
async function cancelCrumbleStrikeAndRefund(
client,
eventId
) {
const {
db
} =
getCollections();
const events =
db.collection(
EVENT_COLLECTION
);
const event =
await events.findOneAndUpdate(
{
eventId,
eventType:
"CrumbleStrike",
state:
"Joining"
},
{
$set: {
state:
"Cancelled",
cancelledAt:
getUnixTime()
}
},
{
returnDocument:
"after"
}
);
if (!event)
return false;
for (
const [
userId,
participant
]
of Object.entries(
event.participants ??
{}
)
) {
if (
participant.investment >
0
) {
await safeTransfer(
event.escrowId,
userId,
participant.investment
);
}
}
const remaining =
await getCookieBalance(
event.escrowId
);
if (
remaining >
0
) {
await safeTransfer(
event.escrowId,
event.creatorId,
remaining
);
}
await refreshCrumbleStrikeMessage(
client,
eventId
);
return true;
}
/*
* ============================================================
* PUBLIC MESSAGE REFRESH
* ============================================================
*/
async function refreshCrumbleStrikeMessage(
client,
eventId
) {
const event =
await getCrumbleStrikeByEventId(
eventId
);
if (!event)
return;
let channel;
try {
channel =
await client.channels.fetch(
event.channelId
);
} catch {
return;
}
if (!channel)
return;
let message;
try {
message =
await channel.messages.fetch(
event.messageId
);
} catch {
return;
}
const {
embed,
components
} =
buildCrumbleStrikeMessage(
event
);
await message.edit({
embeds: [
embed
],
components
});
}
/*
* ============================================================
* PUBLIC EMBED
* ============================================================
*/
function buildCrumbleStrikeMessage(
event
) {
const participants =
Object.values(
event.participants ??
{}
);
const participantCount =
participants.length;
const roleCounts =
getRoleCounts(
participants
);
/*
* RECRUITMENT
*/
if (
event.state ===
"Joining"
) {
return {
embed:
new EmbedBuilder()
.setTitle(
"🍪 Crumble Strike"
)
.setDescription(
[
"## The Crumbbound Behemoth",
"",
"*A creature of hunger, crumbs, and extremely questionable dietary choices.*",
"",
`**Recruitment ends:** <t:${event.joinUntil}:R>`,
`**Participants:** ${participantCount}/${event.minimumParticipants} required`,
`**Reward Pool:** ${event.rewardPool.toLocaleString()} 🍪`,
"",
`⚔️ DPS: **${roleCounts.DPS}**`,
`🛡️ Tanks: **${roleCounts.Tank}**`,
`❤️ Healers: **${roleCounts.Healer}**`,
"",
participantCount >=
event.minimumParticipants
? "✅ The summoner may start the battle early."
: "Choose your role below to join."
].join("\n")
)
.setFooter({
text:
`crumbleStrike:${event.eventId}`
}),
components:
buildRecruitmentButtons()
};
}
/*
* ACTIVE
*/
if (
event.state ===
"Active"
) {
const hp =
Math.max(
0,
event.boss.health
);
const limit =
Math.min(
LIMIT_GAUGE_MAX,
event.boss.limitGauge ??
0
);
const bossGauge =
event.boss.actionGauge ??
0;
return {
embed:
new EmbedBuilder()
.setTitle(
"⚔️ Crumble Strike"
)
.setDescription(
[
"## The Crumbbound Behemoth",
"",
buildBar(
hp,
event.boss.maxHealth,
20
),
`**${hp.toLocaleString()} / ${event.boss.maxHealth.toLocaleString()} HP**`,
"",
"### ✨ Limit Break",
buildBar(
limit,
LIMIT_GAUGE_MAX,
20
),
`**${limit}/${LIMIT_GAUGE_MAX}**`,
"",
`👹 **Boss Turn:** ${bossGauge}/${BOSS_ACTION_THRESHOLD}`,
"",
`**Participants:** ${participantCount}`,
`⚔️ ${roleCounts.DPS} DPS • 🛡️ ${roleCounts.Tank} Tanks • ❤️ ${roleCounts.Healer} Healers`,
`**Reward Pool:** ${event.rewardPool.toLocaleString()} 🍪`,
`**Battle ends:** <t:${event.battleEndsAt}:R>`,
"",
"### ⚔️ Battle Actions",
"**⚔️ Strike**",
"Low damage • Generates Energy and Limit Gauge • 8s cooldown",
"",
"**🛡️ Defend**",
"Reduces damage from upcoming boss attacks • Base cost: 5 Energy",
"",
"**❤️ Heal**",
"Restores HP to all living party members • Base cost: 10 Energy",
"",
"**✨ Limit Break**",
"Massive shared attack • Requires a full 100-point Limit Gauge",
"",
"*Your Job modifies how effective these actions are.*"
].join("\n")
)
.setFooter({
text:
`crumbleStrike:${event.eventId}`
}),
/*
* Combat + late joining.
*/
components: [
...buildCombatButtons(),
...buildLateJoinButtons()
]
};
}
/*
* DEFEATED
*/
if (
event.state ===
"Defeated"
) {
const rewards =
(
event.rewards ??
[]
)
.slice()
.sort(
(
a,
b
) =>
b.contribution -
a.contribution
);
const leaderboard =
rewards
.slice(
0,
10
)
.map(
(
player,
index
) =>
[
`**${index + 1}.** <@${player.userId}> — ${player.job}`,
`⚔️ ${player.damage.toLocaleString()} • ❤️ ${player.healing.toLocaleString()} • 🛡️ ${player.mitigation.toLocaleString()}`,
`🍪 **${player.reward.toLocaleString()}**`
].join("\n")
)
.join("\n\n");
return {
embed:
new EmbedBuilder()
.setTitle(
"🏆 Crumble Strike Complete!"
)
.setDescription(
[
"## The Crumbbound Behemoth Has Fallen!",
"",
leaderboard ||
"The beast was defeated."
].join("\n")
)
.setFooter({
text:
`crumbleStrike:${event.eventId}`
}),
components: []
};
}
/*
* FAILED
*/
if (
event.state ===
"Failed"
) {
return {
embed:
new EmbedBuilder()
.setTitle(
"💀 Crumble Strike Failed"
)
.setDescription(
[
"The Crumbbound Behemoth survived.",
"",
`The committed **${event.rewardPool.toLocaleString()} Cookies** were claimed by the ShaiBot Vault.`
].join("\n")
)
.setFooter({
text:
`crumbleStrike:${event.eventId}`
}),
components: []
};
}
/*
* CANCELLED
*/
if (
event.state ===
"Cancelled"
) {
return {
embed:
new EmbedBuilder()
.setTitle(
"🍪 Crumble Strike Cancelled"
)
.setDescription(
"Not enough adventurers joined during recruitment. All committed Cookies were refunded."
)
.setFooter({
text:
`crumbleStrike:${event.eventId}`
}),
components: []
};
}
return {
embed:
new EmbedBuilder()
.setTitle(
"🍪 Crumble Strike"
)
.setDescription(
`Encounter state: **${event.state}**`
),
components: []
};
}
/*
* ============================================================
* COMBAT BUTTONS
* ============================================================
*/
function buildCombatButtons() {
return [
new ActionRowBuilder()
.addComponents(
new ButtonBuilder()
.setCustomId(
"crumbleStrikeStrike"
)
.setLabel(
"Strike"
)
.setEmoji(
"⚔️"
)
.setStyle(
ButtonStyle.Danger
),
new ButtonBuilder()
.setCustomId(
"crumbleStrikeDefend"
)
.setLabel(
"Defend"
)
.setEmoji(
"🛡️"
)
.setStyle(
ButtonStyle.Primary
),
new ButtonBuilder()
.setCustomId(
"crumbleStrikeHeal"
)
.setLabel(
"Heal"
)
.setEmoji(
"❤️"
)
.setStyle(
ButtonStyle.Success
),
new ButtonBuilder()
.setCustomId(
"crumbleStrikeLimitBreak"
)
.setLabel(
"Limit Break"
)
.setEmoji(
"✨"
)
.setStyle(
ButtonStyle.Secondary
)
)
];
}
/*
* ============================================================
* LATE JOIN BUTTONS
* ============================================================
*/
function buildLateJoinButtons() {
return [
new ActionRowBuilder()
.addComponents(
new ButtonBuilder()
.setCustomId(
"crumbleStrikeRoleDPS"
)
.setLabel(
"Join DPS"
)
.setEmoji(
"⚔️"
)
.setStyle(
ButtonStyle.Danger
),
new ButtonBuilder()
.setCustomId(
"crumbleStrikeRoleTank"
)
.setLabel(
"Join Tank"
)
.setEmoji(
"🛡️"
)
.setStyle(
ButtonStyle.Primary
),
new ButtonBuilder()
.setCustomId(
"crumbleStrikeRoleHealer"
)
.setLabel(
"Join Healer"
)
.setEmoji(
"❤️"
)
.setStyle(
ButtonStyle.Success
)
)
];
}
/*
* ============================================================
* RECRUITMENT BUTTONS
* ============================================================
*/
function buildRecruitmentButtons() {
return [
new ActionRowBuilder()
.addComponents(
new ButtonBuilder()
.setCustomId(
"crumbleStrikeRoleDPS"
)
.setLabel(
"DPS"
)
.setEmoji(
"⚔️"
)
.setStyle(
ButtonStyle.Danger
),
new ButtonBuilder()
.setCustomId(
"crumbleStrikeRoleTank"
)
.setLabel(
"Tank"
)
.setEmoji(
"🛡️"
)
.setStyle(
ButtonStyle.Primary
),
new ButtonBuilder()
.setCustomId(
"crumbleStrikeRoleHealer"
)
.setLabel(
"Healer"
)
.setEmoji(
"❤️"
)
.setStyle(
ButtonStyle.Success
)
),
new ActionRowBuilder()
.addComponents(
new ButtonBuilder()
.setCustomId(
"crumbleStrikeStart"
)
.setLabel(
"Start Battle"
)
.setEmoji(
"⚔️"
)
.setStyle(
ButtonStyle.Secondary
)
)
];
}
/*
* ============================================================
* JOB SELECTION
* ============================================================
*/
function buildJobSelectionEmbed(
event,
user,
role
) {
const jobs =
JOBS[
role
];
const description =
Object.values(
jobs
)
.map(
job =>
`${job.emoji} **${formatJobProgression(job)}**`
)
.join("\n");
return new EmbedBuilder()
.setTitle(
`${getRoleEmoji(role)} Choose Your ${role} Job`
)
.setDescription(
[
description,
"",
"Choose the Job you want to bring into battle."
].join("\n")
)
.setFooter({
text:
`crumbleStrikeJob:${event.eventId}:${user.id}:${role}`
});
}
function buildJobButtons(
role
) {
return [
new ActionRowBuilder()
.addComponents(
...Object.entries(
JOBS[
role
]
)
.map(
(
[
key,
job
]
) =>
new ButtonBuilder()
.setCustomId(
`crumbleStrikeJob${key}`
)
.setLabel(
job.name
)
.setEmoji(
job.emoji
)
.setStyle(
ButtonStyle.Primary
)
)
)
];
}
/*
* ============================================================
* TIER SELECTION
* ============================================================
*/
function buildTierSelectionEmbed(
event,
user,
role,
jobKey,
job
) {
return new EmbedBuilder()
.setTitle(
`${job.emoji} ${formatJobProgression(job)}`
)
.setDescription(
[
`${getRoleEmoji(role)} **${role}**`,
"",
"Choose your commitment.",
"",
"🌱 **Sprout — 100 🍪**",
"Entry-level HP and Power.",
"",
"⚔️ **Adventurer — 500 🍪**",
"Improved HP and Power.",
"",
"✨ **Warrior of Light — 1,000 🍪**",
"Highest starting HP and Power."
].join("\n")
)
.setFooter({
text:
`crumbleStrikeTier:${event.eventId}:${user.id}:${role}:${jobKey}`
});
}
function buildTierButtons() {
return [
new ActionRowBuilder()
.addComponents(
new ButtonBuilder()
.setCustomId(
"crumbleStrikeTierSprout"
)
.setLabel(
"Sprout — 100"
)
.setEmoji(
"🌱"
)
.setStyle(
ButtonStyle.Secondary
),
new ButtonBuilder()
.setCustomId(
"crumbleStrikeTierAdventurer"
)
.setLabel(
"Adventurer — 500"
)
.setEmoji(
"⚔️"
)
.setStyle(
ButtonStyle.Primary
),
new ButtonBuilder()
.setCustomId(
"crumbleStrikeTierWarriorOfLight"
)
.setLabel(
"Warrior of Light — 1,000"
)
.setEmoji(
"✨"
)
.setStyle(
ButtonStyle.Success
)
)
];
}
/*
* ============================================================
* EVENT LOOKUPS
* ============================================================
*/
async function getCrumbleStrikeFromPublicMessage(
interaction
) {
const footer =
interaction.message
.embeds[0]
?.footer
?.text;
const [
type,
eventId
] =
footer?.split(":")
?? [];
if (
type !==
"crumbleStrike"
||
!eventId
) {
return null;
}
return getCrumbleStrikeByEventId(
eventId
);
}
async function getCrumbleStrikeByEventId(
eventId
) {
const {
db
} =
getCollections();
return db
.collection(
EVENT_COLLECTION
)
.findOne({
eventId,
eventType:
"CrumbleStrike"
});
}
/*
* ============================================================
* SELECTION FOOTER
* ============================================================
*/
function parseSelectionFooter(
interaction
) {
const footer =
interaction.message
.embeds[0]
?.footer
?.text;
const parts =
footer?.split(":")
?? [];
if (
parts[0] ===
"crumbleStrikeJob"
) {
return {
type:
parts[0],
eventId:
parts[1],
userId:
parts[2],
role:
parts[3]
};
}
if (
parts[0] ===
"crumbleStrikeTier"
) {
return {
type:
parts[0],
eventId:
parts[1],
userId:
parts[2],
role:
parts[3],
job:
parts[4]
};
}
return {};
}
/*
* ============================================================
* JOB HELPERS
* ============================================================
*/
function getParticipantJob(
participant
) {
if (
participant.jobKey &&
JOBS[
participant.role
]?.[
participant.jobKey
]
) {
return JOBS[
participant.role
][
participant.jobKey
];
}
/*
* Compatibility with participants created
* before jobKey was stored.
*/
return Object.values(
JOBS[
participant.role
]
??
{}
)
.find(
job =>
job.name ===
participant.job
)
??
null;
}
function getJobEmoji(
participant
) {
return (
getParticipantJob(
participant
)?.emoji
??
getRoleEmoji(
participant.role
)
);
}
function formatJobProgression(
job
) {
if (
job.class
) {
return (
`${job.class} → ${job.name}`
);
}
return job.name;
}
/*
* ============================================================
* DISPLAY HELPERS
* ============================================================
*/
function getRoleEmoji(
role
) {
switch (role) {
case "DPS":
return "⚔️";
case "Tank":
return "🛡️";
case "Healer":
return "❤️";
default:
return "🍪";
}
}
function getRoleCounts(
participants
) {
const counts = {
DPS: 0,
Tank: 0,
Healer: 0
};
for (
const participant
of participants
) {
if (
counts[
participant.role
]
!==
undefined
) {
counts[
participant.role
]++;
}
}
return counts;
}
function buildBar(
current,
max,
size = 20
) {
if (
max <=
0
) {
return "░".repeat(
size
);
}
const ratio =
Math.max(
0,
Math.min(
1,
current /
max
)
);
const filled =
Math.round(
ratio *
size
);
return (
"█".repeat(
filled
)
+
"░".repeat(
size -
filled
)
);
}
/*
* ============================================================
* SAFE TRANSFER
* ============================================================
*/
async function safeTransfer(
from,
to,
amount
) {
try {
await transferCookies(
from,
to,
amount
);
return true;
} catch (error) {
console.error(
`Crumble Strike Cookie transfer failed (${from} -> ${to}, ${amount}):`,
error
);
return false;
}
}
/*
* ============================================================
* EXPORTS
* ============================================================
*/
module.exports = {
JOBS,
TIERS,
chooseCrumbleStrikeRole,
chooseCrumbleStrikeJob,
confirmCrumbleStrikeJoin,
startCrumbleStrikeBattle,
activateCrumbleStrike,
performCrumbleStrikeAction,
resolveCrumbleStrikeVictory,
failCrumbleStrike,
cancelCrumbleStrikeAndRefund,
refreshCrumbleStrikeMessage
};