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

1894 lines
34 KiB
JavaScript

const {
EmbedBuilder,
ActionRowBuilder,
ButtonBuilder,
ButtonStyle,
MessageFlags
} = require("discord.js");
const {
getCollections,
getCookieBalance,
transferCookies,
temporaryEphemeralReply,
getUnixTime
} = require("./cookieEconomy");
const EVENT_COLLECTION =
"items_events";
const BATTLE_DURATION =
60 * 60;
/*
* JOBS
*/
const JOBS = {
DPS: {
BlackMage: {
name:
"Black Mage",
class:
"Thaumaturge",
emoji:
"🔥"
},
Dragoon: {
name:
"Dragoon",
class:
"Lancer",
emoji:
"🐉"
},
Bard: {
name:
"Bard",
class:
"Archer",
emoji:
"🏹"
}
},
Tank: {
Paladin: {
name:
"Paladin",
class:
"Gladiator",
emoji:
"⚔️"
},
Warrior: {
name:
"Warrior",
class:
"Marauder",
emoji:
"🪓"
},
DarkKnight: {
name:
"Dark Knight",
class:
null,
emoji:
"🌑"
}
},
Healer: {
WhiteMage: {
name:
"White Mage",
class:
"Conjurer",
emoji:
"✨"
},
Scholar: {
name:
"Scholar",
class:
"Arcanist",
emoji:
"📖"
},
Astrologian: {
name:
"Astrologian",
class:
null,
emoji:
"🌟"
}
}
};
/*
* INVESTMENT TIERS
*/
const TIERS = {
Sprout: {
name:
"Sprout",
emoji:
"🌱",
cost:
100,
health:
180,
power:
20
},
Adventurer: {
name:
"Adventurer",
emoji:
"⚔️",
cost:
500,
health:
280,
power:
35
},
WarriorOfLight: {
name:
"Warrior of Light",
emoji:
"✨",
cost:
1000,
health:
380,
power:
50
}
};
/*
* ROLE SELECTION
*/
async function chooseCrumbleStrikeRole(
interaction,
role
) {
const event =
await getCrumbleStrikeFromPublicMessage(
interaction
);
if (!event) {
return temporaryEphemeralReply(
interaction,
{
content:
"This Crumble Strike encounter no longer exists."
}
);
}
if (
event.state !==
"Joining"
) {
return temporaryEphemeralReply(
interaction,
{
content:
"The recruitment phase has already ended."
}
);
}
if (
event.joinUntil <=
getUnixTime()
) {
return temporaryEphemeralReply(
interaction,
{
content:
"Recruitment has already expired."
}
);
}
const participant =
event.participants?.[
interaction.user.id
];
if (participant) {
return temporaryEphemeralReply(
interaction,
{
content:
`You're already participating as **${participant.job}**.`
}
);
}
if (!JOBS[role]) {
return temporaryEphemeralReply(
interaction,
{
content:
"That role doesn't exist."
}
);
}
return interaction.reply({
embeds: [
buildJobSelectionEmbed(
event,
interaction.user,
role
)
],
components:
buildJobButtons(
role
),
flags:
MessageFlags.Ephemeral
});
}
/*
* JOB SELECTION
*/
async function chooseCrumbleStrikeJob(
interaction,
role,
jobKey
) {
const metadata =
parseSelectionFooter(
interaction
);
if (
metadata.type !==
"crumbleStrikeJob" ||
metadata.userId !==
interaction.user.id ||
metadata.role !==
role
) {
return temporaryEphemeralReply(
interaction,
{
content:
"This character selection is no longer valid."
}
);
}
const job =
JOBS[role]?.[
jobKey
];
if (!job) {
return temporaryEphemeralReply(
interaction,
{
content:
"That Job isn't available."
}
);
}
const event =
await getCrumbleStrikeByEventId(
metadata.eventId
);
if (
!event ||
event.state !==
"Joining" ||
event.joinUntil <=
getUnixTime()
) {
return interaction.update({
embeds: [
new EmbedBuilder()
.setTitle(
"🍪 Recruitment Closed"
)
.setDescription(
"This encounter is no longer accepting adventurers."
)
],
components: []
});
}
return interaction.update({
embeds: [
buildTierSelectionEmbed(
event,
interaction.user,
role,
jobKey,
job
)
],
components:
buildTierButtons()
});
}
/*
* CONFIRM JOIN
*/
async function confirmCrumbleStrikeJoin(
interaction,
tierKey
) {
const metadata =
parseSelectionFooter(
interaction
);
if (
metadata.type !==
"crumbleStrikeTier" ||
metadata.userId !==
interaction.user.id
) {
return temporaryEphemeralReply(
interaction,
{
content:
"This character selection is no longer valid."
}
);
}
const role =
metadata.role;
const jobKey =
metadata.job;
const job =
JOBS[role]?.[
jobKey
];
const tier =
TIERS[
tierKey
];
if (
!job ||
!tier
) {
return temporaryEphemeralReply(
interaction,
{
content:
"That character setup is invalid."
}
);
}
const {
db
} =
getCollections();
const events =
db.collection(
EVENT_COLLECTION
);
const event =
await events.findOne({
eventId:
metadata.eventId,
eventType:
"CrumbleStrike"
});
if (
!event ||
event.state !==
"Joining" ||
event.joinUntil <=
getUnixTime()
) {
return interaction.update({
embeds: [
new EmbedBuilder()
.setTitle(
"🍪 Recruitment Closed"
)
.setDescription(
"The battle can no longer be joined."
)
],
components: []
});
}
if (
event.participants?.[
interaction.user.id
]
) {
return interaction.update({
embeds: [
new EmbedBuilder()
.setTitle(
"🍪 Already Joined"
)
.setDescription(
"You're already participating."
)
],
components: []
});
}
const balance =
await getCookieBalance(
interaction.user.id
);
if (
balance <
tier.cost
) {
return temporaryEphemeralReply(
interaction,
{
content:
`You need **${tier.cost.toLocaleString()} Cookies** to join as a ${tier.name}.`
}
);
}
try {
await transferCookies(
interaction.user.id,
event.escrowId,
tier.cost
);
} catch (error) {
if (
error.message ===
"INSUFFICIENT_COOKIES"
) {
return temporaryEphemeralReply(
interaction,
{
content:
"Your Cookie balance changed before you could join."
}
);
}
throw error;
}
const now =
getUnixTime();
const participant = {
role,
job:
job.name,
class:
job.class,
tier:
tierKey,
investment:
tier.cost,
health:
tier.health,
maxHealth:
tier.health,
power:
tier.power,
energy:
0,
maxEnergy:
100,
damage:
0,
healing:
0,
mitigation:
0,
defense: {
charges:
0,
reduction:
0
},
knockedOut:
false,
joinedAt:
now,
cooldowns: {
Strike:
0,
Defend:
0,
Heal:
0
}
};
const result =
await events.updateOne(
{
eventId:
event.eventId,
eventType:
"CrumbleStrike",
state:
"Joining",
[`participants.${interaction.user.id}`]: {
$exists:
false
}
},
{
$set: {
[`participants.${interaction.user.id}`]:
participant
},
$inc: {
rewardPool:
tier.cost
}
}
);
if (
result.modifiedCount !==
1
) {
await safeTransfer(
event.escrowId,
interaction.user.id,
tier.cost
);
return interaction.update({
embeds: [
new EmbedBuilder()
.setTitle(
"🍪 Unable to Join"
)
.setDescription(
"Your entry could not be registered. Your Cookies were refunded."
)
],
components: []
});
}
await refreshCrumbleStrikeMessage(
interaction.client,
event.eventId
);
return interaction.update({
embeds: [
new EmbedBuilder()
.setTitle(
"⚔️ You Joined Crumble Strike!"
)
.setDescription(
[
`### ${job.emoji} ${formatJobProgression(job)}`,
"",
`${getRoleEmoji(role)} **${role}**`,
`${tier.emoji} **${tier.name}**`,
"",
`🍪 Investment: **${tier.cost.toLocaleString()}**`,
`❤️ HP: **${tier.health}**`,
`💪 Power: **${tier.power}**`,
`⚡ Energy: **0/100**`
].join("\n")
)
],
components: []
});
}
/*
* START BATTLE
*/
async function startCrumbleStrikeBattle(
interaction
) {
const event =
await getCrumbleStrikeFromPublicMessage(
interaction
);
if (!event) {
return temporaryEphemeralReply(
interaction,
{
content:
"This encounter no longer exists."
}
);
}
if (
interaction.user.id !==
event.creatorId
) {
return temporaryEphemeralReply(
interaction,
{
content:
"Only the player who summoned the boss can start the battle early."
}
);
}
const participantCount =
Object.keys(
event.participants ??
{}
).length;
if (
participantCount <
event.minimumParticipants
) {
return temporaryEphemeralReply(
interaction,
{
content:
`You need at least **${event.minimumParticipants} players**. Currently **${participantCount}/${event.minimumParticipants}**.`
}
);
}
await interaction.deferUpdate();
return activateCrumbleStrike(
interaction.client,
event.eventId
);
}
/*
* ACTIVATE
*/
async function activateCrumbleStrike(
client,
eventId
) {
const {
db
} =
getCollections();
const events =
db.collection(
EVENT_COLLECTION
);
const event =
await events.findOne({
eventId,
eventType:
"CrumbleStrike"
});
if (
!event ||
event.state !==
"Joining"
) {
return false;
}
const participantCount =
Object.keys(
event.participants ??
{}
).length;
if (
participantCount <
event.minimumParticipants
) {
return false;
}
const now =
getUnixTime();
const maxHealth =
15000 +
(
participantCount *
5000
);
const result =
await events.updateOne(
{
eventId,
eventType:
"CrumbleStrike",
state:
"Joining"
},
{
$set: {
state:
"Active",
battleStartedAt:
now,
battleEndsAt:
now +
BATTLE_DURATION,
"boss.health":
maxHealth,
"boss.maxHealth":
maxHealth,
"boss.limitGauge":
0,
"boss.actionGauge":
0
}
}
);
if (
result.modifiedCount !==
1
) {
return false;
}
await refreshCrumbleStrikeMessage(
client,
eventId
);
return true;
}
/*
* FAILURE
*/
async function failCrumbleStrike(
client,
eventId
) {
const {
db
} =
getCollections();
const events =
db.collection(
EVENT_COLLECTION
);
const event =
await events.findOneAndUpdate(
{
eventId,
eventType:
"CrumbleStrike",
state:
"Active"
},
{
$set: {
state:
"Failed",
failedAt:
getUnixTime()
}
},
{
returnDocument:
"after"
}
);
if (!event)
return false;
const escrow =
await getCookieBalance(
event.escrowId
);
if (
escrow >
0
) {
await safeTransfer(
event.escrowId,
client.user.id,
escrow
);
}
await refreshCrumbleStrikeMessage(
client,
eventId
);
return true;
}
/*
* CANCEL + REFUND
*/
async function cancelCrumbleStrikeAndRefund(
client,
eventId
) {
const {
db
} =
getCollections();
const events =
db.collection(
EVENT_COLLECTION
);
const event =
await events.findOneAndUpdate(
{
eventId,
eventType:
"CrumbleStrike",
state:
"Joining"
},
{
$set: {
state:
"Cancelled",
cancelledAt:
getUnixTime()
}
},
{
returnDocument:
"after"
}
);
if (!event)
return false;
for (
const [
userId,
participant
]
of Object.entries(
event.participants ??
{}
)
) {
if (
participant.investment >
0
) {
await safeTransfer(
event.escrowId,
userId,
participant.investment
);
}
}
const remaining =
await getCookieBalance(
event.escrowId
);
if (
remaining >
0
) {
await safeTransfer(
event.escrowId,
event.creatorId,
remaining
);
}
await refreshCrumbleStrikeMessage(
client,
eventId
);
return true;
}
/*
* REFRESH PUBLIC MESSAGE
*/
async function refreshCrumbleStrikeMessage(
client,
eventId
) {
const event =
await getCrumbleStrikeByEventId(
eventId
);
if (!event)
return;
let channel;
try {
channel =
await client.channels.fetch(
event.channelId
);
} catch {
return;
}
if (!channel)
return;
let message;
try {
message =
await channel.messages.fetch(
event.messageId
);
} catch {
return;
}
const {
embed,
components
} =
buildCrumbleStrikeMessage(
event
);
await message.edit({
embeds: [
embed
],
components
});
}
/*
* PUBLIC MESSAGE
*/
function buildCrumbleStrikeMessage(
event
) {
const participants =
Object.values(
event.participants ??
{}
);
const participantCount =
participants.length;
if (
event.state ===
"Joining"
) {
const roleCounts =
getRoleCounts(
participants
);
return {
embed:
new EmbedBuilder()
.setTitle(
"🍪 Crumble Strike"
)
.setDescription(
[
"## The Crumbbound Behemoth",
"",
"*A creature of hunger, crumbs, and extremely questionable dietary choices.*",
"",
`**Recruitment ends:** <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()
};
}
if (
event.state ===
"Active"
) {
const hp =
Math.max(
0,
event.boss.health
);
return {
embed:
new EmbedBuilder()
.setTitle(
"⚔️ Crumble Strike"
)
.setDescription(
[
"## The Crumbbound Behemoth",
"",
buildHpBar(
hp,
event.boss.maxHealth
),
`**${hp.toLocaleString()} / ${event.boss.maxHealth.toLocaleString()} HP**`,
"",
`✨ Limit Gauge: **${event.boss.limitGauge ?? 0}/100**`,
`👹 Boss Gauge: **${event.boss.actionGauge ?? 0}/10**`,
"",
`**Participants:** ${participantCount}`,
`**Reward Pool:** ${event.rewardPool.toLocaleString()} 🍪`,
`**Battle ends:** <t:${event.battleEndsAt}:R>`,
"",
"Combat actions are being upgraded next."
].join("\n")
)
.setFooter({
text:
`crumbleStrike:${event.eventId}`
}),
components: []
};
}
if (
event.state ===
"Failed"
) {
return {
embed:
new EmbedBuilder()
.setTitle(
"💀 Crumble Strike Failed"
)
.setDescription(
[
"The Crumbbound Behemoth survived the encounter.",
"",
`**${event.rewardPool.toLocaleString()} Cookies** were claimed by the ShaiBot Vault.`
].join("\n")
)
.setFooter({
text:
`crumbleStrike:${event.eventId}`
}),
components: []
};
}
if (
event.state ===
"Cancelled"
) {
return {
embed:
new EmbedBuilder()
.setTitle(
"🍪 Crumble Strike Cancelled"
)
.setDescription(
"Not enough adventurers joined. All committed Cookies were refunded."
)
.setFooter({
text:
`crumbleStrike:${event.eventId}`
}),
components: []
};
}
return {
embed:
new EmbedBuilder()
.setTitle(
"🍪 Crumble Strike"
)
.setDescription(
`Encounter state: **${event.state}**`
)
.setFooter({
text:
`crumbleStrike:${event.eventId}`
}),
components: []
};
}
/*
* RECRUITMENT BUTTONS
*/
function buildRecruitmentButtons() {
return [
new ActionRowBuilder()
.addComponents(
new ButtonBuilder()
.setCustomId(
"crumbleStrikeRoleDPS"
)
.setLabel(
"DPS"
)
.setEmoji(
"⚔️"
)
.setStyle(
ButtonStyle.Danger
),
new ButtonBuilder()
.setCustomId(
"crumbleStrikeRoleTank"
)
.setLabel(
"Tank"
)
.setEmoji(
"🛡️"
)
.setStyle(
ButtonStyle.Primary
),
new ButtonBuilder()
.setCustomId(
"crumbleStrikeRoleHealer"
)
.setLabel(
"Healer"
)
.setEmoji(
"❤️"
)
.setStyle(
ButtonStyle.Success
)
),
new ActionRowBuilder()
.addComponents(
new ButtonBuilder()
.setCustomId(
"crumbleStrikeStart"
)
.setLabel(
"Start Battle"
)
.setEmoji(
"⚔️"
)
.setStyle(
ButtonStyle.Secondary
)
)
];
}
/*
* JOB MENU
*/
function buildJobSelectionEmbed(
event,
user,
role
) {
const jobs =
JOBS[
role
];
const description =
Object.values(
jobs
)
.map(
job =>
`${job.emoji} **${formatJobProgression(job)}**`
)
.join("\n");
return new EmbedBuilder()
.setTitle(
`${getRoleEmoji(role)} Choose Your ${role} Job`
)
.setDescription(
[
description,
"",
"Choose the Job you want to bring into battle."
].join("\n")
)
.setFooter({
text:
`crumbleStrikeJob:${event.eventId}:${user.id}:${role}`
});
}
function buildJobButtons(
role
) {
return [
new ActionRowBuilder()
.addComponents(
...Object.entries(
JOBS[
role
]
)
.map(
(
[
key,
job
]
) =>
new ButtonBuilder()
.setCustomId(
`crumbleStrikeJob${key}`
)
.setLabel(
job.name
)
.setEmoji(
job.emoji
)
.setStyle(
ButtonStyle.Primary
)
)
)
];
}
/*
* TIER MENU
*/
function buildTierSelectionEmbed(
event,
user,
role,
jobKey,
job
) {
return new EmbedBuilder()
.setTitle(
`${job.emoji} ${formatJobProgression(job)}`
)
.setDescription(
[
`${getRoleEmoji(role)} **${role}**`,
"",
"Choose your commitment.",
"",
"🌱 **Sprout — 100 🍪**",
"Entry-level HP and Power.",
"",
"⚔️ **Adventurer — 500 🍪**",
"Improved HP and Power.",
"",
"✨ **Warrior of Light — 1,000 🍪**",
"Highest starting HP and Power."
].join("\n")
)
.setFooter({
text:
`crumbleStrikeTier:${event.eventId}:${user.id}:${role}:${jobKey}`
});
}
function buildTierButtons() {
return [
new ActionRowBuilder()
.addComponents(
new ButtonBuilder()
.setCustomId(
"crumbleStrikeTierSprout"
)
.setLabel(
"Sprout — 100"
)
.setEmoji(
"🌱"
)
.setStyle(
ButtonStyle.Secondary
),
new ButtonBuilder()
.setCustomId(
"crumbleStrikeTierAdventurer"
)
.setLabel(
"Adventurer — 500"
)
.setEmoji(
"⚔️"
)
.setStyle(
ButtonStyle.Primary
),
new ButtonBuilder()
.setCustomId(
"crumbleStrikeTierWarriorOfLight"
)
.setLabel(
"Warrior of Light — 1,000"
)
.setEmoji(
"✨"
)
.setStyle(
ButtonStyle.Success
)
)
];
}
/*
* LOOKUPS
*/
async function getCrumbleStrikeFromPublicMessage(
interaction
) {
const footer =
interaction.message
.embeds[0]
?.footer
?.text;
const [
type,
eventId
] =
footer?.split(":")
?? [];
if (
type !==
"crumbleStrike" ||
!eventId
) {
return null;
}
return getCrumbleStrikeByEventId(
eventId
);
}
async function getCrumbleStrikeByEventId(
eventId
) {
const {
db
} =
getCollections();
return db
.collection(
EVENT_COLLECTION
)
.findOne({
eventId,
eventType:
"CrumbleStrike"
});
}
function parseSelectionFooter(
interaction
) {
const footer =
interaction.message
.embeds[0]
?.footer
?.text;
const parts =
footer?.split(":")
?? [];
if (
parts[0] ===
"crumbleStrikeJob"
) {
return {
type:
parts[0],
eventId:
parts[1],
userId:
parts[2],
role:
parts[3]
};
}
if (
parts[0] ===
"crumbleStrikeTier"
) {
return {
type:
parts[0],
eventId:
parts[1],
userId:
parts[2],
role:
parts[3],
job:
parts[4]
};
}
return {};
}
/*
* DISPLAY
*/
function formatJobProgression(
job
) {
if (
job.class
) {
return (
`${job.class} → ${job.name}`
);
}
return job.name;
}
function getRoleEmoji(
role
) {
switch (role) {
case "DPS":
return "⚔️";
case "Tank":
return "🛡️";
case "Healer":
return "❤️";
default:
return "🍪";
}
}
function getRoleCounts(
participants
) {
const counts = {
DPS: 0,
Tank: 0,
Healer: 0
};
for (
const participant
of participants
) {
if (
counts[
participant.role
]
!==
undefined
) {
counts[
participant.role
]++;
}
}
return counts;
}
function buildHpBar(
current,
max,
size = 20
) {
if (
max <=
0
) {
return "░".repeat(
size
);
}
const ratio =
Math.max(
0,
Math.min(
1,
current /
max
)
);
const filled =
Math.round(
ratio *
size
);
return (
"█".repeat(
filled
)
+
"░".repeat(
size -
filled
)
);
}
/*
* SAFE COOKIE TRANSFER
*/
async function safeTransfer(
from,
to,
amount
) {
try {
await transferCookies(
from,
to,
amount
);
return true;
} catch (error) {
console.error(
`Crumble Strike Cookie transfer failed (${from} -> ${to}, ${amount}):`,
error
);
return false;
}
}
module.exports = {
JOBS,
TIERS,
chooseCrumbleStrikeRole,
chooseCrumbleStrikeJob,
confirmCrumbleStrikeJoin,
startCrumbleStrikeBattle,
activateCrumbleStrike,
failCrumbleStrike,
cancelCrumbleStrikeAndRefund,
refreshCrumbleStrikeMessage
};