Files
ShaiBot/utils/cookieBoss.js
T
2026-08-18 18:57:24 +02:00

1693 lines
30 KiB
JavaScript

const {
EmbedBuilder,
ActionRowBuilder,
ButtonBuilder,
ButtonStyle
} = require("discord.js");
const {
getCollections,
getCookieBalance,
transferCookies,
temporaryEphemeralReply,
temporaryEphemeralFollowUp,
getUnixTime,
randomInteger
} = require("./cookieEconomy");
const EVENT_COLLECTION =
"items_events";
const BATTLE_DURATION =
60 * 60;
/*
* Buy-in determines temporary combat stats.
*/
const ENTRY_TIERS = {
Scout: {
cost: 100,
health: 180,
power: 20
},
Fighter: {
cost: 500,
health: 280,
power: 35
},
Champion: {
cost: 1000,
health: 380,
power: 50
}
};
const ACTIONS = {
Strike: {
multiplier:
1.0,
energyCost:
0,
cooldown:
10
},
Burst: {
multiplier:
2.25,
energyCost:
40,
cooldown:
30
},
Limit: {
multiplier:
4.0,
energyCost:
100,
cooldown:
90
}
};
async function joinBoss(
interaction,
tierName
) {
const event =
await getBossFromMessage(
interaction
);
if (!event) {
return temporaryEphemeralReply(
interaction,
{
content:
"This boss event 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."
}
);
}
if (
event.participants?.[
interaction.user.id
]
) {
return temporaryEphemeralReply(
interaction,
{
content:
"You're already signed up for this fight."
}
);
}
const tier =
ENTRY_TIERS[
tierName
];
if (!tier)
return;
const balance =
await getCookieBalance(
interaction.user.id
);
if (
balance <
tier.cost
) {
return temporaryEphemeralReply(
interaction,
{
content:
`You need **${tier.cost} Cookies** for the ${tierName} tier.`
}
);
}
/*
* Entry fee goes to encounter escrow,
* not directly into the house vault.
*/
await transferCookies(
interaction.user.id,
event.escrowId,
tier.cost
);
const {
db
} =
getCollections();
const events =
db.collection(
EVENT_COLLECTION
);
const now =
getUnixTime();
await events.updateOne(
{
eventId:
event.eventId,
state:
"Joining",
[`participants.${interaction.user.id}`]:
{
$exists: false
}
},
{
$set: {
[`participants.${interaction.user.id}`]:
{
tier:
tierName,
entry:
tier.cost,
health:
tier.health,
maxHealth:
tier.health,
power:
tier.power,
energy:
100,
maxEnergy:
100,
lastEnergyUpdate:
now,
damage:
0,
knockedOut:
false,
cooldowns: {
Strike: 0,
Burst: 0,
Limit: 0
}
}
},
$inc: {
rewardPool:
tier.cost
}
}
);
await refreshBossMessage(
interaction.client,
event.eventId
);
return temporaryEphemeralReply(
interaction,
{
content:
`You joined as a **${tierName}** for **${tier.cost} Cookies**.`
},
6000
);
}
async function startBossBattle(
interaction
) {
const event =
await getBossFromMessage(
interaction
);
if (!event) {
return temporaryEphemeralReply(
interaction,
{
content:
"This boss event 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();
await activateBoss(
interaction.client,
event.eventId
);
}
async function activateBoss(
client,
eventId
) {
const {
db
} =
getCollections();
const events =
db.collection(
EVENT_COLLECTION
);
const event =
await events.findOne({
eventId
});
if (
!event ||
event.state !==
"Joining"
) {
return false;
}
const participantCount =
Object.keys(
event.participants ??
{}
).length;
if (
participantCount <
event.minimumParticipants
) {
return false;
}
const now =
getUnixTime();
/*
* Scale boss HP by actual party size.
*/
const maxHealth =
15000 +
(
participantCount *
5000
);
const claimed =
await events.updateOne(
{
eventId,
state:
"Joining"
},
{
$set: {
state:
"Active",
battleStartedAt:
now,
battleEndsAt:
now +
BATTLE_DURATION,
"boss.health":
maxHealth,
"boss.maxHealth":
maxHealth,
"boss.rage":
0
}
}
);
if (
claimed.modifiedCount !==
1
) {
return false;
}
await refreshBossMessage(
client,
eventId
);
return true;
}
async function performBossAction(
interaction,
actionName
) {
const event =
await getBossFromMessage(
interaction
);
if (!event) {
return temporaryEphemeralReply(
interaction,
{
content:
"This boss event no longer exists."
}
);
}
if (
event.state !==
"Active"
) {
return temporaryEphemeralReply(
interaction,
{
content:
"The battle isn't active."
}
);
}
const player =
event.participants?.[
interaction.user.id
];
if (!player) {
return temporaryEphemeralReply(
interaction,
{
content:
"You didn't join this battle."
}
);
}
if (player.knockedOut) {
return temporaryEphemeralReply(
interaction,
{
content:
"You're knocked out and can't attack."
}
);
}
const action =
ACTIONS[
actionName
];
if (!action)
return;
const now =
getUnixTime();
/*
* Lazy energy regeneration:
* +10 Energy per 10 seconds.
*/
const elapsed =
Math.max(
0,
now -
player.lastEnergyUpdate
);
const regenerated =
Math.floor(
elapsed /
10
) *
10;
const energy =
Math.min(
player.maxEnergy,
player.energy +
regenerated
);
const cooldown =
player.cooldowns?.[
actionName
]
??
0;
if (
cooldown > now
) {
return temporaryEphemeralReply(
interaction,
{
content:
`**${actionName}** is ready <t:${cooldown}:R>.`
}
);
}
if (
energy <
action.energyCost
) {
return temporaryEphemeralReply(
interaction,
{
content:
`You need **${action.energyCost} Energy** for ${actionName}. You currently have **${energy}**.`
}
);
}
await interaction.deferUpdate();
const variance =
randomInteger(
90,
110
) /
100;
const damage =
Math.max(
1,
Math.floor(
player.power *
action.multiplier *
variance
)
);
const nextCooldown =
now +
action.cooldown;
/*
* 35% chance of boss retaliation.
*/
const retaliates =
Math.random() <
0.35;
const retaliationDamage =
retaliates
? randomInteger(
15,
45
)
: 0;
const newHealth =
Math.max(
0,
player.health -
retaliationDamage
);
const knockedOut =
newHealth <= 0;
const {
db
} =
getCollections();
const events =
db.collection(
EVENT_COLLECTION
);
await events.updateOne(
{
eventId:
event.eventId,
state:
"Active",
"boss.health": {
$gt: 0
}
},
{
$inc: {
"boss.health":
-damage,
[`participants.${interaction.user.id}.damage`]:
damage
},
$set: {
[`participants.${interaction.user.id}.energy`]:
energy -
action.energyCost,
[`participants.${interaction.user.id}.lastEnergyUpdate`]:
now,
[`participants.${interaction.user.id}.health`]:
newHealth,
[`participants.${interaction.user.id}.knockedOut`]:
knockedOut,
[`participants.${interaction.user.id}.cooldowns.${actionName}`]:
nextCooldown
}
}
);
const updated =
await events.findOne({
eventId:
event.eventId
});
/*
* Clamp boss HP if concurrent attacks pushed it below zero.
*/
if (
updated.boss.health <= 0
) {
await events.updateOne(
{
eventId:
event.eventId
},
{
$set: {
"boss.health":
0
}
}
);
await resolveBossVictory(
interaction.client,
event.eventId
);
} else {
await refreshBossMessage(
interaction.client,
event.eventId
);
}
const result =
new EmbedBuilder()
.setTitle(
`⚔️ ${actionName}`
)
.addFields(
{
name:
"Damage",
value:
`**${damage.toLocaleString()}**`,
inline:
true
},
{
name:
"HP",
value:
`**${newHealth}/${player.maxHealth}**`,
inline:
true
},
{
name:
"Energy",
value:
`**${energy - action.energyCost}/${player.maxEnergy}**`,
inline:
true
}
);
if (retaliates) {
result.setDescription(
`The Crumbbound Behemoth retaliated for **${retaliationDamage} damage**!`
);
}
if (knockedOut) {
result.setDescription(
"💀 The Crumbbound Behemoth knocked you out!"
);
}
return temporaryEphemeralFollowUp(
interaction,
{
embeds: [
result
]
},
9000
);
}
async function resolveBossVictory(
client,
eventId
) {
const {
db
} =
getCollections();
const events =
db.collection(
EVENT_COLLECTION
);
const event =
await events.findOneAndUpdate(
{
eventId,
state:
"Active"
},
{
$set: {
state:
"Resolving"
}
},
{
returnDocument:
"after"
}
);
if (!event)
return;
const participants =
Object.entries(
event.participants ??
{}
);
if (!participants.length)
return;
const rewardPool =
event.rewardPool;
const totalDamage =
participants.reduce(
(
sum,
[
,
player
]
) =>
sum +
player.damage,
0
);
const evenPool =
Math.floor(
rewardPool *
0.30
);
const damagePool =
rewardPool -
evenPool;
const evenShare =
Math.floor(
evenPool /
participants.length
);
let distributed =
0;
const rewards =
[];
for (
const [
userId,
player
]
of participants
) {
const damageShare =
totalDamage > 0
? Math.floor(
damagePool *
(
player.damage /
totalDamage
)
)
: 0;
const reward =
evenShare +
damageShare;
if (reward > 0) {
await transferCookies(
event.escrowId,
userId,
reward
);
distributed +=
reward;
}
rewards.push({
userId,
reward,
damage:
player.damage
});
}
/*
* Rounding remainder goes to top damage.
*/
const remainder =
rewardPool -
distributed;
if (
remainder > 0
) {
const top =
rewards
.slice()
.sort(
(
a,
b
) =>
b.damage -
a.damage
)[0];
if (top) {
await transferCookies(
event.escrowId,
top.userId,
remainder
);
top.reward +=
remainder;
}
}
await events.updateOne(
{
eventId
},
{
$set: {
state:
"Defeated",
defeatedAt:
getUnixTime(),
rewards
}
}
);
await refreshBossMessage(
client,
eventId
);
}
async function failBoss(
client,
eventId
) {
const {
db
} =
getCollections();
const events =
db.collection(
EVENT_COLLECTION
);
const event =
await events.findOneAndUpdate(
{
eventId,
state:
"Active"
},
{
$set: {
state:
"Failed",
failedAt:
getUnixTime()
}
},
{
returnDocument:
"after"
}
);
if (!event)
return;
const escrowBalance =
await getCookieBalance(
event.escrowId
);
/*
* Failure:
* all committed Cookies become house Cookies.
*/
if (
escrowBalance > 0
) {
await transferCookies(
event.escrowId,
client.user.id,
escrowBalance
);
}
await refreshBossMessage(
client,
eventId
);
}
async function cancelBossAndRefund(
client,
eventId
) {
const {
db
} =
getCollections();
const events =
db.collection(
EVENT_COLLECTION
);
const event =
await events.findOneAndUpdate(
{
eventId,
state:
"Joining"
},
{
$set: {
state:
"Cancelled",
cancelledAt:
getUnixTime()
}
},
{
returnDocument:
"after"
}
);
if (!event)
return;
/*
* Refund all participant entries.
*/
for (
const [
userId,
player
]
of Object.entries(
event.participants ??
{}
)
) {
if (
player.entry >
0
) {
await transferCookies(
event.escrowId,
userId,
player.entry
);
}
}
/*
* Refund the original 500 spawn cost.
*/
const remaining =
await getCookieBalance(
event.escrowId
);
if (
remaining > 0
) {
await transferCookies(
event.escrowId,
event.creatorId,
remaining
);
}
await refreshBossMessage(
client,
eventId
);
}
async function refreshBossMessage(
client,
eventId
) {
const {
db
} =
getCollections();
const event =
await db
.collection(
EVENT_COLLECTION
)
.findOne({
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
} =
buildBossMessage(
event
);
await message.edit({
embeds: [
embed
],
components
});
}
function buildBossMessage(
event
) {
const participantCount =
Object.keys(
event.participants ??
{}
).length;
if (
event.state ===
"Joining"
) {
const embed =
new EmbedBuilder()
.setTitle(
"🍪 The Crumbbound Behemoth"
)
.setDescription(
[
"*A creature of hunger, crumbs, and questionable dietary choices.*",
"",
`**Recruitment ends:** <t:${event.joinUntil}:R>`,
`**Participants:** ${participantCount}/${event.minimumParticipants} required`,
`**Reward Pool:** ${event.rewardPool.toLocaleString()} 🍪`,
"",
participantCount >=
event.minimumParticipants
? "✅ The summoner may start the battle early."
: "Recruit more adventurers before the fight can begin."
].join("\n")
)
.setFooter({
text:
`cookieBoss:${event.eventId}`
});
return {
embed,
components:
buildJoinButtons()
};
}
if (
event.state ===
"Active"
) {
const hp =
Math.max(
0,
event.boss.health
);
const hpBar =
buildHpBar(
hp,
event.boss.maxHealth
);
const embed =
new EmbedBuilder()
.setTitle(
"⚔️ The Crumbbound Behemoth"
)
.setDescription(
[
hpBar,
`**${hp.toLocaleString()} / ${event.boss.maxHealth.toLocaleString()} HP**`,
"",
`**Participants:** ${participantCount}`,
`**Reward Pool:** ${event.rewardPool.toLocaleString()} 🍪`,
`**Battle ends:** <t:${event.battleEndsAt}:R>`
].join("\n")
)
.setFooter({
text:
`cookieBoss:${event.eventId}`
});
return {
embed,
components:
buildCombatButtons()
};
}
if (
event.state ===
"Defeated"
) {
const rewards =
(
event.rewards ??
[]
)
.sort(
(
a,
b
) =>
b.damage -
a.damage
)
.slice(
0,
10
)
.map(
(
player,
index
) =>
`**${index + 1}.** <@${player.userId}> — ` +
`${player.damage.toLocaleString()} dmg — ` +
`**${player.reward.toLocaleString()} 🍪**`
)
.join("\n");
return {
embed:
new EmbedBuilder()
.setTitle(
"🏆 The Crumbbound Behemoth Has Fallen!"
)
.setDescription(
rewards ||
"The beast has been defeated."
)
.setFooter({
text:
`cookieBoss:${event.eventId}`
}),
components:
[]
};
}
if (
event.state ===
"Failed"
) {
return {
embed:
new EmbedBuilder()
.setTitle(
"💀 The Crumbbound Behemoth Survived"
)
.setDescription(
[
"The battle timer expired.",
"",
`**${event.rewardPool.toLocaleString()} Cookies** were lost to the ShaiBot Vault.`
].join("\n")
)
.setFooter({
text:
`cookieBoss:${event.eventId}`
}),
components:
[]
};
}
return {
embed:
new EmbedBuilder()
.setTitle(
"🍪 Boss Hunt Cancelled"
)
.setDescription(
"Not enough adventurers joined. All committed Cookies were refunded."
)
.setFooter({
text:
`cookieBoss:${event.eventId}`
}),
components:
[]
};
}
function buildJoinButtons() {
return [
new ActionRowBuilder()
.addComponents(
new ButtonBuilder()
.setCustomId(
"cookieBossJoinScout"
)
.setLabel(
"Scout — 100"
)
.setEmoji(
"🍪"
)
.setStyle(
ButtonStyle.Secondary
),
new ButtonBuilder()
.setCustomId(
"cookieBossJoinFighter"
)
.setLabel(
"Fighter — 500"
)
.setEmoji(
"⚔️"
)
.setStyle(
ButtonStyle.Primary
),
new ButtonBuilder()
.setCustomId(
"cookieBossJoinChampion"
)
.setLabel(
"Champion — 1000"
)
.setEmoji(
"🔥"
)
.setStyle(
ButtonStyle.Danger
)
),
new ActionRowBuilder()
.addComponents(
new ButtonBuilder()
.setCustomId(
"cookieBossStart"
)
.setLabel(
"Start Battle"
)
.setEmoji(
"⚔️"
)
.setStyle(
ButtonStyle.Success
)
)
];
}
function buildCombatButtons() {
return [
new ActionRowBuilder()
.addComponents(
new ButtonBuilder()
.setCustomId(
"cookieBossStrike"
)
.setLabel(
"Strike"
)
.setEmoji(
"⚔️"
)
.setStyle(
ButtonStyle.Primary
),
new ButtonBuilder()
.setCustomId(
"cookieBossBurst"
)
.setLabel(
"Burst"
)
.setEmoji(
"💥"
)
.setStyle(
ButtonStyle.Danger
),
new ButtonBuilder()
.setCustomId(
"cookieBossLimit"
)
.setLabel(
"Limit"
)
.setEmoji(
"✨"
)
.setStyle(
ButtonStyle.Success
)
)
];
}
async function getBossFromMessage(
interaction
) {
const footer =
interaction.message
.embeds[0]
?.footer
?.text;
const [
type,
eventId
] =
footer
?.split(":")
??
[];
if (
type !==
"cookieBoss" ||
!eventId
) {
return null;
}
const {
db
} =
getCollections();
return db
.collection(
EVENT_COLLECTION
)
.findOne({
eventId
});
}
function buildHpBar(
current,
max,
size = 20
) {
const ratio =
Math.max(
0,
Math.min(
1,
current /
max
)
);
const filled =
Math.round(
ratio *
size
);
return (
"█".repeat(
filled
)
+
"░".repeat(
size -
filled
)
);
}
module.exports = {
joinBoss,
startBossBattle,
activateBoss,
performBossAction,
resolveBossVictory,
failBoss,
cancelBossAndRefund,
refreshBossMessage
};