added bankruptcy

This commit is contained in:
DeSqBlocki
2026-08-23 00:09:13 +02:00
parent 030b4b8883
commit 9c867b1adb
12 changed files with 985 additions and 676 deletions
+188
View File
@@ -0,0 +1,188 @@
const { getCollections, getCookieBalance, getUnixTime } = require("./cookieEconomy");
const { trackProfileEvent } = require("./profileSystem");
const BANKRUPTCY_COLLECTION = "items_bankruptcy";
const BANKRUPTCY_GRANT = 1000;
const BANKRUPTCY_COOLDOWN = 1 * 60 * 60;
function getBankruptcyCollection() {
return getCollections().db.collection(BANKRUPTCY_COLLECTION);
}
async function ensureBankruptcyIndexes() {
const bankruptcy = getBankruptcyCollection();
await bankruptcy.createIndex(
{ userId: 1, guildId: 1 },
{ unique: true }
);
}
async function getBankruptcyStatus(userId, guildId) {
const balance = await getCookieBalance(userId);
const document = await getBankruptcyCollection().findOne({
userId,
guildId
});
const now = getUnixTime();
const nextAvailableAt = Number(document?.nextAvailableAt ?? 0);
return {
balance,
eligible: balance === 0 && nextAvailableAt <= now,
nextAvailableAt,
claims: Number(document?.claims ?? 0)
};
}
async function declareBankruptcy(interaction, userId = interaction.user.id) {
const guildId = interaction.guildId;
if (!guildId) {
return {
ok: false,
reason: "GUILD_ONLY"
};
}
const { cookies } = getCollections();
const bankruptcy = getBankruptcyCollection();
const now = getUnixTime();
const previous = await bankruptcy.findOne({
userId,
guildId
});
const nextAvailableAt = Number(previous?.nextAvailableAt ?? 0);
if (nextAvailableAt > now) {
return {
ok: false,
reason: "COOLDOWN",
nextAvailableAt
};
}
let claimLock;
try {
claimLock = await bankruptcy.findOneAndUpdate(
{
userId,
guildId,
$or: [
{ nextAvailableAt: { $exists: false } },
{ nextAvailableAt: { $lte: now } }
]
},
{
$set: {
nextAvailableAt: now + BANKRUPTCY_COOLDOWN,
lastClaimedAt: now
},
$inc: {
claims: 1
},
$setOnInsert: {
userId,
guildId
}
},
{
upsert: true,
returnDocument: "after"
}
);
} catch (error) {
if (error?.code !== 11000) {
throw error;
}
}
if (!claimLock) {
const status = await getBankruptcyStatus(userId, guildId);
return {
ok: false,
reason: "COOLDOWN",
nextAvailableAt: status.nextAvailableAt
};
}
let credit;
try {
credit = await cookies.updateOne(
{
userId,
$or: [
{ cookies: 0 },
{ cookies: { $exists: false } }
]
},
{
$inc: {
cookies: BANKRUPTCY_GRANT
},
$setOnInsert: {
userId
}
},
{
upsert: true
}
);
} catch (error) {
if (error?.code !== 11000) {
throw error;
}
credit = { modifiedCount: 0, upsertedCount: 0 };
}
if (credit.modifiedCount !== 1 && credit.upsertedCount !== 1) {
await bankruptcy.updateOne(
{ userId, guildId },
{
$set: {
nextAvailableAt,
lastClaimedAt: previous?.lastClaimedAt ?? null,
claims: Number(previous?.claims ?? 0)
}
}
);
return {
ok: false,
reason: "BALANCE_NOT_ZERO",
balance: await getCookieBalance(userId)
};
}
await trackProfileEvent(
interaction,
userId,
{
type: "cookie_gain",
amount: BANKRUPTCY_GRANT
}
);
return {
ok: true,
amount: BANKRUPTCY_GRANT,
balance: BANKRUPTCY_GRANT,
nextAvailableAt: now + BANKRUPTCY_COOLDOWN,
claims: Number(claimLock.claims ?? 1)
};
}
module.exports = {
BANKRUPTCY_GRANT,
BANKRUPTCY_COOLDOWN,
ensureBankruptcyIndexes,
getBankruptcyStatus,
declareBankruptcy
};
+6 -6
View File
@@ -27,30 +27,30 @@ const HEIST_METHODS = {
baseChance: 0.65,
minStealPercent: 0.01,
maxStealPercent: 0.03,
maxStealAmount: 1500,
maxStealAmount: 5000,
minPenaltyPercent: 0.01,
maxPenaltyPercent: 0.04,
maxPenaltyAmount: 750
maxPenaltyAmount: 1500
},
Hack: {
baseChance: 0.45,
minStealPercent: 0.03,
maxStealPercent: 0.06,
maxStealAmount: 3500,
maxStealAmount: 12500,
minPenaltyPercent: 0.03,
maxPenaltyPercent: 0.07,
maxPenaltyAmount: 2000
maxPenaltyAmount: 4000
},
Smash: {
baseChance: 0.25,
minStealPercent: 0.06,
maxStealPercent: 0.12,
maxStealAmount: 7500,
maxStealAmount: 25000,
minPenaltyPercent: 0.06,
maxPenaltyPercent: 0.12,
maxPenaltyAmount: 5000
maxPenaltyAmount: 10000
}
};
+2 -1
View File
@@ -26,6 +26,7 @@ const {
const COLLECT_COOLDOWN =
60 * 60;
const COLLECT_MAX = 1000;
/*
* IMPORTANT:
@@ -223,7 +224,7 @@ async function collectCookiesFromSaucer(
const amount =
Math.floor(
Math.random() *
1000
COLLECT_MAX
)
+
1;
+78 -219
View File
@@ -6,101 +6,56 @@ const {
} = require("discord.js");
const {
getCookieBalance,
temporaryEphemeralReply
getCookieBalance
} = require("./cookieEconomy");
const {
createInteractionSession,
requireInteractionSession
} = require("./interactionSession");
/*
* ============================================================
* MAIN MENU
* ============================================================
*/
async function showCrumbSaucer(interaction) {
if (interaction.isButton()) {
const session = await requireInteractionSession(interaction);
async function showCrumbSaucer(
interaction
) {
/*
* Button navigation must preserve ownership.
*/
if (
interaction.isButton()
) {
const ownerId =
getCrumbSaucerSessionOwner(
interaction
);
if (
ownerId !==
interaction.user.id
) {
return temporaryEphemeralReply(
interaction,
{
content:
"This isn't your Crumb Saucer session."
}
);
if (!session) {
return;
}
}
const balance = await getCookieBalance(interaction.user.id);
const embed = buildMainMenuEmbed(interaction.user, balance);
const balance =
await getCookieBalance(
interaction.user.id
);
const embed =
buildMainMenuEmbed(
interaction.user,
balance
);
if (
interaction.isButton()
) {
return interaction.update({
embeds: [
embed
],
components:
buildMainMenuButtons()
embeds: [embed],
components: buildMainMenuButtons()
});
}
const balance = await getCookieBalance(interaction.user.id);
const embed = buildMainMenuEmbed(interaction.user, balance);
return interaction.reply({
embeds: [
embed
],
components:
buildMainMenuButtons()
await interaction.reply({
embeds: [embed],
components: buildMainMenuButtons()
});
const message = await interaction.fetchReply();
await createInteractionSession(
interaction,
{
messageId: message.id,
ownerId: interaction.user.id,
kind: "crumbSaucer"
}
);
return message;
}
/*
* ============================================================
* MAIN MENU EMBED
* ============================================================
*/
function buildMainMenuEmbed(
user,
balance
) {
function buildMainMenuEmbed(user, balance) {
return new EmbedBuilder()
.setTitle(
"🎰 The Crumb Saucer"
)
.setTitle("🎰 The Crumb Saucer")
.setDescription(
[
`Welcome, ${user}!`,
@@ -121,158 +76,78 @@ function buildMainMenuEmbed(
"Bet on Red, Black, Odd, Even, or Zero.",
"",
"### 🏦 Vault Heist",
"Attempt to breach ShaiBot's Cookie Vault."
"Attempt to breach ShaiBot's Cookie Vault.",
"",
"### 🛟 Bankruptcy",
"At exactly 0 Cookies, claim a 1,000 Cookie starter kit."
].join("\n")
)
.addFields({
name:
"Your Vault",
value:
`**${balance.toLocaleString()} 🍪**`
name: "Your Vault",
value: `**${balance.toLocaleString()} 🍪**`
})
.setFooter({
text:
`crumbSaucer:${user.id}`
text: `crumbSaucer:${user.id}`
});
}
/*
* ============================================================
* MAIN MENU BUTTONS
* ============================================================
*/
function buildMainMenuButtons() {
return [
new ActionRowBuilder()
.addComponents(
new ButtonBuilder()
.setCustomId(
"crumbSaucerSlots"
)
.setLabel(
"Slots"
)
.setEmoji(
"🎰"
)
.setStyle(
ButtonStyle.Primary
),
.setCustomId("crumbSaucerSlots")
.setLabel("Slots")
.setEmoji("🎰")
.setStyle(ButtonStyle.Primary),
new ButtonBuilder()
.setCustomId(
"crumbSaucerBlackjack"
)
.setLabel(
"Blackjack"
)
.setEmoji(
"🃏"
)
.setStyle(
ButtonStyle.Success
),
.setCustomId("crumbSaucerBlackjack")
.setLabel("Blackjack")
.setEmoji("🃏")
.setStyle(ButtonStyle.Success),
new ButtonBuilder()
.setCustomId(
"crumbSaucerRoulette"
)
.setLabel(
"Roulette"
)
.setEmoji(
"🎡"
)
.setStyle(
ButtonStyle.Primary
),
.setCustomId("crumbSaucerRoulette")
.setLabel("Roulette")
.setEmoji("🎡")
.setStyle(ButtonStyle.Primary),
new ButtonBuilder()
.setCustomId(
"crumbSaucerHeist"
)
.setLabel(
"Vault Heist"
)
.setEmoji(
"🏦"
)
.setStyle(
ButtonStyle.Danger
)
.setCustomId("crumbSaucerHeist")
.setLabel("Vault Heist")
.setEmoji("🏦")
.setStyle(ButtonStyle.Danger)
),
new ActionRowBuilder()
.addComponents(
new ButtonBuilder()
.setCustomId(
"crumbSaucerCollectMenu"
)
.setLabel(
"Cookie Bakery"
)
.setEmoji(
"🍪"
)
.setStyle(
ButtonStyle.Success
),
/*
* Do NOT use global "abort" here.
*
* The dedicated handler validates
* Crumb Saucer ownership first.
*/
.setCustomId("crumbSaucerCollectMenu")
.setLabel("Cookie Bakery")
.setEmoji("🍪")
.setStyle(ButtonStyle.Success),
new ButtonBuilder()
.setCustomId(
"crumbSaucerClose"
)
.setLabel(
"Close"
)
.setEmoji(
"✖️"
)
.setStyle(
ButtonStyle.Secondary
)
.setCustomId("crumbSaucerBankruptcy")
.setLabel("Bankruptcy")
.setEmoji("🛟")
.setStyle(ButtonStyle.Secondary),
new ButtonBuilder()
.setCustomId("sessionClose")
.setLabel("Close")
.setEmoji("✖️")
.setStyle(ButtonStyle.Secondary)
)
];
}
function getCrumbSaucerSessionOwner(interaction) {
const footer = interaction.message?.embeds?.[0]?.footer?.text;
const parts = footer?.split(":") ?? [];
/*
* ============================================================
* SESSION OWNER
* ============================================================
*/
function getCrumbSaucerSessionOwner(
interaction
) {
const footer =
interaction.message
?.embeds?.[0]
?.footer
?.text;
const parts =
footer?.split(":")
??
[];
switch (
parts[0]
) {
switch (parts[0]) {
case "crumbSaucer":
case "crumbSaucerCollect":
case "crumbSaucerSlots":
@@ -283,35 +158,19 @@ function getCrumbSaucerSessionOwner(
case "crumbRouletteBet":
case "crumbRouletteResult":
case "cookieHeist":
return parts[1];
/*
* Active Blackjack:
*
* crumbBlackjack:<gameId>:<userId>
*/
case "crumbBlackjack":
return parts[2];
default:
return null;
}
}
/*
* ============================================================
* EXPORTS
* ============================================================
*/
module.exports = {
showCrumbSaucer,
buildMainMenuEmbed,
buildMainMenuButtons,
getCrumbSaucerSessionOwner
};
};
+284
View File
@@ -0,0 +1,284 @@
const { EmbedBuilder, MessageFlags } = require("discord.js");
const { mClient } = require("../index");
const DATABASE_NAME = process.env.M_DB;
const SESSION_COLLECTION = "items_sessions";
const DEFAULT_SESSION_LIFETIME_MS = 24 * 60 * 60 * 1000;
function getSessionCollection() {
if (!DATABASE_NAME) {
throw new Error("M_DB is not configured in .env");
}
return mClient.db(DATABASE_NAME).collection(SESSION_COLLECTION);
}
async function ensureInteractionSessionIndexes() {
const sessions = getSessionCollection();
await sessions.createIndex(
{ messageId: 1 },
{ unique: true }
);
await sessions.createIndex(
{ expiresAt: 1 },
{ expireAfterSeconds: 0 }
);
}
async function createInteractionSession(
interaction,
{
messageId = null,
ownerId = interaction.user.id,
kind = "menu",
targetId = null,
metadata = {},
lifetimeMs = DEFAULT_SESSION_LIFETIME_MS
} = {}
) {
const resolvedMessageId =
messageId ?? interaction.message?.id;
if (!resolvedMessageId) {
throw new Error("SESSION_MESSAGE_ID_REQUIRED");
}
const sessions = getSessionCollection();
const now = new Date();
const expiresAt = new Date(now.getTime() + lifetimeMs);
await sessions.updateOne(
{ messageId: resolvedMessageId },
{
$set: {
messageId: resolvedMessageId,
ownerId,
guildId: interaction.guildId ?? null,
channelId: interaction.channelId ?? null,
kind,
targetId,
metadata,
active: true,
updatedAt: now,
expiresAt
},
$setOnInsert: {
createdAt: now
}
},
{ upsert: true }
);
return sessions.findOne({ messageId: resolvedMessageId });
}
async function registerReplySession(interaction, options = {}) {
const message = await interaction.fetchReply();
return createInteractionSession(
interaction,
{
...options,
messageId: message.id
}
);
}
async function getInteractionSession(interaction) {
const messageId = interaction.message?.id;
if (!messageId) {
return null;
}
return getSessionCollection().findOne({
messageId,
active: true,
expiresAt: { $gt: new Date() }
});
}
function inferLegacyOwnerId(interaction) {
const footer = interaction.message?.embeds?.[0]?.footer?.text;
const parts = footer?.split(":") ?? [];
if (!parts.length) {
return null;
}
switch (parts[0]) {
case "profile":
case "crumbSaucer":
case "crumbSaucerCollect":
case "crumbSaucerSlots":
case "crumbSaucerSlotsResult":
case "crumbSaucerBlackjack":
case "crumbBlackjackResult":
case "crumbRouletteMenu":
case "crumbRouletteBet":
case "crumbRouletteResult":
case "cookieHeist":
return parts[1] ?? null;
case "crumbBlackjack":
return parts[2] ?? null;
default:
return null;
}
}
async function replySessionError(
interaction,
content = "This isn't your session."
) {
const payload = {
content,
flags: MessageFlags.Ephemeral
};
if (interaction.replied || interaction.deferred) {
return interaction.followUp(payload);
}
return interaction.reply(payload);
}
async function requireInteractionSession(
interaction,
{
kinds = null,
allowLegacy = true
} = {}
) {
const session = await getInteractionSession(interaction);
if (session) {
if (session.ownerId !== interaction.user.id) {
await replySessionError(interaction);
return null;
}
if (
Array.isArray(kinds) &&
kinds.length > 0 &&
!kinds.includes(session.kind)
) {
await replySessionError(
interaction,
"This control belongs to a different session."
);
return null;
}
return session;
}
if (allowLegacy) {
const ownerId = inferLegacyOwnerId(interaction);
if (ownerId) {
if (ownerId !== interaction.user.id) {
await replySessionError(interaction);
return null;
}
return {
messageId: interaction.message?.id ?? null,
ownerId,
guildId: interaction.guildId ?? null,
channelId: interaction.channelId ?? null,
kind: "legacy",
targetId: null,
metadata: {},
active: true,
legacy: true
};
}
}
return {
messageId: interaction.message?.id ?? null,
ownerId: interaction.user.id,
kind: "unmanaged",
metadata: {},
active: true,
unmanaged: true
};
}
async function guardSessionInteraction(interaction) {
if (!interaction.isButton()) {
return true;
}
const session = await getInteractionSession(interaction);
if (session) {
if (session.ownerId === interaction.user.id) {
return true;
}
await replySessionError(interaction);
return false;
}
const ownerId = inferLegacyOwnerId(interaction);
if (!ownerId || ownerId === interaction.user.id) {
return true;
}
await replySessionError(interaction);
return false;
}
async function closeInteractionSession(interaction) {
const session = await requireInteractionSession(interaction);
if (!session) {
return false;
}
if (session.messageId && !session.unmanaged) {
await getSessionCollection().updateOne(
{ messageId: session.messageId },
{
$set: {
active: false,
closedAt: new Date(),
updatedAt: new Date()
}
}
);
}
const embed = new EmbedBuilder()
.setTitle("Session Closed")
.setDescription("Closing menu. . .");
await interaction.update({
embeds: [embed],
components: []
});
setTimeout(() => {
interaction.deleteReply().catch(() => {});
}, 1200);
return true;
}
module.exports = {
ensureInteractionSessionIndexes,
createInteractionSession,
registerReplySession,
getInteractionSession,
requireInteractionSession,
guardSessionInteraction,
closeInteractionSession,
replySessionError,
inferLegacyOwnerId
};
+1 -3
View File
@@ -1610,9 +1610,7 @@ function buildProfileButtons() {
),
new ButtonBuilder()
.setCustomId(
"profileClose"
)
.setCustomId("sessionClose")
.setLabel(
"Close"
)