Compare commits

..

2 Commits

Author SHA1 Message Date
DeSqBlocki 24d5b395ca new games snapshot 2026-08-18 18:57:24 +02:00
DeSqBlocki 8bdd6868e8 added more steal logic 2026-08-15 08:44:24 +02:00
16 changed files with 5162 additions and 563 deletions
+18
View File
@@ -0,0 +1,18 @@
const {
performBossAction
} = require(
"../../utils/cookieBoss"
);
module.exports = {
name:
"cookieBossBurst",
async execute(interaction) {
return performBossAction(
interaction,
"Burst"
);
}
};
+18
View File
@@ -0,0 +1,18 @@
const {
joinBoss
} = require(
"../../utils/cookieBoss"
);
module.exports = {
name:
"cookieBossJoinChampion",
async execute(interaction) {
return joinBoss(
interaction,
"Champion"
);
}
};
+18
View File
@@ -0,0 +1,18 @@
const {
joinBoss
} = require(
"../../utils/cookieBoss"
);
module.exports = {
name:
"cookieBossJoinFighter",
async execute(interaction) {
return joinBoss(
interaction,
"Fighter"
);
}
};
+18
View File
@@ -0,0 +1,18 @@
const {
joinBoss
} = require(
"../../utils/cookieBoss"
);
module.exports = {
name:
"cookieBossJoinScout",
async execute(interaction) {
return joinBoss(
interaction,
"Scout"
);
}
};
+18
View File
@@ -0,0 +1,18 @@
const {
performBossAction
} = require(
"../../utils/cookieBoss"
);
module.exports = {
name:
"cookieBossLimit",
async execute(interaction) {
return performBossAction(
interaction,
"Limit"
);
}
};
+17
View File
@@ -0,0 +1,17 @@
const {
startBossBattle
} = require(
"../../utils/cookieBoss"
);
module.exports = {
name:
"cookieBossStart",
async execute(interaction) {
return startBossBattle(
interaction
);
}
};
+18
View File
@@ -0,0 +1,18 @@
const {
performBossAction
} = require(
"../../utils/cookieBoss"
);
module.exports = {
name:
"cookieBossStrike",
async execute(interaction) {
return performBossAction(
interaction,
"Strike"
);
}
};
+18
View File
@@ -0,0 +1,18 @@
const {
resolveCookieHeist
} = require(
"../../utils/cookieHeist"
);
module.exports = {
name:
"cookieHeistHack",
async execute(interaction) {
return resolveCookieHeist(
interaction,
"Hack"
);
}
};
+18
View File
@@ -0,0 +1,18 @@
const {
resolveCookieHeist
} = require(
"../../utils/cookieHeist"
);
module.exports = {
name:
"cookieHeistSmash",
async execute(interaction) {
return resolveCookieHeist(
interaction,
"Smash"
);
}
};
+18
View File
@@ -0,0 +1,18 @@
const {
resolveCookieHeist
} = require(
"../../utils/cookieHeist"
);
module.exports = {
name:
"cookieHeistSneak",
async execute(interaction) {
return resolveCookieHeist(
interaction,
"Sneak"
);
}
};
File diff suppressed because it is too large Load Diff
+230
View File
@@ -0,0 +1,230 @@
const {
getCollections,
getUnixTime
} = require("../utils/cookieEconomy");
const {
activateBoss,
failBoss,
cancelBossAndRefund
} = require("../utils/cookieBoss");
/*
* How often we check persistent boss timers.
*
* Discord timestamps handle the visible countdown,
* so checking once per minute is plenty.
*/
const CHECK_INTERVAL = 60 * 1000;
/*
* Your index.js automatically loads every file
* inside /handlers and passes the Discord client:
*
* require(`./handlers/${handler}`)(client)
*/
module.exports = (client) => {
/*
* Run shortly after startup.
*
* This catches boss events whose timers expired
* while ShaiBot was offline/restarting.
*/
setTimeout(() => {
processBossEvents(client);
}, 5000);
/*
* Persistent boss event watcher.
*/
setInterval(() => {
processBossEvents(client);
}, CHECK_INTERVAL);
console.log(
"[CookieBoss] Boss event watcher initialized"
);
};
/*
* Check all currently pending Cookie Boss events.
*/
async function processBossEvents(client) {
try {
const {
db
} = getCollections();
const events =
db.collection(
"items_events"
);
const now =
getUnixTime();
/*
* ==========================================
* JOINING PHASE
* ==========================================
*
* Recruitment lasts for 30 minutes.
*
* If 5+ players joined:
* start the battle automatically.
*
* If fewer than 5 joined:
* cancel the boss and refund everyone.
*/
const joiningEvents =
await events
.find({
eventType:
"CookieBoss",
state:
"Joining",
joinUntil: {
$lte: now
}
})
.toArray();
for (
const event
of joiningEvents
) {
const participantCount =
Object.keys(
event.participants ?? {}
).length;
console.log(
`[CookieBoss] Recruitment expired for ${event.eventId} ` +
`(${participantCount}/${event.minimumParticipants} players)`
);
/*
* Enough players joined.
*
* Start the fight automatically.
*/
if (
participantCount >=
event.minimumParticipants
) {
const started =
await activateBoss(
client,
event.eventId
);
if (started) {
console.log(
`[CookieBoss] Battle ${event.eventId} started automatically`
);
}
continue;
}
/*
* Not enough players.
*
* Nobody actually entered combat,
* so refund all committed Cookies.
*/
await cancelBossAndRefund(
client,
event.eventId
);
console.log(
`[CookieBoss] Battle ${event.eventId} cancelled and refunded`
);
}
/*
* ==========================================
* ACTIVE BATTLES
* ==========================================
*
* If the battle timer expires while the
* boss still has HP remaining, the raid fails.
*
* All Cookies held in the encounter escrow
* are transferred to the ShaiBot vault.
*/
const activeEvents =
await events
.find({
eventType:
"CookieBoss",
state:
"Active",
battleEndsAt: {
$lte: now
},
"boss.health": {
$gt: 0
}
})
.toArray();
for (
const event
of activeEvents
) {
console.log(
`[CookieBoss] Battle ${event.eventId} expired ` +
`with ${event.boss.health}/${event.boss.maxHealth} HP remaining`
);
await failBoss(
client,
event.eventId
);
console.log(
`[CookieBoss] Battle ${event.eventId} failed`
);
}
} catch (error) {
/*
* Never let an exception kill the interval.
*
* The next watcher tick will simply try again.
*/
console.error(
"[CookieBoss] Boss watcher error:",
error
);
}
}
+137 -37
View File
@@ -1,41 +1,141 @@
const { Client, GatewayIntentBits, Collection, Partials } = require('discord.js')
const { MongoClient } = require('mongodb')
require('dotenv').config()
const mClient = new MongoClient(process.env.M_URI)
exports.mClient = mClient;
const fs = require('node:fs')
const {
Client,
GatewayIntentBits,
Collection,
Partials
} = require("discord.js");
const client = new Client({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMembers,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.GuildPresences,
GatewayIntentBits.GuildMessageReactions,
GatewayIntentBits.GuildVoiceStates,
GatewayIntentBits.DirectMessages,
GatewayIntentBits.MessageContent,
],
partials: [
Partials.Channel,
Partials.Message,
Partials.User,
Partials.GuildMember,
Partials.Reaction
]
});
exports.client = client
const {
MongoClient
} = require("mongodb");
client.commands = new Collection() // slash commands collection
client.legacyCommands = new Collection() // legacy commands collection
client.aliases = new Collection() // list of command aliases
client.buttons = new Collection() // list of buttons
client.selectMenus = new Collection() // list of selectMenus
client.modals = new Collection();
const fs = require("node:fs");
fs.readdirSync('./handlers').forEach((handler) => {
require(`./handlers/${handler}`)(client)
});
require("dotenv").config();
client.login(process.env.D_TOKEN)
/*
* MONGODB
*/
const mClient =
new MongoClient(
process.env.M_URI
);
exports.mClient =
mClient;
/*
* DISCORD CLIENT
*/
const client =
new Client({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMembers,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.GuildPresences,
GatewayIntentBits.GuildMessageReactions,
GatewayIntentBits.GuildVoiceStates,
GatewayIntentBits.DirectMessages,
GatewayIntentBits.MessageContent
],
partials: [
Partials.Channel,
Partials.Message,
Partials.User,
Partials.GuildMember,
Partials.Reaction
]
});
exports.client =
client;
/*
* COLLECTIONS
*/
client.commands =
new Collection();
client.legacyCommands =
new Collection();
client.aliases =
new Collection();
client.buttons =
new Collection();
client.selectMenus =
new Collection();
client.modals =
new Collection();
/*
* START APPLICATION
*/
async function start() {
try {
/*
* Connect Mongo first.
*/
await mClient.connect();
console.log(
"MongoDB connected"
);
/*
* Load handlers.
*/
const handlerFiles =
fs.readdirSync(
"./handlers"
);
for (
const handler
of handlerFiles
) {
require(
`./handlers/${handler}`
)(client);
}
/*
* Login Discord.
*/
await client.login(
process.env.D_TOKEN
);
} catch (error) {
console.error(
"Failed to start ShaiBot:",
error
);
process.exit(1);
}
}
start();
+1693
View File
File diff suppressed because it is too large Load Diff
+423
View File
@@ -0,0 +1,423 @@
const {
MessageFlags
} = require("discord.js");
const { mClient } = require("../index");
const DATABASE = "neo-db";
const COOKIE_COLLECTION = "items_cookies";
const COOLDOWN_COLLECTION = "cooldowns_cookies";
function getCollections() {
const db = mClient.db(DATABASE);
return {
db,
cookies: db.collection(COOKIE_COLLECTION),
cooldowns: db.collection(COOLDOWN_COLLECTION)
};
}
async function getCookieBalance(userId) {
const { cookies } = getCollections();
const result = await cookies.findOne(
{
userId
},
{
projection: {
_id: 0,
cookies: 1
}
}
);
return result?.cookies ?? 0;
}
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
) {
throw new Error(
"INVALID_COOKIE_AMOUNT"
);
}
const { cookies } = getCollections();
const debit =
await cookies.updateOne(
{
userId: fromUserId,
cookies: {
$gte: amount
}
},
{
$inc: {
cookies: -amount
}
}
);
if (
debit.modifiedCount !== 1
) {
throw new Error(
"INSUFFICIENT_COOKIES"
);
}
try {
await cookies.updateOne(
{
userId: toUserId
},
{
$inc: {
cookies: amount
},
$setOnInsert: {
userId: toUserId
}
},
{
upsert: true
}
);
} catch (error) {
try {
await cookies.updateOne(
{
userId: fromUserId
},
{
$inc: {
cookies: amount
}
}
);
} catch (rollbackError) {
console.error(
"CRITICAL: Cookie rollback failed:",
rollbackError
);
}
throw error;
}
}
async function getCooldown(
userId,
guildId,
type
) {
const { cooldowns } = getCollections();
const result =
await cooldowns.findOne(
{
userId,
guildId
},
{
projection: {
_id: 0,
[type]: 1
}
}
);
return result?.[type] ?? 0;
}
async function setCooldown(
userId,
guildId,
type,
duration
) {
const { cooldowns } = getCollections();
const now = getUnixTime();
const next = now + duration;
await cooldowns.updateOne(
{
userId,
guildId
},
{
$set: {
[type]: next
},
$setOnInsert: {
userId,
guildId
}
},
{
upsert: true
}
);
return next;
}
/*
* Atomically claim a cooldown.
*
* Useful for button games where a double-click
* could otherwise resolve twice.
*/
async function claimCooldown(
userId,
guildId,
type,
duration
) {
const { cooldowns } = getCollections();
const now = getUnixTime();
const next = now + duration;
try {
const result =
await cooldowns.updateOne(
{
userId,
guildId,
$or: [
{
[type]: {
$lte: now
}
},
{
[type]: {
$exists: false
}
}
]
},
{
$set: {
[type]: next
},
$setOnInsert: {
userId,
guildId
}
},
{
upsert: true
}
);
return {
claimed:
result.modifiedCount === 1 ||
result.upsertedCount === 1,
next
};
} catch (error) {
/*
* A unique {userId, guildId} index may cause
* a duplicate-key error if an active cooldown
* already exists.
*/
if (error?.code === 11000) {
const active =
await getCooldown(
userId,
guildId,
type
);
return {
claimed: false,
next: active
};
}
throw error;
}
}
async function temporaryEphemeralReply(
interaction,
options,
deleteAfter = 5000
) {
await interaction.reply({
...options,
flags: MessageFlags.Ephemeral
});
setTimeout(
async () => {
try {
await interaction.deleteReply();
} catch {}
},
deleteAfter
);
}
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
);
}
function randomInteger(
min,
max
) {
return Math.floor(
Math.random() *
(max - min + 1)
) + min;
}
function randomFloat(
min,
max
) {
return (
Math.random() *
(max - min)
) + min;
}
function randomItem(items) {
return items[
Math.floor(
Math.random() *
items.length
)
];
}
function formatPercentage(value) {
return `${Math.round(value * 100)}%`;
}
module.exports = {
DATABASE,
COOKIE_COLLECTION,
COOLDOWN_COLLECTION,
getCollections,
getCookieBalance,
changeCookies,
transferCookies,
getCooldown,
setCooldown,
claimCooldown,
temporaryEphemeralReply,
temporaryEphemeralFollowUp,
getUnixTime,
randomInteger,
randomFloat,
randomItem,
formatPercentage
};
+836
View File
@@ -0,0 +1,836 @@
const {
EmbedBuilder
} = require("discord.js");
const {
getCookieBalance,
transferCookies,
claimCooldown,
temporaryEphemeralReply,
randomFloat,
formatPercentage
} = require("./cookieEconomy");
/*
* ============================================================
* CONFIG
* ============================================================
*/
const HEIST_COOLDOWN =
60 * 60; // 1 hour
/*
* Each approach has a different risk/reward profile.
*
* Sneak:
* High success chance
* Small reward
* Small failure penalty
*
* Hack:
* Medium success chance
* Medium reward
* Medium failure penalty
*
* Smash:
* Low success chance
* Large reward
* Large failure penalty
*/
const HEIST_METHODS = {
Sneak: {
baseChance:
0.65,
minStealPercent:
0.01,
maxStealPercent:
0.03,
maxStealAmount:
1500,
minPenaltyPercent:
0.01,
maxPenaltyPercent:
0.04
},
Hack: {
baseChance:
0.45,
minStealPercent:
0.03,
maxStealPercent:
0.06,
maxStealAmount:
3500,
minPenaltyPercent:
0.03,
maxPenaltyPercent:
0.07
},
Smash: {
baseChance:
0.25,
minStealPercent:
0.06,
maxStealPercent:
0.12,
maxStealAmount:
7500,
minPenaltyPercent:
0.06,
maxPenaltyPercent:
0.12
}
};
/*
* ============================================================
* RESOLVE HEIST
* ============================================================
*/
async function resolveCookieHeist(
interaction,
methodName
) {
/*
* The original /cookies heist command stores
* the player ID inside the embed footer:
*
* cookieHeist:123456789
*
* This prevents somebody else from pressing
* another player's heist buttons.
*/
const footer =
interaction.message
.embeds[0]
?.footer
?.text;
const [
type,
ownerId
] =
footer
?.split(":")
??
[];
if (
type !== "cookieHeist" ||
ownerId !== interaction.user.id
) {
return temporaryEphemeralReply(
interaction,
{
content:
"This isn't your heist. Start your own with `/cookies heist`."
}
);
}
/*
* Validate selected method.
*/
const method =
HEIST_METHODS[
methodName
];
if (!method) {
return temporaryEphemeralReply(
interaction,
{
content:
"That heist approach doesn't exist."
}
);
}
/*
* ========================================================
* CLAIM COOLDOWN
* ========================================================
*
* This is atomic so rapid double-clicks can't
* resolve the same heist multiple times.
*/
const cooldown =
await claimCooldown(
interaction.user.id,
interaction.guildId,
"heist",
HEIST_COOLDOWN
);
if (!cooldown.claimed) {
return temporaryEphemeralReply(
interaction,
{
content:
`You can attempt another vault heist <t:${cooldown.next}:R>.`
}
);
}
/*
* ========================================================
* LOCK THE HEIST MESSAGE
* ========================================================
*
* Remove all buttons immediately.
*/
await interaction.update({
embeds: [
new EmbedBuilder()
.setTitle(
"🏦 Vault Heist in Progress..."
)
.setDescription(
getHeistProgressText(
methodName
)
)
],
components: []
});
/*
* ========================================================
* GET BALANCES
* ========================================================
*/
const playerId =
interaction.user.id;
const vaultId =
interaction.client.user.id;
const [
playerCookies,
vaultCookies
] =
await Promise.all([
getCookieBalance(
playerId
),
getCookieBalance(
vaultId
)
]);
/*
* Something may have changed between opening
* the heist menu and pressing the button.
*/
if (
playerCookies < 1 ||
vaultCookies < 1
) {
return interaction.editReply({
embeds: [
new EmbedBuilder()
.setTitle(
"🏦 Heist Aborted"
)
.setDescription(
"There weren't enough Cookies available to resolve the heist."
)
],
components: []
});
}
/*
* Small dramatic pause.
*
* Only one edit happens after this, so this is
* comfortably within Discord rate limits.
*/
await sleep(
1200
);
/*
* ========================================================
* VAULT DIFFICULTY
* ========================================================
*
* The richer ShaiBot's vault becomes,
* the harder it is to rob.
*
* Penalty is capped at 15 percentage points.
*/
const securityPenalty =
getVaultSecurityPenalty(
vaultCookies
);
const successChance =
Math.max(
0.10,
method.baseChance -
securityPenalty
);
const success =
Math.random() <
successChance;
/*
* ========================================================
* SUCCESS
* ========================================================
*/
if (success) {
/*
* Roll percentage of the current vault.
*/
const percent =
randomFloat(
method.minStealPercent,
method.maxStealPercent
);
/*
* Apply both:
*
* percentage roll
* AND
* hard maximum
*
* This prevents a massive vault from producing
* absurd payouts.
*/
const amount =
Math.max(
1,
Math.min(
Math.floor(
vaultCookies *
percent
),
method.maxStealAmount,
vaultCookies
)
);
/*
* ShaiBot pays the robber.
*/
await transferCookies(
vaultId,
playerId,
amount
);
const remaining =
await getCookieBalance(
vaultId
);
const actualPercentage =
(
amount /
vaultCookies
) *
100;
const embed =
new EmbedBuilder()
.setTitle(
"💥 Vault Breached!"
)
.setDescription(
getSuccessText(
methodName,
interaction.user,
amount
)
)
.addFields(
{
name:
"Approach",
value:
methodName,
inline:
true
},
{
name:
"Success Chance",
value:
`**${formatPercentage(successChance)}**`,
inline:
true
},
{
name:
"Vault Taken",
value:
`**${actualPercentage.toFixed(2)}%**`,
inline:
true
},
{
name:
"Cookies Stolen",
value:
`**${amount.toLocaleString()} 🍪**`,
inline:
true
},
{
name:
"Vault Remaining",
value:
`**${remaining.toLocaleString()} 🍪**`,
inline:
true
},
{
name:
"Next Heist",
value:
`<t:${cooldown.next}:R>`,
inline:
true
}
);
return interaction.editReply({
embeds: [
embed
],
components: []
});
}
/*
* ========================================================
* FAILURE
* ========================================================
*
* Failure fines the player.
*
* Unlike normal player stealing, the fine goes
* directly into ShaiBot's vault.
*/
const penaltyPercent =
randomFloat(
method.minPenaltyPercent,
method.maxPenaltyPercent
);
const penalty =
Math.max(
1,
Math.min(
Math.floor(
playerCookies *
penaltyPercent
),
playerCookies
)
);
await transferCookies(
playerId,
vaultId,
penalty
);
const [
playerAfter,
vaultAfter
] =
await Promise.all([
getCookieBalance(
playerId
),
getCookieBalance(
vaultId
)
]);
const embed =
new EmbedBuilder()
.setTitle(
"🚨 HEIST FAILED"
)
.setDescription(
getFailureText(
methodName,
interaction.user,
penalty
)
)
.addFields(
{
name:
"Approach",
value:
methodName,
inline:
true
},
{
name:
"Success Chance",
value:
`**${formatPercentage(successChance)}**`,
inline:
true
},
{
name:
"Fine",
value:
`**${penalty.toLocaleString()} 🍪**`,
inline:
true
},
{
name:
"Your Vault",
value:
`**${playerAfter.toLocaleString()} 🍪**`,
inline:
true
},
{
name:
"House Vault",
value:
`**${vaultAfter.toLocaleString()} 🍪**`,
inline:
true
},
{
name:
"Next Heist",
value:
`<t:${cooldown.next}:R>`,
inline:
true
}
);
return interaction.editReply({
embeds: [
embed
],
components: []
});
}
/*
* ============================================================
* VAULT SECURITY
* ============================================================
*/
function getVaultSecurityPenalty(
vaultCookies
) {
/*
* Logarithmic scaling means the vault gets harder
* as it becomes richer without becoming impossible.
*
* Examples approximately:
*
* 100 -> 5%
* 1,000 -> 7.5%
* 10,000 -> 10%
* 100,000 -> 12.5%
* 1,000,000 -> 15%
*/
return Math.min(
Math.log10(
vaultCookies + 1
) *
0.025,
0.15
);
}
/*
* ============================================================
* FLAVOR TEXT
* ============================================================
*/
function getHeistProgressText(
methodName
) {
switch (
methodName
) {
case "Sneak":
return [
"🥷 You slip through the service entrance...",
"",
"The Cookie Vault guards haven't noticed you yet."
].join("\n");
case "Hack":
return [
"💻 You connect to the vault terminal...",
"",
"Several extremely suspicious progress bars appear."
].join("\n");
case "Smash":
return [
"💥 You charge directly at the vault...",
"",
"Subtlety has been abandoned."
].join("\n");
default:
return "The heist is underway...";
}
}
function getSuccessText(
methodName,
user,
amount
) {
const amountText =
`**${amount.toLocaleString()} Cookies**`;
switch (
methodName
) {
case "Sneak":
return (
`🥷 ${user} slipped through security and quietly escaped with ${amountText}.`
);
case "Hack":
return (
`💻 ${user} convinced the vault terminal that ${amountText} belonged to them.`
);
case "Smash":
return (
`💥 ${user} smashed through the Cookie Vault and escaped with ${amountText}!`
);
default:
return (
`${user} escaped with ${amountText}.`
);
}
}
function getFailureText(
methodName,
user,
penalty
) {
const penaltyText =
`**${penalty.toLocaleString()} Cookies**`;
switch (
methodName
) {
case "Sneak":
return [
`🚨 ${user} stepped directly on the world's loudest Cookie wrapper.`,
"",
`The ShaiBot Vault collected a ${penaltyText} fine.`
].join("\n");
case "Hack":
return [
`🚨 ${user} entered the password incorrectly one too many times.`,
"",
`The ShaiBot Vault collected a ${penaltyText} security fee.`
].join("\n");
case "Smash":
return [
`🚨 ${user} discovered that the vault door is significantly stronger than expected.`,
"",
`Repairs cost ${penaltyText}. Naturally, you're paying.`
].join("\n");
default:
return (
`The heist failed and cost ${user} ${penaltyText}.`
);
}
}
/*
* ============================================================
* UTIL
* ============================================================
*/
function sleep(ms) {
return new Promise(
resolve =>
setTimeout(
resolve,
ms
)
);
}
module.exports = {
resolveCookieHeist
};