Compare commits

...

30 Commits

Author SHA1 Message Date
DeSqBlocki 6e3e1620d1 added more documentation 2026-09-13 22:10:34 +02:00
DeSqBlocki dd4d6e4817 added achievements for new steal logic 2026-09-13 21:56:18 +02:00
DeSqBlocki b441de286c added ai rulebook 2026-09-13 21:56:01 +02:00
DeSqBlocki 2ca18aae06 updated steal logic 2026-09-13 21:14:56 +02:00
DeSqBlocki 1ef2295c07 snapshot_poker_phase5 2026-09-08 21:49:43 +02:00
DeSqBlocki 6342c4569b snapshot_poker_ui_ux_fix_again_fuckmylife 2026-09-08 21:12:34 +02:00
DeSqBlocki c61e34c241 snapshot_poker_uiux_cleanup_2 2026-09-08 21:05:52 +02:00
DeSqBlocki 82cef1b34a snapshot_poker_uiux_cleanup 2026-09-08 20:51:58 +02:00
DeSqBlocki 136a5902c0 snapshot_poker_phase4_hotfix 2026-09-08 20:17:19 +02:00
DeSqBlocki 903a2f3081 snapshot_poker_phase3 2026-09-08 19:37:02 +02:00
DeSqBlocki 995a2317b9 small changes to UI/UX 2026-08-27 21:35:06 +02:00
DeSqBlocki e3dbb11e12 snapshot migration pre-phase 6, stable 2026-08-27 21:14:27 +02:00
DeSqBlocki 14a059034c snapshot migration subPhase 5.5 2026-08-27 19:50:12 +02:00
DeSqBlocki 9710fc65bf snapshot migration phase 5 2026-08-27 19:34:16 +02:00
DeSqBlocki 42fc848f8c snapshot migration phase 4 2026-08-27 19:19:58 +02:00
DeSqBlocki c2ac7737c0 snapshot rollout phase 1 2026-08-27 18:52:56 +02:00
DeSqBlocki 6a519f3a2d changed achievment to work without followup 2026-08-23 01:19:22 +02:00
DeSqBlocki 338b32430d made achievments easier to discern 2026-08-23 01:00:03 +02:00
DeSqBlocki 9f1a539501 updated readme 2026-08-23 00:52:37 +02:00
DeSqBlocki 425686ec40 added new logic to the new rewards 2026-08-23 00:43:43 +02:00
DeSqBlocki 4d858554be made collect logic actually connect to the utils 2026-08-23 00:21:52 +02:00
DeSqBlocki 9c867b1adb added bankruptcy 2026-08-23 00:09:13 +02:00
DeSqBlocki 030b4b8883 updated profile and centralized functions 2026-08-22 23:33:20 +02:00
DeSqBlocki 08d1a8c5cd updated badges 2026-08-22 22:46:40 +02:00
DeSqBlocki 9e0870c1ed snapshot 2026-08-22 22:29:16 +02:00
DeSqBlocki ca823726e3 added more achievments 2026-08-22 22:10:41 +02:00
DeSqBlocki ae35f61522 added cookie collect button 2026-08-22 20:59:25 +02:00
DeSqBlocki 985f481fae fixed some copy pasted issues 2026-08-22 20:40:25 +02:00
DeSqBlocki e437c051b9 profile update with badges and achievements 2026-08-22 20:27:42 +02:00
DeSqBlocki c630543f9e fixed runitback for slots 2026-08-22 20:15:25 +02:00
68 changed files with 23352 additions and 5106 deletions
+3
View File
@@ -1,2 +1,5 @@
node_modules node_modules
.env .env
backup/
*.bak
tests/
+2220
View File
File diff suppressed because it is too large Load Diff
+193 -282
View File
@@ -1,338 +1,280 @@
# ShaiBot # ShaiBot
A modular **Discord.js** bot with a shared Cookie economy, interactive ShaiBot is a modular Discord community bot built around a persistent
Crumb Saucer games, cooperative boss battles, raid planning, and **Cookie economy**, interactive games, player progression, and
community interactions. multiplayer activities.
> **Status:** Active development. Game balance and individual features The bot's main entertainment hub is the **Crumb Saucer**, where players
> may change. can collect and wager Cookies, play casino-style games, attempt Vault
Heists, recover through Bankruptcy, and sit down at a multiplayer Texas
Hold'em table.
## Features ShaiBot also includes persistent player profiles, achievements,
guild-unique badges, cooperative encounters, raid planning, and lighter
community features.
### 🍪 Cookie Economy > **Status:** Active development and stabilization. The major economy,
> profile, achievement, and Poker overhaul is live; current work is
> focused primarily on testing, balancing, and polish.
Cookies are ShaiBot's shared virtual currency. The economy supports ## Highlights
persistent balances, atomic balance checks, transfers, per-user
cooldowns, and a shared bot/house vault.
The bot itself acts as the general Cookie vault: gambling losses and - 🍪 Persistent Cookie economy
penalties can feed the vault, while wins, heists, and rewards can pay - 🎰 Crumb Saucer game hub
Cookies back out. - 🎰 Slots, Blackjack, Roulette, Vault Heist, and Bankruptcy
- ♠️ Persistent 2--6 player Texas Hold'em
- 🏴 Player-to-player Cookie stealing
- 👤 Lifetime player profiles and statistics
- 🏆 Personal achievements
- 🎖️ Guild-unique global badges
- 🔁 All-In and Run It Back progression
- ⚔️ Cooperative Crumble Strike encounters
- 📅 Raid planning and community utilities
- 💾 Mongo-backed persistence and recovery
### 🎰 The Crumb Saucer ## The Crumb Saucer
The **Crumb Saucer** is ShaiBot's Cookie-powered entertainment hub. The **Crumb Saucer** is the main home for ShaiBot's Cookie-powered
games.
#### Slots ### 🍪 Cookie Bakery
- Multiple Cookie wagers Collect Cookies using the same shared collection system as
- Embed-based reel presentation `/cookies collect`.
- Different symbol payouts and jackpots
- Replay support
- Shared Cookie vault
#### Blackjack ### 🎰 Slots
- Hit, Stand, and Double Spin for symbol combinations, jackpots, All-In wagers, and Run It Back
- Dealer stands on 17 streaks.
- Natural Blackjack pays 3:2
- Push returns the wager
- Persistent active hands
- Per-player session ownership
- Safe simultaneous sessions
#### Roulette ### 🃏 Blackjack
A compact European Roulette implementation using **0--36**. Includes Hit, Stand, Double, natural Blackjack, pushes, All-In, and Run
It Back.
Available bets: ### 🎡 Roulette
- 🔴 Red A compact European Roulette game using numbers `0–36`, with Red, Black,
- ⚫ Black Odd, Even, and Zero bets.
- Odd
- Even
- 🟢 Zero
Roulette uses repeated edits of the same embed for a ### 🏦 Vault Heist
rate-limit-conscious pseudo-animation. Zero loses on Red, Black, Odd,
and Even.
#### Vault Heist Attempt to steal from ShaiBot's house vault using different risk/reward
approaches.
Players can attempt to steal directly from ShaiBot's Cookie vault using ### 🆘 Bankruptcy
different risk/reward approaches such as **Sneak**, **Hack**, and
**Smash**. Heist actions share a per-player cooldown so changing
approach does not bypass it.
### ⚔️ Crumble Strike A recovery mechanic for players who need a fresh start, with profile
progression for rebuilding afterward.
**Crumble Strike** is a cooperative Cookie-powered boss battle. ### ♠️ Shai Poker
- A player spawns an encounter. A persistent multiplayer Texas Hold'em game for **2--6 players**.
- Other players join during recruitment.
- The author can start after at least **5 players** have joined.
- Players can join an active encounter later.
- Encounters have a limited lifetime.
- Failure can result in committed Cookies being lost.
Combat actions include: Current defaults:
- **Strike** --- basic damage and Energy generation.
- **Defend** --- reduces incoming damage at an Energy cost.
- **Heal** --- restores party health at an Energy cost.
- **Revive** --- party-wide revival progress. Normal roles add +1 to
every downed player; Healers add +2. Three progress revives a
player.
- **Limit Break** --- consumes the party's global Limit resource for
major damage.
Roles are **DPS**, **Tank**, and **Healer**, with role-specific
proficiencies. The general progression theme includes **Sprout →
Adventurer → Warrior of Light**.
### 🏴 Cookie Stealing
Normal player-to-player stealing is separate from Vault Heist. Steal
difficulty can account for attacker and target wealth, successful steals
can use a randomized percentage with a cap, and failed attempts can
apply a fee that feeds the bot vault.
### 📅 Raid Planner
Raid planning uses Discord embeds and reactions. Participation can be
counted across reactions without automatically removing user reactions,
including a count of unique participants.
### 👈 Poke
A lightweight social command can privately poke another guild member
through DMs. Repeated pokes are bounded to a rate-limit-friendly
maximum.
## Project Structure
A typical layout:
``` text ``` text
ShaiBot/ Buy-in: 1,000 Cookies
├── buttons/ Blinds: 10 / 20
│ ├── crumbSaucer/ Turn timer: 90 seconds
│ └── crumbleStrike/ Pot model: single pot
├── commands/
│ └── applications/
├── events/
├── handlers/
├── modals/
├── utils/
│ ├── cookieEconomy.js
│ ├── crumbBlackjack.js
│ ├── crumbRoulette.js
│ └── crumbleStrike.js
├── index.js
├── package.json
└── .env
``` ```
Interaction modules are loaded into Discord.js collections such as: Poker supports private hole cards, public community cards,
Fold/Check/Call/Bet/Raise/All-In, automatic timeout folding, restart
recovery, host cancellation, safe buy-in/cash-out settlement, Poker
statistics, achievements, and guild-first badges.
``` js Side pots, tournaments, bots, rake, and player-facing equity assistance
client.commands = new Collection(); are intentionally outside the current V1 design.
client.legacyCommands = new Collection();
client.aliases = new Collection(); ## Player Profiles
client.buttons = new Collection();
client.selectMenus = new Collection(); Profiles provide the permanent history behind ShaiBot's progression
client.modals = new Collection(); systems.
```
They track things such as:
- Game wins and losses
- Per-game statistics
- Cookie gains and losses
- Giving and stealing
- Daily activity and streaks
- Social progression
- Run It Back streaks
- Poker history and hand accomplishments
- Achievements
- Global badges
Completed multiplayer game documents do not need to become permanent
history when the meaningful result already belongs in the profile
system.
## Achievements & Global Badges
**Achievements** are personal progression. Any eligible player can earn
them.
**Global badges** are guild-unique distinctions, usually awarded to the
first player in a guild to accomplish something notable.
Progression spans the Cookie economy, stealing, Crumb Saucer games,
activity, social interactions, Poker, and special cross-game challenges.
## Cookie Stealing
Player-to-player stealing is separate from Vault Heist.
The current system accounts for wealth differences, recent pressure on
the victim, percentage-based loot, hard caps, and an hourly exposure
budget.
Detailed balancing and mechanics are documented in
[`COOKIE_LOGIC.md`](COOKIE_LOGIC.md).
## ⚔️ Crumble Strike
Crumble Strike is ShaiBot's cooperative boss-battle system.
Players recruit a party, choose combat roles, commit Cookies, and work
together using actions such as Strike, Defend, Heal, Revive, and Limit
Break.
Encounters use persistent state because they can span many players and
interactions.
## 📅 Raid Planner & Community Features
ShaiBot also includes raid-planning tools and smaller social/community
commands alongside its economy and progression systems.
------------------------------------------------------------------------
# Installation
## Requirements ## Requirements
- Node.js - Node.js
- Discord.js
- MongoDB - MongoDB
- A Discord bot application/token - A Discord bot application/token
- `dotenv` - Dependencies listed in `package.json`
Use the Node.js version required by the dependencies in your Clone and install:
`package.json`.
## Installation
Clone the repository:
``` bash ``` bash
git clone https://github.com/YOUR_USERNAME/ShaiBot.git git clone https://git.desq-gaming.de/Shaiwase/ShaiBot.git
cd ShaiBot cd ShaiBot
```
Install dependencies:
``` bash
npm install npm install
``` ```
Create `.env` in the project root: Create `.env` in the repository root:
``` env ``` env
D_TOKEN=YOUR_DISCORD_BOT_TOKEN D_TOKEN=YOUR_DISCORD_BOT_TOKEN
M_URI=YOUR_MONGODB_CONNECTION_STRING M_URI=YOUR_MONGODB_CONNECTION_STRING
M_DB=YOUR_DATABASE_NAME M_DB=YOUR_DATABASE_NAME
# Optional
BOT_AUTHOR_ID=YOUR_DISCORD_USER_ID
PROFILE_TIMEZONE=Europe/Berlin
``` ```
Start the bot: Never commit `.env`.
Start normally:
``` bash ``` bash
node index.js node index.js
``` ```
For PM2, a typical command is: Or with PM2:
``` bash ``` bash
pm2 start index.js --name ShaiBot pm2 start index.js --name ShaiBot
``` ```
A typical update cycle is:
``` bash
node --check path/to/changed-file.js
pm2 restart ShaiBot
pm2 logs ShaiBot --lines 100
```
## MongoDB ## MongoDB
ShaiBot's Cookie economy intentionally does **not** depend on MongoDB ShaiBot does not require MongoDB multi-document transactions for its
transactions, allowing it to work with deployments that do not support core economy.
replica-set transactions.
If your deployment does not support retryable writes, include: The bot instead relies on atomic updates, unique indexes, optimistic
concurrency, idempotent operations, and recovery logic where
appropriate.
If your Mongo deployment does not support retryable writes, your
connection string may require:
``` text ``` text
retryWrites=false retryWrites=false
``` ```
in the connection string, for example:
``` env
M_URI=mongodb://USER:PASSWORD@HOST:PORT/DATABASE?authSource=admin&retryWrites=false
```
Never commit database credentials. Never commit database credentials.
Important economy collections include: ------------------------------------------------------------------------
``` text # Documentation
items_cookies
cooldown_cookies
items_games
```
`items_cookies` stores balances, `cooldown_cookies` stores action The central README intentionally stays high-level. Detailed
cooldown timestamps, and `items_games` can store persistent game state. implementation rules live in focused manuals:
### Recommended Indexes ------------------------------------------------------------------------------------
Document Purpose
------------------------------------------------ -----------------------------------
[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) Runtime structure, handlers,
utilities, Mongo persistence,
concurrency, and recovery
Run the shared index initializer after MongoDB connects: [`docs/ECONOMY.md`](docs/ECONOMY.md) Cookie balances, transfers,
cooldowns, house vault, settlement,
and idempotency
``` js [`docs/PROGRESSION.md`](docs/PROGRESSION.md) Profiles, achievements, badges,
const { activity, social progression, and
ensureCookieIndexes maintenance
} = require("./utils/cookieEconomy");
await mClient.connect(); [`docs/GAMES.md`](docs/GAMES.md) Crumb Saucer games, Poker, Crumble
await ensureCookieIndexes(); Strike, and important game rules
```
The intended unique keys are: [`COOKIE_LOGIC.md`](COOKIE_LOGIC.md) Detailed PvP Cookie stealing rules
and balancing
``` text [`AGENTS.md`](AGENTS.md) Repository-wide safety and
items_cookies → userId architecture guide for AI coding
cooldown_cookies → userId + guildId agents
``` ------------------------------------------------------------------------------------
## Discord Intents The current repository code remains the final source of truth.
The current client uses intents including: ------------------------------------------------------------------------
``` js # Development Principles
GatewayIntentBits.Guilds
GatewayIntentBits.GuildMembers
GatewayIntentBits.GuildMessages
GatewayIntentBits.GuildPresences
GatewayIntentBits.GuildMessageReactions
GatewayIntentBits.GuildVoiceStates
GatewayIntentBits.DirectMessages
GatewayIntentBits.MessageContent
```
and partials including: ShaiBot generally favors:
``` js - Shared utilities instead of duplicated game/economy logic
Partials.Channel - Thin Discord command/button wrappers
Partials.Message - Persistent authoritative state where recovery matters
Partials.User - Atomic and idempotent economy operations
Partials.GuildMember - Centralized profile progression
Partials.Reaction - Public game experiences with private sensitive information
``` - Small explicit solutions over unnecessary abstraction
Enable any required privileged intents in the Discord Developer Portal. For stateful changes, always consider duplicate interactions,
simultaneous users, insufficient-balance races, and bot restarts during
## Interaction Design settlement.
ShaiBot generally follows these conventions:
- Public game output uses **embeds**.
- Short private errors/feedback can use **ephemeral messages**.
- Games use **buttons** for interaction.
- Existing messages are edited where practical instead of repeatedly
sending new messages.
- Game/session ownership is preserved so multiple users can play
independently.
- Animation-like effects use rate-limit-conscious message edits.
## Economy Safety
Economy operations favor atomic MongoDB updates for balance checks and
debits.
Transfers avoid MongoDB transactions for deployment compatibility. If a
destination credit fails after a successful debit, the shared economy
utility performs a best-effort compensating credit to the source
account. Administrators should monitor logs for critical compensation
failures.
When adding games, check that the house vault can cover the maximum
possible payout before accepting a wager.
## Adding a Crumb Saucer Game
Preferred pattern:
1. Put shared game logic in `utils/`.
2. Put individual Discord button handlers in the appropriate `buttons/`
directory.
3. Reuse `cookieEconomy.js` rather than implementing separate balance
logic.
4. Preserve the player/session owner through every screen.
5. Check house liquidity before accepting a wager.
6. Prefer editing the current embed for animation/state transitions.
7. Protect wager resolution against rapid repeated interactions.
## Roadmap / TODO
The major planned Cookie systems are implemented or designed. Remaining
work is primarily testing, balancing, and hardening.
- [ ] Verify Slots replay behavior after deployment
- [ ] Deploy and test limited Roulette
- [ ] Verify Crumble Strike AoE Revive behavior
- [ ] Verify Crumble Strike expiration/failure cleanup survives bot
restarts
- [ ] Balance boss HP, damage, healing, Energy, Limit generation,
entry costs, and rewards
- [ ] Stress-test simultaneous gambling interactions
- [ ] Stress-test repeated/double button presses
- [ ] Test insufficient-balance races
- [ ] Test behavior with a nearly empty house vault
- [ ] Consider dynamic Vault Heist payout caps
- [ ] Consider an economy audit/log collection
- [ ] Add more Crumble Strike bosses, lore, and encounter variety
- [ ] Add more Crumb Saucer attractions
## Security ## Security
A suitable `.gitignore` should include at least: Keep at least the following out of version control:
``` gitignore ``` gitignore
node_modules/ node_modules/
@@ -340,39 +282,8 @@ node_modules/
*.log *.log
``` ```
If a Discord token or MongoDB credential is accidentally committed, Rotate Discord or MongoDB credentials immediately if they are exposed.
rotate it immediately.
## Disclaimer
ShaiBot Cookies are fictional in-bot currency with no real-world
monetary value. Crumb Saucer gambling-style games are entertainment
mechanics using that fictional currency.
Final Fantasy XIV names and concepts referenced by community game
features belong to their respective rights holders. ShaiBot is not
affiliated with or endorsed by Square Enix.
## Contributing
Bug reports, balance suggestions, and contributions are welcome.
When contributing:
- Keep commands and button handlers modular.
- Reuse shared economy utilities.
- Avoid blocking the event loop.
- Be mindful of Discord API rate limits.
- Validate interaction ownership.
- Keep database operations safe under concurrent interactions.
## License ## License
Add the license used by the repository here. If the project is open See the repository's current license information, if present.
source, include a `LICENSE` file and replace this section with the
chosen license.
------------------------------------------------------------------------
Made for Discord communities that believe every problem can be improved
with a few more Cookies. 🍪
+11
View File
@@ -0,0 +1,11 @@
const {
closeInteractionSession
} = require("../../utils/interactionSession");
module.exports = {
name: "sessionClose",
async execute(interaction) {
return closeInteractionSession(interaction);
}
};
+91
View File
@@ -0,0 +1,91 @@
const {
EmbedBuilder,
ActionRowBuilder,
ButtonBuilder,
ButtonStyle,
MessageFlags
} = require("discord.js");
const {
declareBankruptcy
} = require("../../utils/bankruptcy");
const {
requireInteractionSession
} = require("../../utils/interactionSession");
module.exports = {
name: "crumbSaucerBankruptcy",
async execute(interaction) {
const session = await requireInteractionSession(interaction);
if (!session) {
return;
}
const result = await declareBankruptcy(interaction);
if (!result.ok) {
if (result.reason === "COOLDOWN") {
return interaction.reply({
content:
`Bankruptcy assistance is unavailable until <t:${result.nextAvailableAt}:R>.`,
flags: MessageFlags.Ephemeral
});
}
if (result.reason === "BALANCE_NOT_ZERO") {
return interaction.reply({
content:
`Bankruptcy can only be declared at exactly **0 Cookies**. Your current balance is **${result.balance.toLocaleString()}**.`,
flags: MessageFlags.Ephemeral
});
}
return interaction.reply({
content: "Bankruptcy could not be declared.",
flags: MessageFlags.Ephemeral
});
}
const embed = new EmbedBuilder()
.setTitle("🍪 Bankruptcy Assistance")
.setDescription(
[
`${interaction.user} declared bankruptcy and received a starter kit.`,
"",
`**+${result.amount.toLocaleString()} Cookies**`,
"",
"Spend them wisely. Or at least memorably."
].join("\n")
)
.addFields({
name: "Next Eligibility",
value: `<t:${result.nextAvailableAt}:R>`
})
.setFooter({
text: `crumbSaucer:${interaction.user.id}`
});
const row = new ActionRowBuilder()
.addComponents(
new ButtonBuilder()
.setCustomId("crumbSaucerBack")
.setLabel("Back to Crumb Saucer")
.setEmoji("◀️")
.setStyle(ButtonStyle.Secondary),
new ButtonBuilder()
.setCustomId("sessionClose")
.setLabel("Close")
.setEmoji("✖️")
.setStyle(ButtonStyle.Secondary)
);
return interaction.update({
embeds: [embed],
components: [row]
});
}
};
+91
View File
@@ -0,0 +1,91 @@
const {
EmbedBuilder
} = require(
"discord.js"
);
const {
temporaryEphemeralReply
} = require(
"../../utils/cookieEconomy"
);
const {
getCrumbSaucerSessionOwner
} = require(
"../../utils/crumbSaucer"
);
function delay(
ms
) {
return new Promise(
resolve =>
setTimeout(
resolve,
ms
)
);
}
module.exports = {
name:
"crumbSaucerClose",
async execute(
interaction
) {
const ownerId =
getCrumbSaucerSessionOwner(
interaction
);
if (
ownerId !==
interaction.user.id
) {
return temporaryEphemeralReply(
interaction,
{
content:
"This isn't your Crumb Saucer session."
}
);
}
const embed =
new EmbedBuilder()
.setTitle(
"Closed"
)
.setDescription(
"Closing Crumb Saucer..."
);
await interaction.update({
embeds: [
embed
],
components: []
});
await delay(
1500
);
try {
await interaction.deleteReply();
}
catch {}
}
};
+20
View File
@@ -0,0 +1,20 @@
const {
collectCookiesFromSaucer
} = require(
"../../utils/crumbCollect"
);
module.exports = {
name:
"crumbSaucerCollect",
async execute(
interaction
) {
return collectCookiesFromSaucer(
interaction
);
}
};
+20
View File
@@ -0,0 +1,20 @@
const {
showCollectPage
} = require(
"../../utils/crumbCollect"
);
module.exports = {
name:
"crumbSaucerCollectMenu",
async execute(
interaction
) {
return showCollectPage(
interaction
);
}
};
+27
View File
@@ -0,0 +1,27 @@
const {
buildPokerHubEmbed,
buildPokerHubComponents,
deferPokerReply
} = require("../../utils/pokerDiscord");
module.exports = {
name:
"crumbSaucerPoker",
async execute(
interaction
) {
await deferPokerReply(
interaction
);
return interaction.editReply({
embeds: [
buildPokerHubEmbed()
],
components:
buildPokerHubComponents()
});
}
};
+58
View File
@@ -0,0 +1,58 @@
const {
buildProfileOverview,
buildProfileButtons
} = require("../../utils/profileSystem");
const {
temporaryEphemeralReply
} = require("../../utils/cookieEconomy");
module.exports = {
name: "crumbSaucerProfile",
async execute(interaction) {
const footer =
interaction.message
?.embeds?.[0]
?.footer
?.text;
const [
type,
ownerId
] = footer?.split(":") ?? [];
if (
type !== "crumbSaucer" ||
ownerId !== interaction.user.id
) {
return temporaryEphemeralReply(
interaction,
{
content:
"This isn't your Crumb Saucer session."
}
);
}
const embed =
await buildProfileOverview(
interaction.guildId,
interaction.user,
interaction.user.id
);
return interaction.update({
embeds: [embed],
components:
buildProfileButtons(
"overview",
0,
1
)
});
}
};
+19
View File
@@ -0,0 +1,19 @@
const {
runSlotsBack
} = require(
"../../utils/crumbSlots.js"
);
module.exports = {
name:
"crumbSaucerSlotsRunItBack",
async execute(
interaction
) {
return runSlotsBack(
interaction
);
}
};
+17
View File
@@ -0,0 +1,17 @@
const {
executePokerAction
} = require("../../utils/pokerInteractions");
module.exports = {
name: "pokerAllIn",
async execute(interaction) {
return executePokerAction(
interaction,
{
type: "all_in"
},
"✅ All-In."
);
}
};
+47
View File
@@ -0,0 +1,47 @@
const {
getPokerService
} = require("../../utils/crumbPoker");
const {
deferPokerReply,
editPokerError,
buildJoinPicker
} = require("../../utils/pokerDiscord");
module.exports = {
name:
"pokerBrowse",
async execute(
interaction
) {
await deferPokerReply(
interaction
);
try {
const tables =
await getPokerService()
.listJoinableTables({
guildId:
interaction.guildId,
channelId:
interaction.channelId
});
return interaction.editReply(
buildJoinPicker(
tables
)
);
}
catch (error) {
return editPokerError(
interaction,
error
);
}
}
};
+17
View File
@@ -0,0 +1,17 @@
const {
executePokerAction
} = require("../../utils/pokerInteractions");
module.exports = {
name: "pokerCall",
async execute(interaction) {
return executePokerAction(
interaction,
{
type: "call"
},
"✅ Called."
);
}
};
+85
View File
@@ -0,0 +1,85 @@
const {
EmbedBuilder,
ActionRowBuilder,
ButtonBuilder,
ButtonStyle
} = require("discord.js");
const {
getPokerService
} = require("../../utils/crumbPoker");
const {
POKER_TABLE_FOOTER_PREFIX,
deferPokerReply,
editPokerError,
getPokerGameIdFromInteraction
} = require("../../utils/pokerDiscord");
module.exports = {
name: "pokerCancel",
async execute(interaction) {
await deferPokerReply(interaction);
const gameId =
getPokerGameIdFromInteraction(interaction);
if (!gameId) {
return interaction.editReply({
content: "❌ This Poker table is no longer active.",
embeds: [],
components: []
});
}
try {
const game =
await getPokerService().getTable({ gameId });
if (
String(game.data.ownerId) !==
String(interaction.user.id)
) {
return interaction.editReply({
content: "❌ Only the table owner can cancel this Poker table.",
embeds: [],
components: []
});
}
return interaction.editReply({
content: "",
embeds: [
new EmbedBuilder()
.setTitle("🛑 Cancel Poker Table?")
.setDescription(
[
"All seated players will be safely cashed out.",
"",
"This cannot be undone."
].join("\n")
)
.setFooter({
text: `${POKER_TABLE_FOOTER_PREFIX}:${gameId}`
})
],
components: [
new ActionRowBuilder().addComponents(
new ButtonBuilder()
.setCustomId("pokerCancelConfirm")
.setLabel("Cancel Table")
.setEmoji("🛑")
.setStyle(ButtonStyle.Danger),
new ButtonBuilder()
.setCustomId("pokerCancelKeep")
.setLabel("Keep Table")
.setStyle(ButtonStyle.Secondary)
)
]
});
} catch (error) {
return editPokerError(interaction, error);
}
}
};
+50
View File
@@ -0,0 +1,50 @@
const {
getPokerService
} = require("../../utils/crumbPoker");
const {
deferPokerReply,
editPokerError,
getPokerGameIdFromInteraction,
refreshPokerTableMessage
} = require("../../utils/pokerDiscord");
module.exports = {
name: "pokerCancelConfirm",
async execute(interaction) {
await deferPokerReply(interaction);
const gameId =
getPokerGameIdFromInteraction(interaction);
if (!gameId) {
return interaction.editReply({
content: "❌ This Poker table is no longer active.",
embeds: [],
components: []
});
}
try {
const game =
await getPokerService().cancelTable({
gameId,
userId: interaction.user.id
});
await refreshPokerTableMessage(
interaction.client,
game
);
try {
await interaction.deleteReply();
} catch {}
return;
} catch (error) {
return editPokerError(interaction, error);
}
}
};
+17
View File
@@ -0,0 +1,17 @@
const {
deferPokerReply
} = require("../../utils/pokerDiscord");
module.exports = {
name: "pokerCancelKeep",
async execute(interaction) {
await deferPokerReply(interaction);
try {
await interaction.deleteReply();
} catch {}
return;
}
};
+151
View File
@@ -0,0 +1,151 @@
const {
cardToString
} = require("../../utils/pokerEngine");
const {
getPokerService
} = require("../../utils/crumbPoker");
const {
POKER_CARD_RESPONSE_LIFETIME,
deferPokerReply,
editPokerError,
getPokerGameIdFromInteraction
} = require("../../utils/pokerDiscord");
module.exports = {
name:
"pokerCards",
async execute(
interaction
) {
await deferPokerReply(
interaction
);
const gameId =
getPokerGameIdFromInteraction(
interaction
);
if (!gameId) {
return interaction.editReply({
content:
"❌ This Poker table is no longer active.",
embeds: [],
components: []
});
}
try {
const service =
getPokerService();
const [
cards,
game
] =
await Promise.all([
service.viewCards({
gameId,
userId:
interaction.user.id
}),
service.getTable({
gameId
})
]);
const hand =
game.data.hand;
const player =
game.data.players.find(
candidate =>
String(candidate.userId) ===
String(interaction.user.id)
);
const currentPlayer =
hand?.players?.find(
candidate =>
candidate.seat ===
hand.currentSeat
);
const isYourTurn =
Boolean(
player
&&
hand
&&
hand.currentSeat ===
player.seat
);
const communityCards =
hand?.communityCards ?? [];
const lines = [
"### 🃏 Your Cards",
`**${cards.map(cardToString).join(" ")}**`,
"",
"**Community:** " +
(communityCards.length
? communityCards
.map(cardToString)
.join(" ")
: "No community cards yet."),
""
];
if (isYourTurn) {
lines.push(
"👉 **It's YOUR TURN.**",
game.data.turnExpiresAt
? `⏱️ Auto-folds <t:${game.data.turnExpiresAt}:R>.`
: "⏱️ Your turn timer is active."
);
}
else if (currentPlayer) {
lines.push(
`Waiting for <@${currentPlayer.userId}> to act.`
);
}
else {
lines.push(
"The hand is not currently waiting on a player action."
);
}
await interaction.editReply({
content:
lines.join("\n"),
embeds: [],
components: []
});
setTimeout(
() => {
interaction
.deleteReply()
.catch(() => {});
},
POKER_CARD_RESPONSE_LIFETIME
);
return;
}
catch (error) {
return editPokerError(
interaction,
error
);
}
}
};
+67
View File
@@ -0,0 +1,67 @@
const {
getPokerService
} = require("../../utils/crumbPoker");
const {
deferPokerReply,
editPokerError,
getPokerGameIdFromInteraction,
refreshPokerTableMessage
} = require("../../utils/pokerDiscord");
module.exports = {
name:
"pokerCashOut",
async execute(
interaction
) {
await deferPokerReply(
interaction
);
const gameId =
getPokerGameIdFromInteraction(
interaction
);
if (!gameId) {
return interaction.editReply({
content:
"❌ This Poker table is no longer active.",
embeds: [],
components: []
});
}
try {
const result =
await getPokerService()
.cashOut({
gameId,
userId:
interaction.user.id
});
await refreshPokerTableMessage(
interaction.client,
result.game
);
return interaction.editReply({
content:
`✅ Cashed out **${result.amount.toLocaleString()} Cookies** back to your vault.`,
embeds: [],
components: []
});
}
catch (error) {
return editPokerError(
interaction,
error
);
}
}
};
+17
View File
@@ -0,0 +1,17 @@
const {
executePokerAction
} = require("../../utils/pokerInteractions");
module.exports = {
name: "pokerCheck",
async execute(interaction) {
return executePokerAction(
interaction,
{
type: "check"
},
"✅ Checked."
);
}
};
+81
View File
@@ -0,0 +1,81 @@
const {
getPokerService
} = require("../../utils/crumbPoker");
const {
deferPokerReply,
editPokerError,
publishPokerTable,
buildOpenTableLink
} = require("../../utils/pokerDiscord");
module.exports = {
name:
"pokerCreate",
async execute(
interaction
) {
await deferPokerReply(
interaction
);
const service =
getPokerService();
let game = null;
try {
game =
await service.createTable({
guildId:
interaction.guildId,
channelId:
interaction.channelId,
ownerId:
interaction.user.id
});
const published =
await publishPokerTable(
interaction,
game
);
return interaction.editReply({
content:
`✅ Poker table created. **${published.game.data.buyIn.toLocaleString()} Cookies** were moved into your Poker stack.`,
embeds: [],
components:
buildOpenTableLink(
published.game
)
});
}
catch (error) {
if (game) {
try {
await service.cashOut({
gameId:
game.gameId,
userId:
interaction.user.id
});
}
catch (refundError) {
console.error(
`[Poker] Failed to refund unpublished table ${game.gameId}:`,
refundError
);
}
}
return editPokerError(
interaction,
error
);
}
}
};
+17
View File
@@ -0,0 +1,17 @@
const {
executePokerAction
} = require("../../utils/pokerInteractions");
module.exports = {
name: "pokerFold",
async execute(interaction) {
return executePokerAction(
interaction,
{
type: "fold"
},
"✅ Folded."
);
}
};
+73
View File
@@ -0,0 +1,73 @@
const {
getPokerService
} = require("../../utils/crumbPoker");
const {
deferPokerReply,
editPokerError,
getPokerGameIdFromInteraction,
refreshPokerTableMessage,
buildOpenTableLink
} = require("../../utils/pokerDiscord");
module.exports = {
name:
"pokerJoinTable",
async execute(
interaction
) {
await deferPokerReply(
interaction
);
const gameId =
getPokerGameIdFromInteraction(
interaction
);
if (!gameId) {
return interaction.editReply({
content:
"❌ This Poker table is no longer active.",
embeds: [],
components: []
});
}
try {
const game =
await getPokerService()
.joinTable({
gameId,
guildId:
interaction.guildId,
userId:
interaction.user.id
});
await refreshPokerTableMessage(
interaction.client,
game
);
return interaction.editReply({
content:
`✅ Joined the Poker table for **${game.data.buyIn.toLocaleString()} Cookies**.`,
embeds: [],
components:
buildOpenTableLink(
game
)
});
}
catch (error) {
return editPokerError(
interaction,
error
);
}
}
};
+66
View File
@@ -0,0 +1,66 @@
const {
getPokerService
} = require("../../utils/crumbPoker");
const {
deferPokerReply,
editPokerError,
refreshPokerTableMessage,
buildOpenTableLink
} = require("../../utils/pokerDiscord");
module.exports = {
name:
"pokerMyTable",
async execute(
interaction
) {
await deferPokerReply(
interaction
);
try {
const game =
await getPokerService()
.getMyTable({
guildId:
interaction.guildId,
userId:
interaction.user.id
});
if (!game) {
return interaction.editReply({
content:
"❌ You are not currently seated at a Poker table.",
embeds: [],
components: []
});
}
await refreshPokerTableMessage(
interaction.client,
game
);
return interaction.editReply({
content:
"♠️ Your active Poker table:",
embeds: [],
components:
buildOpenTableLink(
game
)
});
}
catch (error) {
return editPokerError(
interaction,
error
);
}
}
};
+13
View File
@@ -0,0 +1,13 @@
const {
executeMinimumRaise
} = require("../../utils/pokerInteractions");
module.exports = {
name: "pokerRaise",
async execute(interaction) {
return executeMinimumRaise(
interaction
);
}
};
+65
View File
@@ -0,0 +1,65 @@
const {
getPokerService
} = require("../../utils/crumbPoker");
const {
deferPokerReply,
editPokerError,
getPokerGameIdFromInteraction,
refreshPokerTableMessage
} = require("../../utils/pokerDiscord");
module.exports = {
name:
"pokerRefresh",
async execute(
interaction
) {
await deferPokerReply(
interaction
);
const gameId =
getPokerGameIdFromInteraction(
interaction
);
if (!gameId) {
return interaction.editReply({
content:
"❌ This Poker table is no longer active.",
embeds: [],
components: []
});
}
try {
const game =
await getPokerService()
.getTable({
gameId
});
await refreshPokerTableMessage(
interaction.client,
game
);
return interaction.editReply({
content:
"✅ Poker table refreshed.",
embeds: [],
components: []
});
}
catch (error) {
return editPokerError(
interaction,
error
);
}
}
};
+67
View File
@@ -0,0 +1,67 @@
const {
getPokerService
} = require("../../utils/crumbPoker");
const {
deferPokerReply,
editPokerError,
getPokerGameIdFromInteraction,
refreshPokerTableMessage
} = require("../../utils/pokerDiscord");
module.exports = {
name:
"pokerStart",
async execute(
interaction
) {
await deferPokerReply(
interaction
);
const gameId =
getPokerGameIdFromInteraction(
interaction
);
if (!gameId) {
return interaction.editReply({
content:
"❌ This Poker table is no longer active.",
embeds: [],
components: []
});
}
try {
const game =
await getPokerService()
.startTableHand({
gameId,
userId:
interaction.user.id
});
await refreshPokerTableMessage(
interaction.client,
game
);
return interaction.editReply({
content:
`✅ Hand **#${game.data.handNumber}** started. Cards have been dealt — use **🃏 Cards** to view your hand.`,
embeds: [],
components: []
});
}
catch (error) {
return editPokerError(
interaction,
error
);
}
}
};
+41 -9
View File
@@ -1,22 +1,54 @@
const { const {
buildAchievementsEmbed, buildAchievementsEmbed,
buildProfileButtons, buildProfileButtons,
getProfileTargetId getProfileContextFromFooter,
} = require("../../utils/profileSystem"); requireProfileOwner
} = require(
"../../utils/profileSystem"
);
module.exports = { module.exports = {
name: "profileAchievements", name:
"profileAchievements",
async execute(interaction) { async execute(
const userId = getProfileTargetId(interaction); interaction
if (!userId) return; ) {
const context =
await requireProfileOwner(
interaction
);
const user = await interaction.client.users.fetch(userId); if (!context) {
const embed = await buildAchievementsEmbed(interaction.guildId, user); return;
}
const user =
await interaction.client.users.fetch(
context.profileUserId
);
const embed =
await buildAchievementsEmbed(
interaction.guildId,
user,
context.sessionOwnerId,
0
);
const pageContext =
getProfileContextFromFooter(
embed.data.footer?.text
);
return interaction.update({ return interaction.update({
embeds: [embed], embeds: [embed],
components: buildProfileButtons() components:
buildProfileButtons(
"achievements",
pageContext?.page ?? 0,
pageContext?.pageCount ?? 1
)
}); });
} }
}; };
+54
View File
@@ -0,0 +1,54 @@
const {
buildAchievementsEmbed,
buildProfileButtons,
getProfileContextFromFooter,
requireProfileOwner
} = require(
"../../utils/profileSystem"
);
module.exports = {
name:
"profileAchievementsLeft",
async execute(
interaction
) {
const context =
await requireProfileOwner(
interaction
);
if (!context) {
return;
}
const user =
await interaction.client.users.fetch(
context.profileUserId
);
const embed =
await buildAchievementsEmbed(
interaction.guildId,
user,
context.sessionOwnerId,
context.page - 1
);
const pageContext =
getProfileContextFromFooter(
embed.data.footer?.text
);
return interaction.update({
embeds: [embed],
components:
buildProfileButtons(
"achievements",
pageContext?.page ?? 0,
pageContext?.pageCount ?? 1
)
});
}
};
+54
View File
@@ -0,0 +1,54 @@
const {
buildAchievementsEmbed,
buildProfileButtons,
getProfileContextFromFooter,
requireProfileOwner
} = require(
"../../utils/profileSystem"
);
module.exports = {
name:
"profileAchievementsRight",
async execute(
interaction
) {
const context =
await requireProfileOwner(
interaction
);
if (!context) {
return;
}
const user =
await interaction.client.users.fetch(
context.profileUserId
);
const embed =
await buildAchievementsEmbed(
interaction.guildId,
user,
context.sessionOwnerId,
context.page + 1
);
const pageContext =
getProfileContextFromFooter(
embed.data.footer?.text
);
return interaction.update({
embeds: [embed],
components:
buildProfileButtons(
"achievements",
pageContext?.page ?? 0,
pageContext?.pageCount ?? 1
)
});
}
};
+41 -9
View File
@@ -1,22 +1,54 @@
const { const {
buildBadgesEmbed, buildBadgesEmbed,
buildProfileButtons, buildProfileButtons,
getProfileTargetId getProfileContextFromFooter,
} = require("../../utils/profileSystem"); requireProfileOwner
} = require(
"../../utils/profileSystem"
);
module.exports = { module.exports = {
name: "profileBadges", name:
"profileBadges",
async execute(interaction) { async execute(
const userId = getProfileTargetId(interaction); interaction
if (!userId) return; ) {
const context =
await requireProfileOwner(
interaction
);
const user = await interaction.client.users.fetch(userId); if (!context) {
const embed = await buildBadgesEmbed(interaction.guildId, user); return;
}
const user =
await interaction.client.users.fetch(
context.profileUserId
);
const embed =
await buildBadgesEmbed(
interaction.guildId,
user,
context.sessionOwnerId,
0
);
const pageContext =
getProfileContextFromFooter(
embed.data.footer?.text
);
return interaction.update({ return interaction.update({
embeds: [embed], embeds: [embed],
components: buildProfileButtons() components:
buildProfileButtons(
"badges",
pageContext?.page ?? 0,
pageContext?.pageCount ?? 1
)
}); });
} }
}; };
+54
View File
@@ -0,0 +1,54 @@
const {
buildBadgesEmbed,
buildProfileButtons,
getProfileContextFromFooter,
requireProfileOwner
} = require(
"../../utils/profileSystem"
);
module.exports = {
name:
"profileBadgesLeft",
async execute(
interaction
) {
const context =
await requireProfileOwner(
interaction
);
if (!context) {
return;
}
const user =
await interaction.client.users.fetch(
context.profileUserId
);
const embed =
await buildBadgesEmbed(
interaction.guildId,
user,
context.sessionOwnerId,
context.page - 1
);
const pageContext =
getProfileContextFromFooter(
embed.data.footer?.text
);
return interaction.update({
embeds: [embed],
components:
buildProfileButtons(
"badges",
pageContext?.page ?? 0,
pageContext?.pageCount ?? 1
)
});
}
};
+54
View File
@@ -0,0 +1,54 @@
const {
buildBadgesEmbed,
buildProfileButtons,
getProfileContextFromFooter,
requireProfileOwner
} = require(
"../../utils/profileSystem"
);
module.exports = {
name:
"profileBadgesRight",
async execute(
interaction
) {
const context =
await requireProfileOwner(
interaction
);
if (!context) {
return;
}
const user =
await interaction.client.users.fetch(
context.profileUserId
);
const embed =
await buildBadgesEmbed(
interaction.guildId,
user,
context.sessionOwnerId,
context.page + 1
);
const pageContext =
getProfileContextFromFooter(
embed.data.footer?.text
);
return interaction.update({
embeds: [embed],
components:
buildProfileButtons(
"badges",
pageContext?.page ?? 0,
pageContext?.pageCount ?? 1
)
});
}
};
+60
View File
@@ -0,0 +1,60 @@
const {
EmbedBuilder
} = require(
"discord.js"
);
const {
requireProfileOwner
} = require(
"../../utils/profileSystem"
);
function delay(
ms
) {
return new Promise(
resolve =>
setTimeout(
resolve,
ms
)
);
}
module.exports = {
name:
"profileClose",
async execute(
interaction
) {
const context =
await requireProfileOwner(
interaction
);
if (!context) {
return;
}
await interaction.update({
embeds: [
new EmbedBuilder()
.setTitle(
"Profile Closed"
)
.setDescription(
"Closing profile..."
)
],
components: []
});
await delay(1200);
try {
await interaction.deleteReply();
} catch {}
}
};
+34 -9
View File
@@ -1,22 +1,47 @@
const { const {
buildProfileOverview, buildProfileOverview,
buildProfileButtons, buildProfileButtons,
getProfileTargetId requireProfileOwner
} = require("../../utils/profileSystem"); } = require(
"../../utils/profileSystem"
);
module.exports = { module.exports = {
name: "profileOverview", name:
"profileOverview",
async execute(interaction) { async execute(
const userId = getProfileTargetId(interaction); interaction
if (!userId) return; ) {
const context =
await requireProfileOwner(
interaction
);
const user = await interaction.client.users.fetch(userId); if (!context) {
const embed = await buildProfileOverview(interaction.guildId, user); return;
}
const user =
await interaction.client.users.fetch(
context.profileUserId
);
const embed =
await buildProfileOverview(
interaction.guildId,
user,
context.sessionOwnerId
);
return interaction.update({ return interaction.update({
embeds: [embed], embeds: [embed],
components: buildProfileButtons() components:
buildProfileButtons(
"overview",
0,
1
)
}); });
} }
}; };
+47
View File
@@ -0,0 +1,47 @@
const {
buildStatsEmbed,
buildProfileButtons,
requireProfileOwner
} = require(
"../../utils/profileSystem"
);
module.exports = {
name:
"profileStats",
async execute(
interaction
) {
const context =
await requireProfileOwner(
interaction
);
if (!context) {
return;
}
const user =
await interaction.client.users.fetch(
context.profileUserId
);
const embed =
await buildStatsEmbed(
interaction.guildId,
user,
context.sessionOwnerId
);
return interaction.update({
embeds: [embed],
components:
buildProfileButtons(
"stats",
0,
1
)
});
}
};
+64
View File
@@ -0,0 +1,64 @@
const {
SlashCommandBuilder,
EmbedBuilder,
MessageFlags
} = require("discord.js");
const {
declareBankruptcy
} = require("../../utils/bankruptcy");
module.exports = {
data: new SlashCommandBuilder()
.setName("bankruptcy")
.setDescription(
"Declare bankruptcy at exactly 0 Cookies and receive a starter kit."
),
async execute(interaction) {
const result = await declareBankruptcy(interaction);
if (!result.ok) {
if (result.reason === "COOLDOWN") {
return interaction.reply({
content:
`Bankruptcy assistance is unavailable until <t:${result.nextAvailableAt}:R>.`,
flags: MessageFlags.Ephemeral
});
}
if (result.reason === "BALANCE_NOT_ZERO") {
return interaction.reply({
content:
`You may only declare bankruptcy at exactly **0 Cookies**. Your current balance is **${result.balance.toLocaleString()}**.`,
flags: MessageFlags.Ephemeral
});
}
return interaction.reply({
content: "Bankruptcy could not be declared.",
flags: MessageFlags.Ephemeral
});
}
const embed = new EmbedBuilder()
.setTitle("🍪 Bankruptcy Declared")
.setDescription(
[
`${interaction.user} has been issued a fresh starter kit.`,
"",
`**+${result.amount.toLocaleString()} Cookies**`,
"",
"The Cookie economy believes in second chances."
].join("\n")
)
.addFields({
name: "Next Eligibility",
value: `<t:${result.nextAvailableAt}:R>`
});
return interaction.reply({
embeds: [embed]
});
}
};
File diff suppressed because it is too large Load Diff
+49 -20
View File
@@ -1,34 +1,63 @@
const { SlashCommandBuilder } = require("discord.js"); const {
SlashCommandBuilder
} = require(
"discord.js"
);
const { const {
buildProfileOverview, buildProfileOverview,
buildProfileButtons, buildProfileButtons
ensureGuildBadges } = require(
} = require("../../utils/profileSystem"); "../../utils/profileSystem"
);
module.exports = { module.exports = {
data: new SlashCommandBuilder() data:
.setName("profile") new SlashCommandBuilder()
.setDescription("View a player profile") .setName(
.addUserOption(option => "profile"
option )
.setName("user") .setDescription(
.setDescription("Player to view") "View a player profile"
), )
.addUserOption(
option =>
option
.setName(
"user"
)
.setDescription(
"Player to view"
)
),
async execute(interaction) { async execute(
const user = interaction.options.getUser("user") ?? interaction.user; interaction
) {
const user =
interaction.options.getUser(
"user"
) ??
interaction.user;
await ensureGuildBadges(interaction.guildId); const sessionOwnerId =
interaction.user.id;
const embed = await buildProfileOverview( const embed =
interaction.guildId, await buildProfileOverview(
user interaction.guildId,
); user,
sessionOwnerId
);
return interaction.reply({ return interaction.reply({
embeds: [embed], embeds: [embed],
components: buildProfileButtons() components:
buildProfileButtons(
"overview",
0,
1
)
}); });
} }
}; };
+629
View File
@@ -0,0 +1,629 @@
const {
SlashCommandBuilder,
MessageFlags,
EmbedBuilder
} = require("discord.js");
const {
reconcileUserAchievements,
reconcileAllAchievements,
grantMaintenanceReward,
getMaintenanceRewardStatus,
auditProfileData,
DEFAULT_MAINTENANCE_REWARD_ID,
DEFAULT_MAINTENANCE_REWARD_AMOUNT
} = require(
"../../utils/profileMaintenance"
);
const {
requireAdminAccess
} = require(
"../../utils/permissions"
);
module.exports = {
data:
new SlashCommandBuilder()
.setName(
"profileadmin"
)
.setDescription(
"Administrative profile maintenance tools"
)
.addSubcommand(
subcommand =>
subcommand
.setName(
"reconcile"
)
.setDescription(
"Backfill database-trackable achievements"
)
.addUserOption(
option =>
option
.setName(
"user"
)
.setDescription(
"Only reconcile this user; omit for everyone"
)
)
.addBooleanOption(
option =>
option
.setName(
"dry-run"
)
.setDescription(
"Show what would change without writing it"
)
)
)
.addSubcommand(
subcommand =>
subcommand
.setName(
"audit"
)
.setDescription(
"Audit stored profile statistics for inconsistencies"
)
)
.addSubcommand(
subcommand =>
subcommand
.setName(
"maintenance-reward"
)
.setDescription(
"Grant an idempotent maintenance reward to stored profile users"
)
.addStringOption(
option =>
option
.setName(
"reward-id"
)
.setDescription(
`Unique reward ID; defaults to ${DEFAULT_MAINTENANCE_REWARD_ID}`
)
)
.addIntegerOption(
option =>
option
.setName(
"amount"
)
.setDescription(
`Cookies per player; defaults to ${DEFAULT_MAINTENANCE_REWARD_AMOUNT}`
)
.setMinValue(
1
)
)
.addBooleanOption(
option =>
option
.setName(
"dry-run"
)
.setDescription(
"Preview recipients without granting Cookies"
)
)
)
.addSubcommand(
subcommand =>
subcommand
.setName(
"maintenance-status"
)
.setDescription(
"Show maintenance reward claim status"
)
.addStringOption(
option =>
option
.setName(
"reward-id"
)
.setDescription(
`Reward ID; defaults to ${DEFAULT_MAINTENANCE_REWARD_ID}`
)
)
),
async execute(
interaction
) {
/*
* Allows:
*
* - Discord Administrators
* - BOT_AUTHOR_ID from .env
*/
if (
!await requireAdminAccess(
interaction
)
) {
return;
}
const subcommand =
interaction.options.getSubcommand();
await interaction.deferReply({
flags:
MessageFlags.Ephemeral
});
try {
if (
subcommand ===
"reconcile"
) {
const user =
interaction.options.getUser(
"user"
);
const dryRun =
interaction.options.getBoolean(
"dry-run"
) ?? false;
if (user) {
const result =
await reconcileUserAchievements({
guildId:
interaction.guildId,
userId:
user.id,
dryRun
});
return interaction.editReply({
embeds: [
new EmbedBuilder()
.setTitle(
"🛠️ Profile Reconciliation"
)
.setDescription(
`${dryRun ? "Dry run for" : "Reconciled"} ${user}.`
)
.addFields(
{
name:
"New unlocks",
value:
`**${result.unlocked}**`,
inline:
true
},
{
name:
"Definitions checked",
value:
`**${result.checked}**`,
inline:
true
},
{
name:
"Event-only skipped",
value:
`**${result.skippedEventOnly}**`,
inline:
true
}
)
]
});
}
const summary =
await reconcileAllAchievements({
guildId:
interaction.guildId,
dryRun
});
return interaction.editReply({
embeds: [
new EmbedBuilder()
.setTitle(
"🛠️ Profile Reconciliation"
)
.setDescription(
`${dryRun ? "Dry run completed" : "Reconciliation completed"} for stored profile users.`
)
.addFields(
{
name:
"Users",
value:
`**${summary.users}**`,
inline:
true
},
{
name:
"New unlocks",
value:
`**${summary.unlocked}**`,
inline:
true
},
{
name:
"Event-only checks skipped",
value:
`**${summary.skippedEventOnly}**`,
inline:
true
}
)
]
});
}
if (
subcommand ===
"maintenance-reward"
) {
const rewardId =
interaction.options.getString(
"reward-id"
)
??
DEFAULT_MAINTENANCE_REWARD_ID;
const amount =
interaction.options.getInteger(
"amount"
)
??
DEFAULT_MAINTENANCE_REWARD_AMOUNT;
/*
* Safer admin default:
*
* If omitted, maintenance-reward previews
* instead of paying immediately.
*
* Pass dry-run:false explicitly to execute.
*/
const dryRun =
interaction.options.getBoolean(
"dry-run"
)
??
true;
const summary =
await grantMaintenanceReward({
guildId:
interaction.guildId,
rewardId,
amount,
dryRun
});
return interaction.editReply({
embeds: [
new EmbedBuilder()
.setTitle(
dryRun
? "🧪 Maintenance Reward Dry Run"
: "🎁 Maintenance Reward"
)
.setDescription(
dryRun
? "No Cookies were granted. Run again with `dry-run:false` to execute this reward."
: `Maintenance compensation **${rewardId}** has been processed.`
)
.addFields(
{
name:
"Reward ID",
value:
`\`${summary.rewardId}\``,
inline:
false
},
{
name:
"Per player",
value:
`**${summary.amount.toLocaleString()} 🍪**`,
inline:
true
},
{
name:
"Stored profiles",
value:
`**${summary.users}**`,
inline:
true
},
{
name:
dryRun
? "Would receive"
: "Granted",
value:
`**${dryRun ? summary.eligible : summary.granted}**`,
inline:
true
},
{
name:
"Already claimed",
value:
`**${summary.alreadyClaimed}**`,
inline:
true
},
{
name:
"Failed",
value:
`**${summary.failed}**`,
inline:
true
}
)
]
});
}
if (
subcommand ===
"maintenance-status"
) {
const rewardId =
interaction.options.getString(
"reward-id"
)
??
DEFAULT_MAINTENANCE_REWARD_ID;
const status =
await getMaintenanceRewardStatus({
guildId:
interaction.guildId,
rewardId
});
return interaction.editReply({
embeds: [
new EmbedBuilder()
.setTitle(
"🎁 Maintenance Reward Status"
)
.addFields(
{
name:
"Reward ID",
value:
`\`${status.rewardId}\``,
inline:
false
},
{
name:
"Default amount",
value:
`**${status.amount.toLocaleString()} 🍪**`,
inline:
true
},
{
name:
"Stored profiles",
value:
`**${status.users}**`,
inline:
true
},
{
name:
"Claimed",
value:
`**${status.claimed}**`,
inline:
true
},
{
name:
"Remaining",
value:
`**${status.remaining}**`,
inline:
true
}
)
]
});
}
const audit =
await auditProfileData(
interaction.guildId
);
const issuePreview =
audit.issues
.slice(
0,
10
)
.join(
"\n"
);
return interaction.editReply({
embeds: [
new EmbedBuilder()
.setTitle(
"🔎 Profile Data Audit"
)
.addFields(
{
name:
"Stats documents",
value:
`**${audit.statDocuments}**`,
inline:
true
},
{
name:
"Achievement documents",
value:
`**${audit.achievementDocuments}**`,
inline:
true
},
{
name:
"Active achievements",
value:
`**${audit.activeAchievements}**`,
inline:
true
},
{
name:
"Global badges",
value:
`**${audit.badgeDocuments}** (${audit.claimedBadges} claimed)`,
inline:
true
},
{
name:
"Orphan achievement docs",
value:
`**${audit.orphanAchievementDocs}**`,
inline:
true
},
{
name:
"Issues",
value:
`**${audit.issueCount}**`,
inline:
true
}
)
.setDescription(
issuePreview
? `First issues:\n${issuePreview}`
: "No structural inconsistencies found."
)
]
});
}
catch (error) {
console.error(
`[ProfileAdmin] ${subcommand} failed:`,
error
);
return interaction.editReply({
embeds: [
new EmbedBuilder()
.setTitle(
"❌ Profile Admin Error"
)
.setDescription(
"The maintenance action failed. Check the bot logs for details."
)
]
});
}
}
};
+61
View File
@@ -0,0 +1,61 @@
const {
SlashCommandBuilder
} = require("discord.js");
const {
buildRecordsEmbed,
getRecordChoices
} = require(
"../../utils/profileRecords"
);
module.exports = {
data:
new SlashCommandBuilder()
.setName(
"records"
)
.setDescription(
"View Olymp Cookie and Crumb Saucer records"
)
.addStringOption(
option =>
option
.setName(
"category"
)
.setDescription(
"Record category"
)
.setRequired(
true
)
.addChoices(
...getRecordChoices()
)
),
async execute(
interaction
) {
const category =
interaction.options.getString(
"category",
true
);
const embed =
await buildRecordsEmbed({
guildId:
interaction.guildId,
category,
botUserId:
interaction.client.user.id,
limit: 10
});
return interaction.reply({
embeds: [embed]
});
}
};
+185
View File
@@ -0,0 +1,185 @@
# ShaiBot Architecture
This manual describes ShaiBot's implementation structure at a level
useful to maintainers. For exact behavior, the current code remains
authoritative.
## Runtime
`index.js` initializes the Discord client and MongoDB connection,
creates the interaction collections, initializes shared systems/indexes,
loads handlers, and logs the bot in.
Interaction modules are discovered into collections such as:
``` js
client.commands
client.legacyCommands
client.aliases
client.buttons
client.selectMenus
client.modals
```
Before manually wiring a new component into the central router, check
whether the filesystem loader already supports it.
## Preferred Layering
ShaiBot favors:
``` text
Discord command / button / select menu
↓
shared utility
↓
Mongo / economy / profile
```
Discord wrappers should remain thin. Shared rules belong in `utils/`.
A feature reachable from multiple interfaces should normally have one
authoritative implementation.
## Important Shared Utilities
Examples include:
-----------------------------------------------------------------------
Utility Responsibility
----------------------------------- -----------------------------------
`cookieEconomy.js` Cookie balances, transfers,
cooldowns, economy primitives
`crumbCollect.js` Shared Cookie collection
`crumbSlots.js` Slots
`crumbBlackjack.js` Blackjack
`crumbRoulette.js` Roulette
`cookieHeist.js` Vault Heist
`bankruptcy.js` Bankruptcy/recovery
`profileSystem.js` Profiles, achievements, badges,
activity
`profileMaintenance.js` Profile audit/reconciliation
`interactionSession.js` Shared single-owner interaction
sessions
`permissions.js` Administrator/bot-author checks
`pokerEngine.js` Pure Hold'em engine
`crumbPoker.js` Persistent Poker service
`pokerDiscord.js` Poker Discord rendering/components
`pokerProfile.js` Poker progression bridge
`crumbleStrike.js` Cooperative encounter state/combat
-----------------------------------------------------------------------
## MongoDB
Important collections include:
``` text
items_cookies
cooldown_cookies
items_games
items_special_games
stats_profiles
items_achievements
guild_badges
config_achievements
```
Indexes are part of correctness, not only performance.
ShaiBot intentionally avoids requiring multi-document MongoDB
transactions. Depending on the subsystem it instead uses:
- Atomic filtered updates
- Unique indexes
- Optimistic version checks
- Idempotency markers
- Persistent resolution state
- Compensating writes
## Concurrency
Assume users can double-click, spam controls, act simultaneously, race a
timeout, or restart the bot during a transition.
Stateful systems should answer:
``` text
What is authoritative?
Can two interactions both succeed?
Can money move twice?
What happens if the process stops after the first write?
Can the operation safely be retried?
```
Do not rely on process-local flags for correctness that must survive a
restart.
## Discord Interactions
Button visibility is never authorization.
For potentially slow stateful interactions, acknowledge Discord early
using the response style appropriate to that flow.
Do not globally defer every interaction from the central router because
different modules legitimately use `reply`, `update`, `deferReply`, or
`deferUpdate`.
Critical state should not exist only in an embed/footer.
## Permissions
Administrative access is centralized through the shared permission
utility.
Administrative access may include:
``` text
Discord Administrator
OR
BOT_AUTHOR_ID
```
Do not hard-code administrative Discord IDs.
## Deployment
Typical production workflow:
``` bash
node --check path/to/changed-file.js
pm2 restart ShaiBot
pm2 logs ShaiBot --lines 100
```
Syntax checking is useful, but economy and persistent-state changes
still require behavioral testing.
## Maintainer Checklist
Before shipping a stateful change:
- Identify the authoritative state.
- Check duplicate-click behavior.
- Check simultaneous-user behavior.
- Check insufficient-balance behavior.
- Check restart/recovery behavior.
- Check private/public information boundaries.
- Check whether profile tracking can safely fail without corrupting
settlement.
- Inspect Mongo after a controlled live test.
+168
View File
@@ -0,0 +1,168 @@
# ShaiBot Cookie Economy
This manual describes the shared Cookie economy and the rules
maintainers should preserve.
## Source of Truth
`utils/cookieEconomy.js` is the shared economy utility.
Game and command modules should not perform independent ad-hoc balance
mutations when the shared economy already provides the required
operation.
## Balances
Cookie balances live in:
``` text
items_cookies
```
Conceptually:
``` js
{
userId: "...",
cookies: 2500
}
```
Negative changes use an atomic balance filter so a player cannot spend
more Cookies than are available.
Expected insufficient-balance failures use:
``` text
INSUFFICIENT_COOKIES
```
Some subsystems carry expected error identifiers in `error.message`,
while others may also use `error.code`. User-facing formatters should
account for the convention used by that subsystem.
## Transfers
Player-to-player transfers do not require MongoDB transactions.
The safe conceptual sequence is:
``` text
atomic debit source
→ credit destination
→ compensate source if destination credit fails
```
A critical compensation failure must remain visible in logs.
## Cooldowns
Cooldowns live in:
``` text
cooldown_cookies
```
Use the shared cooldown helpers, especially atomic cooldown claiming
when double-clicks or simultaneous requests must not both succeed.
## House Vault
ShaiBot acts as the general house Cookie vault for supported games.
Gambling losses and penalties may feed the vault, while wins, heists,
and rewards may pay out from it.
House-funded features should respect their existing liquidity rules.
## Cookie Collection
`/cookies collect` and the Crumb Saucer Bakery share
`utils/crumbCollect.js`.
They must share:
- the same cooldown
- the same reward generation
- the same balance mutation
- the same profile tracking
Do not create separate collection behavior for the two interfaces.
## PvP Stealing
Normal player-to-player stealing is separate from Vault Heist.
Detailed robbery balancing lives in:
``` text
COOKIE_LOGIC.md
```
The current model includes wealth scaling, victim heat, percentage-based
loot, a hard per-hit cap, and an hourly victim exposure budget.
## Persistent Game Settlement
For simple house games, settlement should still be protected against
repeated resolution.
For lifecycle-heavy multiplayer games such as Poker, Cookie movement is
separated from internal chip movement.
Poker only crosses the Cookie economy boundary for:
``` text
buy-in
cash-out / refund
```
Ordinary Poker betting modifies the table's internal stack state, not
`items_cookies`.
## Idempotent Operations
Recovery-sensitive economy flows can use operation IDs.
Poker temporarily stores markers such as:
``` js
appliedCookieOperations: [
"poker_buyin:...",
"poker_cashout:..."
]
```
The purpose is to prevent a retry after a crash from moving money twice.
Safe lifecycle:
``` text
apply Cookie operation once
→ persist authoritative game state
→ confirm operation is durably represented
→ remove temporary marker
```
Never remove a recovery marker before the surrounding state is safe.
When the final marker is removed, unset the empty field instead of
leaving:
``` js
appliedCookieOperations: []
```
## Economy Invariants
Every economy change should preserve:
``` text
No Cookie duplication.
No unexplained Cookie loss.
No duplicate debit.
No duplicate payout.
No spending below zero.
```
Correctness and recoverability take priority over cosmetic cleanup.
+262
View File
@@ -0,0 +1,262 @@
# ShaiBot Game Manual
This document gives maintainers a compact overview of the main game
systems. Exact rules remain in the owning utilities.
# Crumb Saucer
The Crumb Saucer is ShaiBot's Cookie-powered game hub.
Shared principle:
``` text
Discord controls
→ game utility
→ Cookie economy / profile system
```
## Slots
Core utility:
``` text
utils/crumbSlots.js
```
Features include multiple wagers, symbol payouts, jackpots, All-In
tracking, Run It Back, profile statistics, achievements, and guild-first
triplet badges.
Historical replay facts must come from wager-time metadata rather than
the player's current Cookie balance.
## Blackjack
Core utility:
``` text
utils/crumbBlackjack.js
```
Supports:
- Hit
- Stand
- Double
- Dealer stands on 17
- Natural Blackjack pays 3:2
- Push
- All-In
- Run It Back
Repeated interactions must never resolve/pay the same hand twice.
## Roulette
Core utility:
``` text
utils/crumbRoulette.js
```
European Roulette uses `0–36`.
Simple bets include Red, Black, Odd, Even, and Zero. Zero loses for
Red/Black/Odd/Even.
Animation uses edits of the same message rather than high-frequency
message spam.
## Vault Heist
Core utility:
``` text
utils/cookieHeist.js
```
Vault Heist targets ShaiBot's house vault and is separate from PvP
stealing.
Approaches include Sneak, Hack, and Smash and share the same Heist
cooldown.
## Bankruptcy
Core utility:
``` text
utils/bankruptcy.js
```
Bankruptcy provides a recovery path and tracks post-Bankruptcy
rebuilding in profiles.
Recovery progress should reflect actual balance after Bankruptcy, not
merely lifetime Cookie gains.
## Run It Back
Supported games can track consecutive replay through the explicit Run It
Back action.
A normal/new wager breaks the chain.
Win/loss/push does not inherently break it because the streak represents
the player's decision to immediately replay.
Historical All-In and wager state must be preserved at the time the
wager occurs.
# Poker
Poker is a persistent 2--6 player Texas Hold'em game inside the Crumb
Saucer.
Important files:
``` text
utils/pokerEngine.js
utils/crumbPoker.js
utils/pokerDiscord.js
utils/pokerInteractions.js
utils/pokerProfile.js
handlers/poker.js
buttons/poker/*
selectMenus/poker/*
```
Current defaults:
``` text
Buy-in: 1,000 Cookies
Blinds: 10 / 20
Turn timer: 90 seconds
Pot model: single pot
```
Supported actions:
``` text
Fold
Check
Call
Bet
Raise
All-In
```
## Single-Pot Rule
Poker intentionally has no side pots.
At hand start, the maximum amount each participant can commit is limited
by the smallest participating starting stack.
Chips above that cap remain protected for a later hand.
## Persistence
MongoDB is authoritative for live/recoverable tables.
Poker uses temporary state in:
``` text
items_special_games
```
and optimistic versioning for authoritative updates.
Fully settled games are cleaned up rather than kept permanently as raw
history.
## Economy
Cookies move only at buy-in and cash-out/refund boundaries.
Betting moves internal table chips.
Recovery-sensitive economy operations are idempotent.
## Privacy
Hole cards are private.
The private Cards view may show the requesting player's cards, community
cards, turn status, and remaining turn time.
It must never expose opponent cards.
A player-facing equity/win-percentage calculator is intentionally
excluded from V1 for fairness.
## Turn / Lobby Behavior
Active turns have a 90-second deadline.
Timeout folding uses the same authoritative Fold transition as a normal
player action.
Lobby expiry is disabled while a hand is active and refreshed when a
hand ends.
The internal between-hand state may be called `NEXT_HAND`, but the
user-facing UI presents **HAND COMPLETE**.
## Cancellation / Cleanup
Host cancellation is allowed outside an active hand.
Cancellation safely settles players before deleting the table state.
Confirmation controls should disappear after Confirm or Keep so stale
buttons cannot remain usable.
# Crumble Strike
Core utility:
``` text
utils/crumbleStrike.js
```
Crumble Strike is a persistent cooperative boss battle.
Typical flow:
1. Spawn encounter.
2. Recruit players.
3. Start once requirements are met.
4. Fight within the encounter lifetime.
5. Settle success/failure and committed Cookies.
Combat includes:
``` text
Strike
Defend
Heal
Revive
Limit Break
```
Roles include:
``` text
DPS
Tank
Healer
```
Crumble Strike has more lifecycle complexity than ordinary Saucer games.
Do not copy its persistence model into simpler games unless the new
feature genuinely requires it.
# Raid Planner
Raid planning uses Discord embeds and reactions.
Participation can count unique users across reactions without forcibly
removing reactions.
Be mindful of Discord partials when changing reaction behavior.
+200
View File
@@ -0,0 +1,200 @@
# ShaiBot Profiles, Achievements & Badges
ShaiBot's profile system is the permanent progression layer behind the
economy and games.
## Central Profile System
`utils/profileSystem.js` owns the main profile/progression behavior.
Important collections:
``` text
stats_profiles
items_achievements
guild_badges
config_achievements
```
Game modules should integrate with this system instead of creating
separate progression databases.
## Profiles
Profiles can track:
- Overall game totals
- Per-game statistics
- Cookie gains and losses
- Giving and stealing
- Daily activity
- Win/loss streaks
- Weekday/time-of-day activity
- Social relationships
- Run It Back progression
- Poker history
- Achievement progress
- Badge ownership
Profiles are intended to preserve meaningful long-term history.
## Profile Events
Prefer extending established event metadata rather than inventing
parallel event systems.
Common concepts include:
``` text
game_start
game_result
cookie_gain
cookies_given
steal_success
stolen_from_me
```
New wager metadata should prefer:
``` text
wager
```
Compatibility reads for older `bet`/`previousBet` fields may remain
intentionally.
## Activity
Profile activity uses a configured timezone.
Default:
``` text
Europe/Berlin
```
Activity supports daily streaks, weekly participation, weekday counts,
and broad time buckets.
Avoid making ordinary progression depend on excessively narrow
late-night windows.
## Social Progression
Profiles can store unique relationship sets for gifting and stealing.
Use set-style updates for unique users rather than appending duplicates.
Current social progression is intentionally scaled for a relatively
small community.
## Achievements
Achievements are personal: multiple players can earn the same
achievement.
Supported models include:
### Stat-backed
Best for durable lifetime milestones and reconciliation.
### Composite
Combines multiple durable conditions.
### Event-only
Used when the accomplishment depends on exact event context that cannot
reliably be reconstructed later.
### Hidden
Used for secret/special challenges.
Event-only achievements should not be treated as historically
backfillable when the necessary event was never stored.
## Achievement Notifications
Unlock notification is centralized.
Individual games should not create competing achievement announcement
systems.
## Global Badges
Global badges are guild-unique distinctions.
The common pattern is an atomic claim against an unowned badge:
``` text
guildId
badgeId
ownerId: null
```
then setting the winner.
This prevents simultaneous candidates from both owning the same
guild-first badge.
Achievements and badges are intentionally different:
``` text
Achievement → personal; many owners
Global badge → guild-unique; one owner
```
## ID Stability
Once achievement or badge IDs may exist in MongoDB, treat them as
persistent database identifiers.
Prefer retiring an old definition over renaming/deleting an ID that
players may already own.
## Reconciliation & Audit
Administrative tooling includes:
``` text
/profileadmin audit
/profileadmin reconcile dry-run:true
```
Reconciliation can backfill durable/stat-backed accomplishments.
Event-only achievements may be skipped because historical event context
cannot be invented safely.
When changing progression schemas:
- use idempotent migrations
- preserve existing values
- preserve historical IDs
- dry-run reconciliation first
- do not fabricate data that was never tracked
## Poker History
Poker stores permanent statistics in the profile system rather than
retaining completed raw table documents.
Examples include:
- hands played/won/lost
- Cookies won/lost
- largest pot
- All-Ins
- showdowns
- action counts
- streaks
- hand-rank counters
- pocket Aces wins
- 7-2 wins
- fold wins
- full-table wins
Poker profile tracking occurs after authoritative game state succeeds. A
profile failure must never corrupt a valid pot/economy settlement.
+183 -275
View File
@@ -1,275 +1,183 @@
const { const {
Events, Events,
MessageFlags MessageFlags
} = require("discord.js"); } = require("discord.js");
require("dotenv").config(); const {
guardSessionInteraction
} = require("../utils/interactionSession");
module.exports = {
name: Events.InteractionCreate, require("dotenv").config();
once: false,
module.exports = {
async execute(interaction) { name: Events.InteractionCreate,
if (interaction.isAutocomplete()) { once: false,
return autocompleteHandler(interaction);
} async execute(interaction) {
if (interaction.isAutocomplete()) {
if (interaction.isChatInputCommand()) { return autocompleteHandler(interaction);
return commandHandler(interaction); }
}
if (interaction.isChatInputCommand()) {
if (interaction.isButton()) { return commandHandler(interaction);
return buttonHandler(interaction); }
}
if (interaction.isButton()) {
if ( return buttonHandler(interaction);
interaction.isMentionableSelectMenu() || }
interaction.isUserSelectMenu()
) { if (
return selectMenuHandler(interaction); interaction.isMentionableSelectMenu() ||
} interaction.isUserSelectMenu() ||
interaction.isStringSelectMenu()
if (interaction.isModalSubmit()) { ) {
return modalHandler(interaction); return selectMenuHandler(interaction);
} }
}
}; if (interaction.isModalSubmit()) {
return modalHandler(interaction);
}
async function autocompleteHandler(interaction) { }
const command = };
interaction.client.commands.get(
interaction.commandName async function autocompleteHandler(interaction) {
); const command = interaction.client.commands.get(
interaction.commandName
);
if (!command) {
return console.error( if (!command) {
`No command matching ${interaction.commandName} was found.` return console.error(
); `No command matching ${interaction.commandName} was found.`
} );
}
if ( if (typeof command.autocomplete !== "function") {
typeof command.autocomplete !== return;
"function" }
) {
return; try {
} await command.autocomplete(interaction);
} catch (error) {
console.error(
try { `Autocomplete error for /${interaction.commandName}:`,
await command.autocomplete( error
interaction );
);
try {
} catch (error) { await interaction.respond([]);
console.error( } catch {}
`Autocomplete error for /${interaction.commandName}:`, }
error }
);
async function commandHandler(interaction) {
const command = interaction.client.commands.get(
/* interaction.commandName
* Autocomplete cannot use normal interaction.reply(). );
* If something failed, send an empty set so Discord
* stops waiting. if (!command) {
*/ return console.error(
try { `No command matching ${interaction.commandName} was found.`
await interaction.respond([]); );
} catch { }
// Interaction may already be acknowledged or expired.
} try {
} await command.execute(interaction);
} } catch (error) {
console.error(error);
await sendInteractionError(
async function commandHandler(interaction) { interaction,
const command = "There was an error while executing this command!"
interaction.client.commands.get( );
interaction.commandName }
); }
async function buttonHandler(interaction) {
if (!command) { const allowed = await guardSessionInteraction(interaction);
return console.error(
`No command matching ${interaction.commandName} was found.` if (!allowed) {
); return;
} }
const button = interaction.client.buttons.get(
try { interaction.customId
await command.execute( );
interaction
); if (!button) {
return console.error(
} catch (error) { `No button registered matching ${interaction.customId}.`
console.error(error); );
}
if (
interaction.replied || try {
interaction.deferred await button.execute(interaction);
) { } catch (error) {
await interaction.followUp({ console.error(error);
content: await sendInteractionError(
"There was an error while executing this command!", interaction,
flags: "There was an error while executing this button!"
MessageFlags.Ephemeral );
}); }
}
} else {
await interaction.reply({ async function selectMenuHandler(interaction) {
content: const selectMenu = interaction.client.selectMenus.get(
"There was an error while executing this command!", interaction.customId
flags: );
MessageFlags.Ephemeral
}); if (!selectMenu) {
} return console.error(
} `No Select Menu registered matching ${interaction.customId}.`
} );
}
async function buttonHandler(interaction) { try {
const button = await selectMenu.execute(interaction);
interaction.client.buttons.get( } catch (error) {
interaction.customId console.error(error);
); await sendInteractionError(
interaction,
"There was an error while executing this select menu!"
if (!button) { );
return console.error( }
`No button registered matching ${interaction.customId}.` }
);
} async function modalHandler(interaction) {
const customId = interaction.customId.split(":")[0];
const modal = interaction.client.modals.get(customId);
try {
await button.execute( if (!modal) {
interaction return console.error(
); `No Modal registered matching ${interaction.customId}.`
);
} catch (error) { }
console.error(error);
try {
if ( await modal.execute(interaction);
interaction.replied || } catch (error) {
interaction.deferred console.error(error);
) { await sendInteractionError(
await interaction.followUp({ interaction,
content: "There was an error while executing this modal!"
"There was an error while executing this button!", );
flags: }
MessageFlags.Ephemeral }
});
async function sendInteractionError(interaction, content) {
} else { const payload = {
await interaction.reply({ content,
content: flags: MessageFlags.Ephemeral
"There was an error while executing this button!", };
flags:
MessageFlags.Ephemeral try {
}); if (interaction.replied || interaction.deferred) {
} await interaction.followUp(payload);
} } else {
} await interaction.reply(payload);
}
} catch (error) {
async function selectMenuHandler(interaction) { console.error(
const selectMenu = "Failed to send interaction error response:",
interaction.client.selectMenus.get( error
interaction.customId );
); }
}
if (!selectMenu) {
return console.error(
`No Select Menu registered matching ${interaction.customId}.`
);
}
try {
await selectMenu.execute(
interaction
);
} catch (error) {
console.error(error);
if (
interaction.replied ||
interaction.deferred
) {
await interaction.followUp({
content:
"There was an error while executing this select menu!",
flags:
MessageFlags.Ephemeral
});
} else {
await interaction.reply({
content:
"There was an error while executing this select menu!",
flags:
MessageFlags.Ephemeral
});
}
}
}
async function modalHandler(interaction) {
const customId =
interaction.customId
.split(":")[0];
const modal =
interaction.client
.modals
.get(
customId
);
if (!modal) {
return console.error(
`No Modal registered matching ${interaction.customId}.`
);
}
try {
await modal.execute(
interaction
);
} catch (error) {
console.error(error);
if (
interaction.replied ||
interaction.deferred
) {
await interaction.followUp({
content:
"There was an error while executing this modal!",
flags:
MessageFlags.Ephemeral
});
} else {
await interaction.reply({
content:
"There was an error while executing this modal!",
flags:
MessageFlags.Ephemeral
});
}
}
}
+313
View File
@@ -0,0 +1,313 @@
const {
getPokerService
} = require("../utils/crumbPoker");
const {
refreshPokerTableMessage
} = require("../utils/pokerDiscord");
const {
announcePokerHandProgress,
createPokerProfileContext,
recordPokerHandProfiles
} = require("../utils/pokerProfile");
const TURN_CHECK_INTERVAL =
5 * 1000;
const LOBBY_CHECK_INTERVAL =
30 * 1000;
const STARTUP_RECOVERY_DELAY =
10 * 1000;
module.exports = client => {
setTimeout(
() => {
recoverPoker(
client
);
},
STARTUP_RECOVERY_DELAY
);
setInterval(
() => {
processPokerTurns(
client
);
},
TURN_CHECK_INTERVAL
);
setInterval(
() => {
processPokerLobbies(
client
);
},
LOBBY_CHECK_INTERVAL
);
console.log(
"[Poker] Recovery and timeout watcher initialized"
);
};
async function refreshResultGame(
client,
result
) {
const game =
result?.game;
if (!game) {
return;
}
await refreshPokerTableMessage(
client,
game
);
}
async function processPokerTurns(
client
) {
if (!client.isReady()) {
return;
}
try {
const results =
await getPokerService()
.processExpiredTurns();
for (const result of results) {
if (result.processed) {
console.log(
`[Poker] Auto-folded ${result.userId} after turn timeout.`
);
await refreshResultGame(
client,
result
);
if (
result.game
?.data
?.hand
?.state ===
"PAYOUT"
) {
try {
await recordPokerHandProfiles(
result.game
);
const context =
await createPokerProfileContext(
client,
result.game
);
if (context) {
await announcePokerHandProgress(
context,
result.game
);
}
}
catch (error) {
console.error(
`[PokerProfile] Timeout profile processing failed for ${result.game.gameId}:`,
error
);
}
}
}
}
}
catch (error) {
console.error(
"[Poker] Turn watcher error:",
error
);
}
}
async function processPokerLobbies(
client
) {
if (!client.isReady()) {
return;
}
try {
const results =
await getPokerService()
.processExpiredLobbies();
for (const result of results) {
if (result.expired) {
console.log(
`[Poker] Expired waiting Poker table ${result.game?.gameId ?? "unknown"}.`
);
await refreshResultGame(
client,
result
);
}
}
}
catch (error) {
console.error(
"[Poker] Lobby watcher error:",
error
);
}
}
async function recoverPoker(
client
) {
if (!client.isReady()) {
return;
}
try {
const service =
getPokerService();
const result =
await service.recover();
const activeTables =
await service.listActiveTables();
/*
* Rehydrate every still-active public Poker table after
* restart, even when no recovery action was necessary.
* Mongo remains authoritative; the Discord embed is only
* a projection of the current stored game state.
*/
for (const game of activeTables) {
try {
await refreshPokerTableMessage(
client,
game
);
if (
game.data?.hand?.state ===
"PAYOUT"
) {
await recordPokerHandProfiles(
game
);
const context =
await createPokerProfileContext(
client,
game
);
if (context) {
await announcePokerHandProgress(
context,
game
);
}
}
}
catch (error) {
console.error(
`[Poker] Failed to refresh active table ${game.gameId}:`,
error
);
}
}
for (
const turn
of result.turns ?? []
) {
if (turn.processed) {
await refreshResultGame(
client,
turn
);
}
}
for (
const lobby
of result.lobbies ?? []
) {
if (lobby.expired) {
await refreshResultGame(
client,
lobby
);
}
}
for (
const closing
of result.closingTables ?? []
) {
await refreshResultGame(
client,
closing
);
}
for (
const cashout
of result.cashouts ?? []
) {
try {
const game =
await service.getTable({
gameId:
cashout.gameId
});
await refreshPokerTableMessage(
client,
game
);
}
catch {}
}
console.log(
`[Poker] Recovery complete: ` +
`${result.memberships.length} membership(s), ` +
`${result.cashouts.length} cash-out(s), ` +
`${result.closingTables.length} closing table(s), ` +
`${result.turns.length} expired turn(s), ` +
`${result.lobbies.length} expired lobby/lobbies, ` +
`${activeTables.length} active table(s) refreshed.`
);
}
catch (error) {
console.error(
"[Poker] Startup recovery error:",
error
);
}
}
+72 -119
View File
@@ -1,141 +1,94 @@
const { const {
Client, Client,
GatewayIntentBits, GatewayIntentBits,
Collection, Collection,
Partials Partials
} = require("discord.js"); } = require("discord.js");
const { const { MongoClient } = require("mongodb");
MongoClient
} = require("mongodb");
const fs = require("node:fs"); const fs = require("node:fs");
require("dotenv").config(); require("dotenv").config();
const mClient = new MongoClient(
process.env.M_URI
);
/* exports.mClient = mClient;
* MONGODB
*/
const mClient = const client = new Client({
new MongoClient( intents: [
process.env.M_URI 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.mClient = exports.client = client;
mClient;
client.commands = new Collection();
/* client.legacyCommands = new Collection();
* DISCORD CLIENT client.aliases = new Collection();
*/ client.buttons = new Collection();
client.selectMenus = new Collection();
const client = client.modals = new Collection();
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() { async function start() {
try { try {
await mClient.connect();
console.log("MongoDB connected");
/* const {
* Connect Mongo first. ensureCookieIndexes
*/ } = require("./utils/cookieEconomy");
const {
ensureProfileSystem
} = require("./utils/profileSystem");
const {
ensureInteractionSessionIndexes
} = require("./utils/interactionSession");
const {
ensureBankruptcyIndexes
} = require("./utils/bankruptcy");
await mClient.connect(); await Promise.all([
ensureCookieIndexes(),
ensureProfileSystem(),
ensureInteractionSessionIndexes(),
ensureBankruptcyIndexes()
]);
console.log( console.log("Database indexes initialized");
"MongoDB connected"
);
const handlerFiles = fs.readdirSync("./handlers");
/* for (const handler of handlerFiles) {
* Load handlers. require(`./handlers/${handler}`)(client);
*/ }
const handlerFiles = await client.login(
fs.readdirSync( process.env.D_TOKEN
"./handlers" );
); } catch (error) {
console.error(
"Failed to start ShaiBot:",
error
);
process.exit(1);
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();
start();
-3
View File
@@ -3,9 +3,6 @@
"version": "1.0.0", "version": "1.0.0",
"description": "", "description": "",
"main": "index.js", "main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://git.desq-gaming.de/admin_rb/ShaiBot.git" "url": "https://git.desq-gaming.de/admin_rb/ShaiBot.git"
+70
View File
@@ -0,0 +1,70 @@
const {
getPokerService
} = require("../../utils/crumbPoker");
const {
deferPokerReply,
editPokerError,
refreshPokerTableMessage,
buildOpenTableLink
} = require("../../utils/pokerDiscord");
module.exports = {
name:
"pokerJoinSelect",
async execute(
interaction
) {
await deferPokerReply(
interaction
);
const gameId =
interaction.values?.[0];
if (!gameId) {
return interaction.editReply({
content:
"❌ No Poker table was selected.",
embeds: [],
components: []
});
}
try {
const game =
await getPokerService()
.joinTable({
gameId,
guildId:
interaction.guildId,
userId:
interaction.user.id
});
await refreshPokerTableMessage(
interaction.client,
game
);
return interaction.editReply({
content:
`✅ Joined the Poker table for **${game.data.buyIn.toLocaleString()} Cookies**.`,
embeds: [],
components:
buildOpenTableLink(
game
)
});
}
catch (error) {
return editPokerError(
interaction,
error
);
}
}
};
+200
View File
@@ -0,0 +1,200 @@
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",
source: "bankruptcy",
amount:
BANKRUPTCY_GRANT
}
);
await trackProfileEvent(
interaction,
userId,
{
type: "bankruptcy",
source: "bankruptcy",
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
};
+308
View File
@@ -268,6 +268,311 @@ async function changeCookies(
} }
/*
* ============================================================
* IDEMPOTENT COOKIE CHANGE
* ============================================================
*
* Applies a Cookie mutation at most once for a user-scoped
* operationId. This is useful for persistent game buy-ins and
* cash-outs that may be retried after a crash or restart.
*
* The Cookie balance mutation and operation marker are written
* atomically to the same user document.
*/
async function changeCookiesOnce(
userId,
amount,
operationId
) {
if (
!Number.isSafeInteger(
amount
)
) {
throw new Error(
"INVALID_COOKIE_AMOUNT"
);
}
if (
typeof operationId !==
"string"
||
!/^[A-Za-z0-9:_-]{1,160}$/.test(
operationId
)
) {
throw new Error(
"INVALID_COOKIE_OPERATION_ID"
);
}
if (
amount ===
0
) {
return {
applied:
false,
balance:
await getCookieBalance(
userId
)
};
}
const {
cookies
} =
getCollections();
const filter = {
userId,
appliedCookieOperations: {
$ne:
operationId
}
};
if (
amount <
0
) {
filter.cookies = {
$gte:
Math.abs(
amount
)
};
}
const update = {
$inc: {
cookies:
amount
},
$addToSet: {
appliedCookieOperations:
operationId
}
};
if (
amount >
0
) {
update.$setOnInsert = {
userId
};
}
try {
const result =
await cookies.updateOne(
filter,
update,
{
upsert:
amount > 0
}
);
if (
result.modifiedCount ===
1
||
result.upsertedCount ===
1
) {
return {
applied:
true,
balance:
await getCookieBalance(
userId
)
};
}
}
catch (error) {
/*
* A concurrent positive retry can race an upsert.
* The unique userId index may reject the losing insert.
* Re-read below and treat an already-recorded operation
* as a successful idempotent retry.
*/
if (
error?.code !==
11000
) {
throw error;
}
}
const document =
await cookies.findOne(
{
userId
},
{
projection: {
cookies:
1,
appliedCookieOperations:
1
}
}
);
const operations =
Array.isArray(
document
?.appliedCookieOperations
)
? document
.appliedCookieOperations
: [];
if (
operations.includes(
operationId
)
) {
return {
applied:
false,
balance:
Math.max(
0,
Number(
document?.cookies ??
0
)
)
};
}
if (
amount <
0
) {
throw new Error(
"INSUFFICIENT_COOKIES"
);
}
throw new Error(
"COOKIE_OPERATION_FAILED"
);
}
async function clearCookieOperation(
userId,
operationId
) {
if (
typeof operationId !==
"string"
||
!/^[A-Za-z0-9:_-]{1,160}$/.test(
operationId
)
) {
throw new Error(
"INVALID_COOKIE_OPERATION_ID"
);
}
const {
cookies
} =
getCollections();
const result =
await cookies.updateOne(
{
userId
},
{
$pull: {
appliedCookieOperations:
operationId
}
}
);
/*
* Keep the Cookie document tidy once the final temporary
* idempotency marker has been cleared.
*/
await cookies.updateOne(
{
userId,
appliedCookieOperations: {
$size: 0
}
},
{
$unset: {
appliedCookieOperations:
""
}
}
);
return result.modifiedCount ===
1;
}
async function hasCookieOperation(
userId,
operationId
) {
if (
typeof operationId !==
"string"
||
!/^[A-Za-z0-9:_-]{1,160}$/.test(
operationId
)
) {
throw new Error(
"INVALID_COOKIE_OPERATION_ID"
);
}
const {
cookies
} =
getCollections();
const document =
await cookies.findOne(
{
userId,
appliedCookieOperations:
operationId
},
{
projection: {
_id:
1
}
}
);
return Boolean(
document
);
}
/* /*
* ============================================================ * ============================================================
* COOKIE TRANSFER * COOKIE TRANSFER
@@ -1188,6 +1493,9 @@ module.exports = {
getCookieBalance, getCookieBalance,
changeCookies, changeCookies,
changeCookiesOnce,
hasCookieOperation,
clearCookieOperation,
transferCookies, transferCookies,
getCooldown, getCooldown,
+93 -340
View File
@@ -14,103 +14,65 @@ const {
formatPercentage formatPercentage
} = require("./cookieEconomy"); } = require("./cookieEconomy");
const {
/* trackProfileEvent,
* ============================================================ recordGameOutcome
* CONFIG } = require("./profileSystem");
* ============================================================
*/
const HEIST_COOLDOWN = const HEIST_COOLDOWN =
60 * 60; // 1 hour 60 * 60;
/*
* All three approaches use the SAME cooldown:
*
* cooldowns_cookies.heist
*
* Sneak does NOT have its own cooldown.
* Hack does NOT have its own cooldown.
* Smash does NOT have its own cooldown.
*/
const HEIST_METHODS = { const HEIST_METHODS = {
Sneak: { Sneak: {
baseChance: 0.65, baseChance: 0.60,
minStealPercent: 0.01, minStealPercent: 0.01,
maxStealPercent: 0.03, maxStealPercent: 0.03,
maxStealAmount: 1500, maxStealAmount: 5000,
minPenaltyPercent: 0.01, minPenaltyPercent: 0.01,
maxPenaltyPercent: 0.04 maxPenaltyPercent: 0.04,
maxPenaltyAmount: 750
}, },
Hack: { Hack: {
baseChance: 0.45, baseChance: 0.40,
minStealPercent: 0.03, minStealPercent: 0.03,
maxStealPercent: 0.06, maxStealPercent: 0.06,
maxStealAmount: 3500, maxStealAmount: 10000,
minPenaltyPercent: 0.03, minPenaltyPercent: 0.03,
maxPenaltyPercent: 0.07 maxPenaltyPercent: 0.07,
maxPenaltyAmount: 2000
}, },
Smash: { Smash: {
baseChance: 0.25, baseChance: 0.20,
minStealPercent: 0.06, minStealPercent: 0.06,
maxStealPercent: 0.12, maxStealPercent: 0.12,
maxStealAmount: 7500, maxStealAmount: 15000,
minPenaltyPercent: 0.06, minPenaltyPercent: 0.06,
maxPenaltyPercent: 0.12 maxPenaltyPercent: 0.12,
maxPenaltyAmount: 5000
} }
}; };
/*
* ============================================================
* RESOLVE HEIST
* ============================================================
*/
async function resolveCookieHeist( async function resolveCookieHeist(
interaction, interaction,
methodName methodName
) { ) {
/*
* Validate ownership of this Heist menu.
*
* Footer:
*
* cookieHeist:<userId>
*/
const footer = const footer =
interaction.message interaction.message
?.embeds?.[0] ?.embeds?.[0]
?.footer ?.footer
?.text; ?.text;
const [ const [
type, type,
ownerId ownerId
] = ] = footer?.split(":") ?? [];
footer?.split(":")
??
[];
if ( if (
type !== type !== "cookieHeist" ||
"cookieHeist"
||
ownerId !== ownerId !==
interaction.user.id interaction.user.id
) { ) {
return temporaryEphemeralReply( return temporaryEphemeralReply(
interaction, interaction,
@@ -121,17 +83,11 @@ async function resolveCookieHeist(
); );
} }
/*
* Validate method.
*/
const method = const method =
HEIST_METHODS[ HEIST_METHODS[
methodName methodName
]; ];
if (!method) { if (!method) {
return temporaryEphemeralReply( return temporaryEphemeralReply(
interaction, interaction,
@@ -142,13 +98,6 @@ async function resolveCookieHeist(
); );
} }
/*
* ========================================================
* SHARED HEIST COOLDOWN
* ========================================================
*/
const cooldown = const cooldown =
await claimCooldown( await claimCooldown(
interaction.user.id, interaction.user.id,
@@ -157,10 +106,7 @@ async function resolveCookieHeist(
HEIST_COOLDOWN HEIST_COOLDOWN
); );
if (!cooldown.claimed) {
if (
!cooldown.claimed
) {
return temporaryEphemeralReply( return temporaryEphemeralReply(
interaction, interaction,
{ {
@@ -171,11 +117,6 @@ async function resolveCookieHeist(
); );
} }
/*
* Lock this Heist interaction immediately.
*/
await interaction.update({ await interaction.update({
embeds: [ embeds: [
new EmbedBuilder() new EmbedBuilder()
@@ -192,45 +133,26 @@ async function resolveCookieHeist(
`cookieHeist:${interaction.user.id}` `cookieHeist:${interaction.user.id}`
}) })
], ],
components: [] components: []
}); });
const playerId = const playerId =
interaction.user.id; interaction.user.id;
const vaultId = const vaultId =
interaction.client.user.id; interaction.client.user.id;
/*
* Re-fetch balances because they may have
* changed since the menu was opened.
*/
const [ const [
playerCookies, playerCookies,
vaultCookies vaultCookies
] = ] = await Promise.all([
await Promise.all([ getCookieBalance(playerId),
getCookieBalance( getCookieBalance(vaultId)
playerId ]);
),
getCookieBalance(
vaultId
)
]);
if ( if (
playerCookies < playerCookies < 1 ||
1 vaultCookies < 1
||
vaultCookies <
1
) { ) {
return interaction.editReply({ return interaction.editReply({
embeds: [ embeds: [
@@ -244,7 +166,6 @@ async function resolveCookieHeist(
.addFields({ .addFields({
name: name:
"Next Heist", "Next Heist",
value: value:
`<t:${cooldown.next}:R>` `<t:${cooldown.next}:R>`
}) })
@@ -253,54 +174,29 @@ async function resolveCookieHeist(
`cookieHeist:${playerId}` `cookieHeist:${playerId}`
}) })
], ],
components: components:
buildReturnButton() buildReturnButton()
}); });
} }
await sleep(1200);
/*
* Short theatrical delay.
*/
await sleep(
1200
);
/*
* ========================================================
* DYNAMIC VAULT SECURITY
* ========================================================
*/
const securityPenalty = const securityPenalty =
getVaultSecurityPenalty( getVaultSecurityPenalty(
vaultCookies vaultCookies
); );
const successChance = const successChance =
Math.max( Math.max(
0.10, 0.10,
method.baseChance - method.baseChance -
securityPenalty securityPenalty
); );
const success = const success =
Math.random() < Math.random() <
successChance; successChance;
/*
* ========================================================
* SUCCESS
* ========================================================
*/
if (success) { if (success) {
const rolledPercent = const rolledPercent =
randomFloat( randomFloat(
@@ -308,31 +204,25 @@ async function resolveCookieHeist(
method.maxStealPercent method.maxStealPercent
); );
const amount = const amount =
Math.max( Math.max(
1, 1,
Math.min( Math.min(
Math.floor( Math.floor(
vaultCookies * vaultCookies *
rolledPercent rolledPercent
), ),
method.maxStealAmount, method.maxStealAmount,
vaultCookies vaultCookies
) )
); );
try { try {
await transferCookies( await transferCookies(
vaultId, vaultId,
playerId, playerId,
amount amount
); );
} catch (error) { } catch (error) {
if ( if (
error.message === error.message ===
@@ -350,7 +240,6 @@ async function resolveCookieHeist(
.addFields({ .addFields({
name: name:
"Next Heist", "Next Heist",
value: value:
`<t:${cooldown.next}:R>` `<t:${cooldown.next}:R>`
}) })
@@ -359,47 +248,64 @@ async function resolveCookieHeist(
`cookieHeist:${playerId}` `cookieHeist:${playerId}`
}) })
], ],
components: components:
buildReturnButton() buildReturnButton()
}); });
} }
throw error; throw error;
} }
await recordGameOutcome(
interaction,
playerId,
{
game:
"heist",
result:
"win",
profit:
amount
}
);
/*
* ============================================================
* HEIST
* ============================================================
*/
await trackProfileEvent(
interaction,
playerId,
{
type: "vault_steal",
source: "heist",
game: "heist",
result: "win",
amount
}
);
const [ const [
playerAfter, playerAfter,
vaultAfter vaultAfter
] = ] = await Promise.all([
await Promise.all([ getCookieBalance(playerId),
getCookieBalance( getCookieBalance(vaultId)
playerId ]);
),
getCookieBalance(
vaultId
)
]);
const actualPercentage = const actualPercentage =
( (
amount / amount /
vaultCookies vaultCookies
) ) * 100;
*
100;
const embed = const embed =
new EmbedBuilder() new EmbedBuilder()
.setTitle( .setTitle(
"💥 Vault Breached!" "💥 Vault Breached!"
) )
.setDescription( .setDescription(
getSuccessText( getSuccessText(
methodName, methodName,
@@ -407,143 +313,101 @@ async function resolveCookieHeist(
amount amount
) )
) )
.addFields( .addFields(
{ {
name: name:
"Approach", "Approach",
value: value:
methodName, methodName,
inline: inline:
true true
}, },
{ {
name: name:
"Success Chance", "Success Chance",
value: value:
`**${formatPercentage(successChance)}**`, `**${formatPercentage(successChance)}**`,
inline: inline:
true true
}, },
{ {
name: name:
"Vault Taken", "Vault Taken",
value: value:
`**${actualPercentage.toFixed(2)}%**`, `**${actualPercentage.toFixed(2)}%**`,
inline: inline:
true true
}, },
{ {
name: name:
"Cookies Stolen", "Cookies Stolen",
value: value:
`**${amount.toLocaleString()} 🍪**`, `**${amount.toLocaleString()} 🍪**`,
inline: inline:
true true
}, },
{ {
name: name:
"Your Vault", "Your Vault",
value: value:
`**${playerAfter.toLocaleString()} 🍪**`, `**${playerAfter.toLocaleString()} 🍪**`,
inline: inline:
true true
}, },
{ {
name: name:
"House Vault", "House Vault",
value: value:
`**${vaultAfter.toLocaleString()} 🍪**`, `**${vaultAfter.toLocaleString()} 🍪**`,
inline: inline:
true true
}, },
{ {
name: name:
"Next Heist", "Next Heist",
value: value:
`<t:${cooldown.next}:R>`, `<t:${cooldown.next}:R>`,
inline: inline:
false false
} }
) )
.setFooter({ .setFooter({
text: text:
`cookieHeist:${playerId}` `cookieHeist:${playerId}`
}); });
return interaction.editReply({ return interaction.editReply({
embeds: [ embeds: [embed],
embed
],
components: components:
buildReturnButton() buildReturnButton()
}); });
} }
/*
* ========================================================
* FAILURE
* ========================================================
*/
const penaltyPercent = const penaltyPercent =
randomFloat( randomFloat(
method.minPenaltyPercent, method.minPenaltyPercent,
method.maxPenaltyPercent method.maxPenaltyPercent
); );
const penalty = const penalty =
Math.max( Math.max(
1, 1,
Math.min( Math.min(
Math.floor( Math.floor(
playerCookies * playerCookies *
penaltyPercent penaltyPercent
), ),
method.maxPenaltyAmount,
playerCookies playerCookies
) )
); );
try { try {
/*
* Heist fines always go into
* ShaiBot's central vault.
*/
await transferCookies( await transferCookies(
playerId, playerId,
vaultId, vaultId,
penalty penalty
); );
} catch (error) { } catch (error) {
if ( if (
error.message === error.message ===
@@ -561,7 +425,6 @@ async function resolveCookieHeist(
.addFields({ .addFields({
name: name:
"Next Heist", "Next Heist",
value: value:
`<t:${cooldown.next}:R>` `<t:${cooldown.next}:R>`
}) })
@@ -570,47 +433,47 @@ async function resolveCookieHeist(
`cookieHeist:${playerId}` `cookieHeist:${playerId}`
}) })
], ],
components: components:
buildReturnButton() buildReturnButton()
}); });
} }
throw error; throw error;
} }
await recordGameOutcome(
interaction,
playerId,
{
game:
"heist",
result:
"loss",
profit:
-penalty
}
);
const [ const [
playerAfter, playerAfter,
vaultAfter vaultAfter
] = ] = await Promise.all([
await Promise.all([ getCookieBalance(playerId),
getCookieBalance( getCookieBalance(vaultId)
playerId ]);
),
getCookieBalance(
vaultId
)
]);
const actualPenaltyPercentage = const actualPenaltyPercentage =
( (
penalty / penalty /
playerCookies playerCookies
) ) * 100;
*
100;
const embed = const embed =
new EmbedBuilder() new EmbedBuilder()
.setTitle( .setTitle(
"🚨 HEIST FAILED" "🚨 HEIST FAILED"
) )
.setDescription( .setDescription(
getFailureText( getFailureText(
methodName, methodName,
@@ -618,109 +481,76 @@ async function resolveCookieHeist(
penalty penalty
) )
) )
.addFields( .addFields(
{ {
name: name:
"Approach", "Approach",
value: value:
methodName, methodName,
inline: inline:
true true
}, },
{ {
name: name:
"Success Chance", "Success Chance",
value: value:
`**${formatPercentage(successChance)}**`, `**${formatPercentage(successChance)}**`,
inline: inline:
true true
}, },
{ {
name: name:
"Fine", "Fine",
value: value:
`**${penalty.toLocaleString()} 🍪**`, `**${penalty.toLocaleString()} 🍪**`,
inline: inline:
true true
}, },
{ {
name: name:
"Vault Lost", "Vault Lost",
value: value:
`**${actualPenaltyPercentage.toFixed(1)}%**`, `**${actualPenaltyPercentage.toFixed(1)}%**`,
inline: inline:
true true
}, },
{ {
name: name:
"Your Vault", "Your Vault",
value: value:
`**${playerAfter.toLocaleString()} 🍪**`, `**${playerAfter.toLocaleString()} 🍪**`,
inline: inline:
true true
}, },
{ {
name: name:
"House Vault", "House Vault",
value: value:
`**${vaultAfter.toLocaleString()} 🍪**`, `**${vaultAfter.toLocaleString()} 🍪**`,
inline: inline:
true true
}, },
{ {
name: name:
"Next Heist", "Next Heist",
value: value:
`<t:${cooldown.next}:R>`, `<t:${cooldown.next}:R>`,
inline: inline:
false false
} }
) )
.setFooter({ .setFooter({
text: text:
`cookieHeist:${playerId}` `cookieHeist:${playerId}`
}); });
return interaction.editReply({ return interaction.editReply({
embeds: [ embeds: [embed],
embed
],
components: components:
buildReturnButton() buildReturnButton()
}); });
} }
/*
* ============================================================
* RETURN TO CRUMB SAUCER
* ============================================================
*/
function buildReturnButton() { function buildReturnButton() {
return [ return [
new ActionRowBuilder() new ActionRowBuilder()
@@ -732,9 +562,7 @@ function buildReturnButton() {
.setLabel( .setLabel(
"Back to Crumb Saucer" "Back to Crumb Saucer"
) )
.setEmoji( .setEmoji("◀️")
"◀️"
)
.setStyle( .setStyle(
ButtonStyle.Secondary ButtonStyle.Secondary
) )
@@ -742,51 +570,21 @@ function buildReturnButton() {
]; ];
} }
/*
* ============================================================
* SECURITY
* ============================================================
*/
function getVaultSecurityPenalty( function getVaultSecurityPenalty(
vaultCookies vaultCookies
) { ) {
/*
* Approximately:
*
* 100 -> 5%
* 1,000 -> 7.5%
* 10,000 -> 10%
* 100,000 -> 12.5%
* 1,000,000 -> 15%
*/
return Math.min( return Math.min(
Math.log10( Math.log10(
vaultCookies + vaultCookies + 1
1 ) * 0.025,
)
*
0.025,
0.15 0.15
); );
} }
/*
* ============================================================
* PROGRESS TEXT
* ============================================================
*/
function getHeistProgressText( function getHeistProgressText(
methodName methodName
) { ) {
switch ( switch (methodName) {
methodName
) {
case "Sneak": case "Sneak":
return [ return [
"🥷 You slip through the service entrance...", "🥷 You slip through the service entrance...",
@@ -794,7 +592,6 @@ function getHeistProgressText(
"The Cookie Vault guards haven't noticed you yet." "The Cookie Vault guards haven't noticed you yet."
].join("\n"); ].join("\n");
case "Hack": case "Hack":
return [ return [
"💻 You connect to the vault terminal...", "💻 You connect to the vault terminal...",
@@ -802,7 +599,6 @@ function getHeistProgressText(
"Several extremely suspicious progress bars appear." "Several extremely suspicious progress bars appear."
].join("\n"); ].join("\n");
case "Smash": case "Smash":
return [ return [
"💥 You charge directly at the vault...", "💥 You charge directly at the vault...",
@@ -810,19 +606,11 @@ function getHeistProgressText(
"Subtlety has been abandoned." "Subtlety has been abandoned."
].join("\n"); ].join("\n");
default: default:
return "The heist is underway..."; return "The heist is underway...";
} }
} }
/*
* ============================================================
* SUCCESS TEXT
* ============================================================
*/
function getSuccessText( function getSuccessText(
methodName, methodName,
user, user,
@@ -831,28 +619,22 @@ function getSuccessText(
const cookies = const cookies =
`**${amount.toLocaleString()} Cookies**`; `**${amount.toLocaleString()} Cookies**`;
switch (methodName) {
switch (
methodName
) {
case "Sneak": case "Sneak":
return ( return (
`🥷 ${user} slipped through security and quietly escaped with ${cookies}.` `🥷 ${user} slipped through security and quietly escaped with ${cookies}.`
); );
case "Hack": case "Hack":
return ( return (
`💻 ${user} convinced the vault terminal that ${cookies} belonged to them.` `💻 ${user} convinced the vault terminal that ${cookies} belonged to them.`
); );
case "Smash": case "Smash":
return ( return (
`💥 ${user} smashed through the Cookie Vault and escaped with ${cookies}!` `💥 ${user} smashed through the Cookie Vault and escaped with ${cookies}!`
); );
default: default:
return ( return (
`${user} escaped with ${cookies}.` `${user} escaped with ${cookies}.`
@@ -860,13 +642,6 @@ function getSuccessText(
} }
} }
/*
* ============================================================
* FAILURE TEXT
* ============================================================
*/
function getFailureText( function getFailureText(
methodName, methodName,
user, user,
@@ -875,10 +650,7 @@ function getFailureText(
const cookies = const cookies =
`**${penalty.toLocaleString()} Cookies**`; `**${penalty.toLocaleString()} Cookies**`;
switch (methodName) {
switch (
methodName
) {
case "Sneak": case "Sneak":
return [ return [
`🚨 ${user} stepped directly on the world's loudest Cookie wrapper.`, `🚨 ${user} stepped directly on the world's loudest Cookie wrapper.`,
@@ -886,7 +658,6 @@ function getFailureText(
`The ShaiBot Vault collected a ${cookies} fine.` `The ShaiBot Vault collected a ${cookies} fine.`
].join("\n"); ].join("\n");
case "Hack": case "Hack":
return [ return [
`🚨 ${user} entered the password incorrectly one too many times.`, `🚨 ${user} entered the password incorrectly one too many times.`,
@@ -894,7 +665,6 @@ function getFailureText(
`The ShaiBot Vault collected a ${cookies} security fee.` `The ShaiBot Vault collected a ${cookies} security fee.`
].join("\n"); ].join("\n");
case "Smash": case "Smash":
return [ return [
`🚨 ${user} discovered that the vault door is significantly stronger than expected.`, `🚨 ${user} discovered that the vault door is significantly stronger than expected.`,
@@ -902,7 +672,6 @@ function getFailureText(
`Repairs cost ${cookies}. Naturally, you're paying.` `Repairs cost ${cookies}. Naturally, you're paying.`
].join("\n"); ].join("\n");
default: default:
return ( return (
`The heist failed and cost ${user} ${cookies}.` `The heist failed and cost ${user} ${cookies}.`
@@ -910,16 +679,7 @@ function getFailureText(
} }
} }
function sleep(ms) {
/*
* ============================================================
* UTIL
* ============================================================
*/
function sleep(
ms
) {
return new Promise( return new Promise(
resolve => resolve =>
setTimeout( setTimeout(
@@ -929,14 +689,7 @@ function sleep(
); );
} }
/*
* ============================================================
* EXPORTS
* ============================================================
*/
module.exports = { module.exports = {
resolveCookieHeist, resolveCookieHeist,
HEIST_COOLDOWN HEIST_COOLDOWN
}; };
+235 -827
View File
File diff suppressed because it is too large Load Diff
+403
View File
@@ -0,0 +1,403 @@
const {
EmbedBuilder,
ActionRowBuilder,
ButtonBuilder,
ButtonStyle
} = require("discord.js");
const {
getCookieBalance,
changeCookies,
claimCooldown,
temporaryEphemeralReply
} = require("./cookieEconomy");
const {
trackProfileEvent
} = require("./profileSystem");
/*
* ============================================================
* CONFIG
* ============================================================
*/
const COLLECT_COOLDOWN = 60 * 60;
const COLLECT_MIN = 1;
const COLLECT_MAX = 1000;
const COLLECT_COOLDOWN_KEY = "collect";
/*
* ============================================================
* SHARED COLLECT LOGIC
* ============================================================
*
* This is the one authoritative implementation used by:
*
* - /cookies collect
* - Crumb Saucer Cookie Bakery
*
* It does not reply/update Discord itself.
*/
async function collectCookies(
interaction,
userId = interaction.user.id
) {
const cooldown = await claimCooldown(
userId,
interaction.guildId,
COLLECT_COOLDOWN_KEY,
COLLECT_COOLDOWN
);
if (!cooldown.claimed) {
const balance = await getCookieBalance(
userId
);
return {
collected: false,
cooldown,
balance,
amount: 0
};
}
const amount =
Math.floor(
Math.random() *
(
COLLECT_MAX -
COLLECT_MIN +
1
)
) +
COLLECT_MIN;
await changeCookies(
userId,
amount
);
await trackProfileEvent(
interaction,
userId,
{
type: "cookie_gain",
source: "collect",
amount
}
);
const balance = await getCookieBalance(
userId
);
return {
collected: true,
cooldown,
balance,
amount
};
}
/*
* ============================================================
* SHARED EMBED BUILDERS
* ============================================================
*/
function buildCollectSuccessEmbed(
userId,
result,
footerType = "crumbSaucerCollect"
) {
return new EmbedBuilder()
.setTitle(
"🍪 Fresh Batch!"
)
.setDescription(
[
`You collected **${result.amount.toLocaleString()} Cookies**! 🍪`,
"",
`Your next batch will be ready <t:${result.cooldown.next}:R>.`
].join("\n")
)
.addFields({
name: "Your Vault",
value:
`**${result.balance.toLocaleString()} 🍪**`
})
.setFooter({
text:
`${footerType}:${userId}`
});
}
function buildCollectCooldownEmbed(
userId,
result,
footerType = "crumbSaucerCollect"
) {
return new EmbedBuilder()
.setTitle(
"🍪 Cookie Bakery"
)
.setDescription(
[
"Your next batch isn't ready yet.",
"",
`Come back <t:${result.cooldown.next}:R>.`
].join("\n")
)
.addFields({
name: "Your Vault",
value:
`**${result.balance.toLocaleString()} 🍪**`
})
.setFooter({
text:
`${footerType}:${userId}`
});
}
/*
* ============================================================
* SHOW SAUCER COLLECT PAGE
* ============================================================
*/
async function showCollectPage(
interaction
) {
const ownerId =
getCollectOwner(
interaction
);
if (
ownerId !==
interaction.user.id
) {
return temporaryEphemeralReply(
interaction,
{
content:
"This isn't your Crumb Saucer session."
}
);
}
const balance =
await getCookieBalance(
interaction.user.id
);
const embed =
new EmbedBuilder()
.setTitle(
"🍪 Crumb Saucer Cookie Bakery"
)
.setDescription(
[
"Fresh Cookies are prepared regularly.",
"",
"Press **Collect Cookies** to claim your batch.",
"",
"Your collection cooldown is shared with `/cookies collect`."
].join("\n")
)
.addFields({
name: "Your Vault",
value:
`**${balance.toLocaleString()} 🍪**`
})
.setFooter({
text:
`crumbSaucerCollect:${interaction.user.id}`
});
return interaction.update({
embeds: [embed],
components:
buildCollectButtons()
});
}
/*
* ============================================================
* SAUCER COLLECT WRAPPER
* ============================================================
*/
async function collectCookiesFromSaucer(
interaction
) {
const ownerId =
getCollectOwner(
interaction
);
if (
ownerId !==
interaction.user.id
) {
return temporaryEphemeralReply(
interaction,
{
content:
"This isn't your Crumb Saucer session."
}
);
}
const result =
await collectCookies(
interaction,
interaction.user.id
);
const embed =
result.collected
? buildCollectSuccessEmbed(
interaction.user.id,
result
)
: buildCollectCooldownEmbed(
interaction.user.id,
result
);
return interaction.update({
embeds: [embed],
components:
buildCollectButtons()
});
}
/*
* ============================================================
* BUTTONS
* ============================================================
*/
function buildCollectButtons() {
return [
new ActionRowBuilder()
.addComponents(
new ButtonBuilder()
.setCustomId(
"crumbSaucerCollect"
)
.setLabel(
"Collect Cookies"
)
.setEmoji(
"🍪"
)
.setStyle(
ButtonStyle.Success
),
new ButtonBuilder()
.setCustomId(
"crumbSaucerBack"
)
.setLabel(
"Crumb Saucer"
)
.setEmoji(
"◀️"
)
.setStyle(
ButtonStyle.Secondary
)
)
];
}
/*
* ============================================================
* OWNER
* ============================================================
*/
function getCollectOwner(
interaction
) {
const footer =
interaction.message
?.embeds?.[0]
?.footer
?.text;
const parts =
footer?.split(":")
??
[];
if (
parts[0] ===
"crumbSaucer"
) {
return parts[1];
}
if (
parts[0] ===
"crumbSaucerCollect"
) {
return parts[1];
}
return null;
}
/*
* ============================================================
* EXPORTS
* ============================================================
*/
module.exports = {
collectCookies,
buildCollectSuccessEmbed,
buildCollectCooldownEmbed,
showCollectPage,
collectCookiesFromSaucer,
COLLECT_COOLDOWN,
COLLECT_MIN,
COLLECT_MAX
};
+3148
View File
File diff suppressed because it is too large Load Diff
+131 -565
View File
File diff suppressed because it is too large Load Diff
+91 -1243
View File
File diff suppressed because it is too large Load Diff
+567 -175
View File
@@ -13,10 +13,17 @@ const {
} = require("./cookieEconomy"); } = require("./cookieEconomy");
const { const {
recordGameOutcome,
trackProfileEvent trackProfileEvent
} = require("./profileSystem"); } = require("./profileSystem");
const SLOT_BETS = [10, 50, 100, 500, 1000]; const SLOT_BETS = [
10,
50,
100,
500,
1000
];
const SLOT_SYMBOLS = [ const SLOT_SYMBOLS = [
"🍒", "🍒",
@@ -37,91 +44,178 @@ const SLOT_OUTCOMES = [
]; ];
async function showSlots(interaction) { async function showSlots(interaction) {
const ownerId = getSlotsSessionOwner(interaction); const ownerId =
getSlotsSessionOwner(
interaction
);
if (ownerId !== interaction.user.id) { if (
return temporaryEphemeralReply(interaction, { ownerId !==
content: "This isn't your Crumb Saucer session." interaction.user.id
}); ) {
return temporaryEphemeralReply(
interaction,
{
content:
"This isn't your Crumb Saucer session."
}
);
} }
const balance = await getCookieBalance(interaction.user.id); const balance =
await getCookieBalance(
interaction.user.id
);
const embed = new EmbedBuilder() const embed =
.setTitle("🎰 Crumb Saucer Slots") new EmbedBuilder()
.setDescription([ .setTitle(
"Welcome to the Cookie reels.", "🎰 Crumb Saucer Slots"
"", )
"Choose your wager below.", .setDescription(
"", [
"🍋 **Triple Lemon** — x1.5", "Welcome to the Cookie reels.",
"🍒 **Triple Cherry** — x2", "",
"🍪 **Triple Cookie** — x3", "Choose your wager below.",
"⭐ **Triple Star** — x5", "",
"💎 **Jackpot** — x10" "🍋 **Triple Lemon** — x1.5",
].join("\n")) "🍒 **Triple Cherry** — x2",
.addFields({ "🍪 **Triple Cookie** — x3",
name: "Your Vault", "⭐ **Triple Star** — x5",
value: `**${balance.toLocaleString()} 🍪**` "💎 **Jackpot** — x10"
}) ].join("\n")
.setFooter({ )
text: `crumbSaucerSlots:${interaction.user.id}` .addFields({
}); name:
"Your Vault",
value:
`**${balance.toLocaleString()} 🍪**`
})
.setFooter({
text:
`crumbSaucerSlots:${interaction.user.id}`
});
return interaction.update({ return interaction.update({
embeds: [embed], embeds: [embed],
components: buildSlotBetButtons() components:
buildSlotBetButtons()
}); });
} }
async function playSlots(interaction, bet) { async function playSlots(
const ownerId = getSlotsSessionOwner(interaction); interaction,
bet,
source = "normal",
previousAllIn = false,
previousBet = 0
) {
const ownerId =
getSlotsSessionOwner(
interaction
);
if (ownerId !== interaction.user.id) { if (
return temporaryEphemeralReply(interaction, { ownerId !==
content: "These aren't your slots. Open your own Crumb Saucer session." interaction.user.id
}); ) {
return temporaryEphemeralReply(
interaction,
{
content:
"These aren't your slots. Open your own Crumb Saucer session."
}
);
} }
if (!SLOT_BETS.includes(bet)) { if (!SLOT_BETS.includes(bet)) {
return temporaryEphemeralReply(interaction, { return temporaryEphemeralReply(
content: "That wager isn't available." interaction,
}); {
content:
"That wager isn't available."
}
);
} }
const playerId = interaction.user.id; const playerId =
const vaultId = interaction.client.user.id; interaction.user.id;
const [playerBalance, vaultBalance] = await Promise.all([ const vaultId =
interaction.client.user.id;
const [
playerBalance,
vaultBalance
] = await Promise.all([
getCookieBalance(playerId), getCookieBalance(playerId),
getCookieBalance(vaultId) getCookieBalance(vaultId)
]); ]);
if (playerBalance < bet) { if (playerBalance < bet) {
return temporaryEphemeralReply(interaction, { return temporaryEphemeralReply(
content: `You need **${bet.toLocaleString()} Cookies** for that wager.` interaction,
}); {
content:
`You need **${bet.toLocaleString()} Cookies** for that wager.`
}
);
} }
const maxPayout = bet * 10; const maxPayout =
bet * 10;
if (vaultBalance + bet < maxPayout) { if (
return temporaryEphemeralReply(interaction, { vaultBalance + bet <
content: "The Crumb Saucer vault doesn't currently have enough Cookies to cover that wager." maxPayout
}); ) {
return temporaryEphemeralReply(
interaction,
{
content:
"The Crumb Saucer vault doesn't currently have enough Cookies to cover that wager."
}
);
} }
try { try {
await transferCookies(playerId, vaultId, bet); await transferCookies(
playerId,
vaultId,
bet
);
} catch (error) { } catch (error) {
if (error.message === "INSUFFICIENT_COOKIES") { if (
return temporaryEphemeralReply(interaction, { error.message ===
content: "Your Cookie balance changed before the spin could begin." "INSUFFICIENT_COOKIES"
}); ) {
return temporaryEphemeralReply(
interaction,
{
content:
"Your Cookie balance changed before the spin could begin."
}
);
} }
throw error; throw error;
} }
await trackProfileEvent(
interaction,
playerId,
{
type: "game_start",
game: "slots",
source,
wager: bet,
allIn:
bet === playerBalance,
previousAllIn,
previousBet
}
);
await interaction.update({ await interaction.update({
embeds: [ embeds: [
buildSpinningEmbed( buildSpinningEmbed(
@@ -135,13 +229,18 @@ async function playSlots(interaction, bet) {
await sleep(500); await sleep(500);
const firstPreview = randomItem(SLOT_SYMBOLS); const firstPreview =
randomItem(SLOT_SYMBOLS);
await interaction.editReply({ await interaction.editReply({
embeds: [ embeds: [
buildSpinningEmbed( buildSpinningEmbed(
bet, bet,
[firstPreview, "❔", "❔"], [
firstPreview,
"❔",
"❔"
],
"The first reel stops..." "The first reel stops..."
) )
], ],
@@ -150,13 +249,18 @@ async function playSlots(interaction, bet) {
await sleep(500); await sleep(500);
const secondPreview = randomItem(SLOT_SYMBOLS); const secondPreview =
randomItem(SLOT_SYMBOLS);
await interaction.editReply({ await interaction.editReply({
embeds: [ embeds: [
buildSpinningEmbed( buildSpinningEmbed(
bet, bet,
[firstPreview, secondPreview, "❔"], [
firstPreview,
secondPreview,
"❔"
],
"One more..." "One more..."
) )
], ],
@@ -165,146 +269,355 @@ async function playSlots(interaction, bet) {
await sleep(600); await sleep(600);
const outcome = rollSlotOutcome(); const outcome =
const result = generateOutcomeLine(outcome); rollSlotOutcome();
const payout = Math.floor(bet * outcome.multiplier);
const result =
generateOutcomeLine(
outcome
);
const payout =
Math.floor(
bet *
outcome.multiplier
);
if (payout > 0) { if (payout > 0) {
try { try {
await transferCookies(vaultId, playerId, payout); await transferCookies(
vaultId,
playerId,
payout
);
} catch (error) { } catch (error) {
console.error("CRITICAL: Crumb Saucer Slots payout failed:", error); console.error(
"CRITICAL: Crumb Saucer Slots payout failed:",
error
);
return interaction.editReply({ return interaction.editReply({
embeds: [ embeds: [
new EmbedBuilder() new EmbedBuilder()
.setTitle("🎰 Crumb Saucer Error") .setTitle(
"🎰 Crumb Saucer Error"
)
.setDescription( .setDescription(
"The reels stopped, but the payout system had a problem. Please contact an administrator." "The reels stopped, but the payout system had a problem. Please contact an administrator."
) )
.setFooter({ .setFooter({
text: `crumbSaucerSlotsResult:${playerId}:${bet}` text:
`crumbSaucerSlotsResult:${playerId}:${bet}`
}) })
], ],
components: buildResultButtons() components:
buildResultButtons()
}); });
} }
} }
const [playerAfter, vaultAfter] = await Promise.all([ const [
playerAfter,
vaultAfter
] = await Promise.all([
getCookieBalance(playerId), getCookieBalance(playerId),
getCookieBalance(vaultId) getCookieBalance(vaultId)
]); ]);
const profit = payout - bet; const profit =
payout - bet;
await trackProfileEvent(interaction, playerId, { await recordGameOutcome(
type: "game_result", interaction,
game: "slots", playerId,
result: profit > 0 ? "win" : profit < 0 ? "loss" : "push" {
}); game:
"slots",
result:
profit > 0
? "win"
: profit < 0
? "loss"
: "push",
profit,
triplet:
outcome.type === "triple"
? outcome.symbol
: null,
jackpot:
outcome.type ===
"triple" &&
outcome.symbol ===
"💎"
}
);
if (profit > 0) { const resultEmbed =
await trackProfileEvent(interaction, playerId, { new EmbedBuilder()
type: "cookie_gain", .setTitle(
amount: profit "🎰 Crumb Saucer Slots"
}); )
} else if (profit < 0) { .setDescription(
await trackProfileEvent(interaction, playerId, { [
type: "cookie_loss", "## " +
amount: Math.abs(profit) result.join(" "),
}); "",
} `### ${getSlotResultName(outcome)}`
].join("\n")
const resultEmbed = new EmbedBuilder() )
.setTitle("🎰 Crumb Saucer Slots") .addFields(
.setDescription([ {
"## " + result.join(" "), name:
"", "Bet",
`### ${getSlotResultName(outcome)}` value:
].join("\n")) `${bet.toLocaleString()} 🍪`,
.addFields( inline:
{ name: "Bet", value: `${bet.toLocaleString()} 🍪`, inline: true }, true
{ name: "Multiplier", value: `x${outcome.multiplier}`, inline: true }, },
{ name: "Payout", value: `${payout.toLocaleString()} 🍪`, inline: true }, {
{ name: "Profit", value: `${profit >= 0 ? "+" : ""}${profit.toLocaleString()} 🍪`, inline: true }, name:
{ name: "Your Vault", value: `${playerAfter.toLocaleString()} 🍪`, inline: true }, "Multiplier",
{ name: "House Vault", value: `${vaultAfter.toLocaleString()} 🍪`, inline: true } value:
) `x${outcome.multiplier}`,
.setFooter({ inline:
text: `crumbSaucerSlotsResult:${playerId}:${bet}` true
}); },
{
name:
"Payout",
value:
`${payout.toLocaleString()} 🍪`,
inline:
true
},
{
name:
"Profit",
value:
`${profit >= 0 ? "+" : ""}${profit.toLocaleString()} 🍪`,
inline:
true
},
{
name:
"Your Vault",
value:
`${playerAfter.toLocaleString()} 🍪`,
inline:
true
},
{
name:
"House Vault",
value:
`${vaultAfter.toLocaleString()} 🍪`,
inline:
true
}
)
.setFooter({
text:
`crumbSaucerSlotsResult:${playerId}:${bet}`
});
return interaction.editReply({ return interaction.editReply({
embeds: [resultEmbed], embeds: [resultEmbed],
components: buildResultButtons() components:
buildResultButtons()
}); });
} }
async function runSlotsBack(interaction) { async function runSlotsBack(
const footer = interaction.message?.embeds?.[0]?.footer?.text; interaction
const parts = footer?.split(":") ?? []; ) {
const footer =
interaction.message
?.embeds?.[0]
?.footer
?.text;
if (parts[0] !== "crumbSaucerSlotsResult") { const parts =
return temporaryEphemeralReply(interaction, { footer?.split(":") ?? [];
content: "This Slots result can no longer be replayed."
}); if (
parts[0] !==
"crumbSaucerSlotsResult"
) {
return temporaryEphemeralReply(
interaction,
{
content:
"This Slots result can no longer be replayed."
}
);
} }
const ownerId = parts[1]; const ownerId =
const bet = Number(parts[2]); parts[1];
if (ownerId !== interaction.user.id) { const bet =
return temporaryEphemeralReply(interaction, { Number(parts[2]);
content: "These aren't your slots."
}); if (
ownerId !==
interaction.user.id
) {
return temporaryEphemeralReply(
interaction,
{
content:
"These aren't your slots."
}
);
} }
if (!SLOT_BETS.includes(bet)) { if (!SLOT_BETS.includes(bet)) {
return temporaryEphemeralReply(interaction, { return temporaryEphemeralReply(
content: "The previous wager is no longer available." interaction,
}); {
content:
"The previous wager is no longer available."
}
);
} }
return playSlots(interaction, bet); return playSlots(
interaction,
bet,
"run_it_back"
);
} }
function buildSlotBetButtons() { function buildSlotBetButtons() {
return [ return [
new ActionRowBuilder().addComponents( new ActionRowBuilder()
new ButtonBuilder().setCustomId("crumbSaucerSlots10").setLabel("10").setEmoji("🍪").setStyle(ButtonStyle.Secondary), .addComponents(
new ButtonBuilder().setCustomId("crumbSaucerSlots50").setLabel("50").setEmoji("🍪").setStyle(ButtonStyle.Secondary), new ButtonBuilder()
new ButtonBuilder().setCustomId("crumbSaucerSlots100").setLabel("100").setEmoji("🍪").setStyle(ButtonStyle.Primary), .setCustomId(
new ButtonBuilder().setCustomId("crumbSaucerSlots500").setLabel("500").setEmoji("🍪").setStyle(ButtonStyle.Primary), "crumbSaucerSlots10"
new ButtonBuilder().setCustomId("crumbSaucerSlots1000").setLabel("1,000").setEmoji("🍪").setStyle(ButtonStyle.Danger) )
), .setLabel("10")
new ActionRowBuilder().addComponents( .setEmoji("🍪")
new ButtonBuilder().setCustomId("crumbSaucerBack").setLabel("Back to Crumb Saucer").setEmoji("◀️").setStyle(ButtonStyle.Secondary) .setStyle(
) ButtonStyle.Secondary
),
new ButtonBuilder()
.setCustomId(
"crumbSaucerSlots50"
)
.setLabel("50")
.setEmoji("🍪")
.setStyle(
ButtonStyle.Secondary
),
new ButtonBuilder()
.setCustomId(
"crumbSaucerSlots100"
)
.setLabel("100")
.setEmoji("🍪")
.setStyle(
ButtonStyle.Primary
),
new ButtonBuilder()
.setCustomId(
"crumbSaucerSlots500"
)
.setLabel("500")
.setEmoji("🍪")
.setStyle(
ButtonStyle.Primary
),
new ButtonBuilder()
.setCustomId(
"crumbSaucerSlots1000"
)
.setLabel("1,000")
.setEmoji("🍪")
.setStyle(
ButtonStyle.Danger
)
),
new ActionRowBuilder()
.addComponents(
new ButtonBuilder()
.setCustomId(
"crumbSaucerBack"
)
.setLabel(
"Back to Crumb Saucer"
)
.setEmoji("◀️")
.setStyle(
ButtonStyle.Secondary
)
)
]; ];
} }
function buildResultButtons() { function buildResultButtons() {
return [ return [
new ActionRowBuilder().addComponents( new ActionRowBuilder()
new ButtonBuilder().setCustomId("crumbSaucerSlotsRunItBack").setLabel("Run It Back").setEmoji("🔁").setStyle(ButtonStyle.Success), .addComponents(
new ButtonBuilder().setCustomId("crumbSaucerSlots").setLabel("Change Bet").setEmoji("🎰").setStyle(ButtonStyle.Primary), new ButtonBuilder()
new ButtonBuilder().setCustomId("crumbSaucerBack").setLabel("Crumb Saucer").setEmoji("◀️").setStyle(ButtonStyle.Secondary) .setCustomId(
) "crumbSaucerSlotsRunItBack"
)
.setLabel(
"Run It Back"
)
.setEmoji("🔁")
.setStyle(
ButtonStyle.Success
),
new ButtonBuilder()
.setCustomId(
"crumbSaucerSlots"
)
.setLabel(
"Change Bet"
)
.setEmoji("🎰")
.setStyle(
ButtonStyle.Primary
),
new ButtonBuilder()
.setCustomId(
"crumbSaucerBack"
)
.setLabel(
"Crumb Saucer"
)
.setEmoji("◀️")
.setStyle(
ButtonStyle.Secondary
)
)
]; ];
} }
function rollSlotOutcome() { function rollSlotOutcome() {
const totalWeight = SLOT_OUTCOMES.reduce( const totalWeight =
(total, outcome) => total + outcome.weight, SLOT_OUTCOMES.reduce(
0 (total, outcome) =>
); total +
outcome.weight,
0
);
let roll = Math.random() * totalWeight; let roll =
Math.random() *
totalWeight;
for (const outcome of SLOT_OUTCOMES) { for (const outcome of SLOT_OUTCOMES) {
roll -= outcome.weight; roll -= outcome.weight;
if (roll <= 0) return outcome;
if (roll <= 0) {
return outcome;
}
} }
return SLOT_OUTCOMES[0]; return SLOT_OUTCOMES[0];
@@ -318,8 +631,13 @@ function generateRandomLine() {
]; ];
} }
function generateOutcomeLine(outcome) { function generateOutcomeLine(
if (outcome.type === "triple") { outcome
) {
if (
outcome.type ===
"triple"
) {
return [ return [
outcome.symbol, outcome.symbol,
outcome.symbol, outcome.symbol,
@@ -327,69 +645,143 @@ function generateOutcomeLine(outcome) {
]; ];
} }
if (outcome.type === "pair") { if (
const symbol = randomItem(SLOT_SYMBOLS); outcome.type ===
"pair"
) {
const symbol =
randomItem(SLOT_SYMBOLS);
let other; let other;
do { do {
other = randomItem(SLOT_SYMBOLS); other =
randomItem(SLOT_SYMBOLS);
} while (other === symbol); } while (other === symbol);
const result = [symbol, symbol, symbol]; const result = [
result[Math.floor(Math.random() * 3)] = other; symbol,
symbol,
symbol
];
result[
Math.floor(
Math.random() * 3
)
] = other;
return result; return result;
} }
let result; let result;
do { do {
result = generateRandomLine(); result =
} while (new Set(result).size < 3); generateRandomLine();
} while (
new Set(result).size < 3
);
return result; return result;
} }
function buildSpinningEmbed(bet, symbols, status) { function buildSpinningEmbed(
bet,
symbols,
status
) {
return new EmbedBuilder() return new EmbedBuilder()
.setTitle("🎰 Crumb Saucer Slots") .setTitle(
.setDescription([ "🎰 Crumb Saucer Slots"
"## " + symbols.join(" "), )
"", .setDescription(
`**${status}**` [
].join("\n")) "## " +
symbols.join(" "),
"",
`**${status}**`
].join("\n")
)
.addFields({ .addFields({
name: "Bet", name:
value: `**${bet.toLocaleString()} 🍪**` "Bet",
value:
`**${bet.toLocaleString()} 🍪**`
}); });
} }
function getSlotResultName(outcome) { function getSlotResultName(
if (outcome.type === "loss") return "No luck this time."; outcome
if (outcome.type === "pair") return "Pair!"; ) {
if (outcome.type === "loss") {
return "No luck this time.";
}
if (outcome.type === "pair") {
return "Pair!";
}
switch (outcome.symbol) { switch (outcome.symbol) {
case "🍋": return "🍋 Triple Lemon!"; case "🍋":
case "🍒": return "🍒 Triple Cherry!"; return "🍋 Triple Lemon!";
case "🍪": return "🍪 Triple Cookie!"; case "🍒":
case "⭐": return "⭐ Triple Star!"; return "🍒 Triple Cherry!";
case "💎": return "💎 JACKPOT! 💎"; case "🍪":
default: return "Winner!"; return "🍪 Triple Cookie!";
case "⭐":
return "⭐ Triple Star!";
case "💎":
return "💎 JACKPOT! 💎";
default:
return "Winner!";
} }
} }
function getSlotsSessionOwner(interaction) { function getSlotsSessionOwner(
const footer = interaction.message?.embeds?.[0]?.footer?.text; interaction
const parts = footer?.split(":") ?? []; ) {
const footer =
interaction.message
?.embeds?.[0]
?.footer
?.text;
if (parts[0] === "crumbSaucer") return parts[1]; const parts =
if (parts[0] === "crumbSaucerSlots") return parts[1]; footer?.split(":") ?? [];
if (parts[0] === "crumbSaucerSlotsResult") return parts[1];
if (
parts[0] ===
"crumbSaucer"
) {
return parts[1];
}
if (
parts[0] ===
"crumbSaucerSlots"
) {
return parts[1];
}
if (
parts[0] ===
"crumbSaucerSlotsResult"
) {
return parts[1];
}
return null; return null;
} }
function sleep(ms) { function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms)); return new Promise(
resolve =>
setTimeout(
resolve,
ms
)
);
} }
module.exports = { module.exports = {
+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
};
+103
View File
@@ -0,0 +1,103 @@
const {
PermissionFlagsBits,
MessageFlags
} = require("discord.js");
function getBotAuthorId() {
return process.env.BOT_AUTHOR_ID ?? null;
}
function isBotAuthor(
userId
) {
const authorId =
getBotAuthorId();
return Boolean(
authorId &&
userId &&
String(userId) === String(authorId)
);
}
function isAdministrator(
interaction
) {
return (
interaction
?.memberPermissions
?.has(
PermissionFlagsBits.Administrator
)
??
false
);
}
function canUseAdminCommands(
interaction
) {
return (
isBotAuthor(
interaction.user.id
)
||
isAdministrator(
interaction
)
);
}
async function requireAdminAccess(
interaction
) {
if (
canUseAdminCommands(
interaction
)
) {
return true;
}
const payload = {
content:
"You don't have permission to use this command.",
flags:
MessageFlags.Ephemeral
};
if (
interaction.replied ||
interaction.deferred
) {
await interaction.followUp(
payload
);
}
else {
await interaction.reply(
payload
);
}
return false;
}
module.exports = {
getBotAuthorId,
isBotAuthor,
isAdministrator,
canUseAdminCommands,
requireAdminAccess
};
File diff suppressed because it is too large Load Diff
+2149
View File
File diff suppressed because it is too large Load Diff
+198
View File
@@ -0,0 +1,198 @@
const {
getPokerService
} = require("./crumbPoker");
const {
deferPokerReply,
editPokerError,
getPokerGameIdFromInteraction,
refreshPokerTableMessage
} = require("./pokerDiscord");
const {
announcePokerHandProgress
} = require("./pokerProfile");
async function executePokerAction(
interaction,
action,
successText
) {
await deferPokerReply(
interaction
);
const gameId =
getPokerGameIdFromInteraction(
interaction
);
if (!gameId) {
return interaction.editReply({
content:
"❌ This Poker control is no longer attached to an active table.",
embeds: [],
components: []
});
}
try {
const service =
getPokerService();
const game =
await service.act({
gameId,
userId:
interaction.user.id,
action
});
await refreshPokerTableMessage(
interaction.client,
game
);
try {
await announcePokerHandProgress(
interaction,
game
);
}
catch (error) {
console.error(
`[PokerProfile] Failed to announce completed hand ${game.gameId}:`,
error
);
}
return interaction.editReply({
content:
typeof successText ===
"function"
? successText(game)
: successText,
embeds: [],
components: []
});
}
catch (error) {
return editPokerError(
interaction,
error
);
}
}
async function executeMinimumRaise(
interaction
) {
await deferPokerReply(
interaction
);
const gameId =
getPokerGameIdFromInteraction(
interaction
);
if (!gameId) {
return interaction.editReply({
content:
"❌ This Poker control is no longer attached to an active table.",
embeds: [],
components: []
});
}
try {
const service =
getPokerService();
const legal =
await service.getLegalPlayerActions({
gameId,
userId:
interaction.user.id
});
const type =
legal.actions?.includes(
"bet"
)
? "bet"
: legal.actions?.includes(
"raise"
)
? "raise"
: null;
if (
!type
||
!Number.isSafeInteger(
legal.minRaiseTo
)
) {
return interaction.editReply({
content:
"❌ A bet or raise is not available right now.",
embeds: [],
components: []
});
}
const game =
await service.act({
gameId,
userId:
interaction.user.id,
action: {
type,
amount:
legal.minRaiseTo
}
});
await refreshPokerTableMessage(
interaction.client,
game
);
try {
await announcePokerHandProgress(
interaction,
game
);
}
catch (error) {
console.error(
`[PokerProfile] Failed to announce completed hand ${game.gameId}:`,
error
);
}
return interaction.editReply({
content:
`✅ ${type === "bet" ? "Bet" : "Raised to"} ${legal.minRaiseTo.toLocaleString()} 🍪`,
embeds: [],
components: []
});
}
catch (error) {
return editPokerError(
interaction,
error
);
}
}
module.exports = {
executePokerAction,
executeMinimumRaise
};
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+225
View File
@@ -0,0 +1,225 @@
const {
EmbedBuilder
} = require("discord.js");
const {
getProfileCollections
} = require("./profileSystem");
const {
getCollections
} = require("./cookieEconomy");
const RECORDS = {
balance: {
label:
"🍪 Current Balance",
source:
"cookies",
field:
"cookies",
format:
value =>
`${Number(value ?? 0).toLocaleString()} 🍪`
},
obtained: {
label:
"🍪 Lifetime Cookies Obtained",
field:
"economy.cookiesObtainedTotal",
format:
value =>
`${Number(value ?? 0).toLocaleString()} 🍪`
},
given: {
label:
"🎁 Cookies Given",
field:
"economy.cookiesGivenTotal",
format:
value =>
`${Number(value ?? 0).toLocaleString()} 🍪`
},
stolen: {
label:
"🥷 Cookies Stolen",
field:
"economy.stolenTotal",
format:
value =>
`${Number(value ?? 0).toLocaleString()} 🍪`
},
vault: {
label:
"🏦 Bot Vault Stolen",
field:
"economy.vaultStolenTotal",
format:
value =>
`${Number(value ?? 0).toLocaleString()} 🍪`
},
wins: {
label:
"🏆 Games Won",
field:
"games.total.won",
format:
value =>
Number(value ?? 0).toLocaleString()
},
played: {
label:
"🎮 Games Played",
field:
"games.total.played",
format:
value =>
Number(value ?? 0).toLocaleString()
},
streak: {
label:
"🔥 Best Win Streak",
field:
"streaks.bestWin",
format:
value =>
Number(value ?? 0).toLocaleString()
},
lost: {
label:
"💸 Lifetime Cookies Lost",
field:
"economy.cookiesLostTotal",
format:
value =>
`${Number(value ?? 0).toLocaleString()} 🍪`
}
};
function getNestedValue(
document,
path
) {
return path
.split(".")
.reduce(
(value, key) =>
value?.[key],
document
);
}
async function buildRecordsEmbed({
guildId,
category,
botUserId,
limit = 10
}) {
const definition =
RECORDS[category] ??
RECORDS.balance;
let rows;
if (
definition.source ===
"cookies"
) {
const {
cookies
} = getCollections();
rows =
await cookies
.find({
userId: {
$ne: botUserId
},
cookies: {
$gt: 0
}
})
.sort({
cookies: -1
})
.limit(limit)
.toArray();
}
else {
const {
stats
} = getProfileCollections();
rows =
await stats
.find({
guildId,
userId: {
$ne: botUserId
},
[definition.field]: {
$gt: 0
}
})
.sort({
[definition.field]: -1
})
.limit(limit)
.toArray();
}
const medals = [
"🥇",
"🥈",
"🥉"
];
const description =
rows.length > 0
? rows.map(
(row, index) => {
const value =
getNestedValue(
row,
definition.field
);
const rank =
medals[index] ??
`**${index + 1}.**`;
return `${rank} <@${row.userId}> — **${definition.format(value)}**`;
}
).join("\n")
: "No qualifying records yet.";
return new EmbedBuilder()
.setTitle(
`📚 Records — ${definition.label}`
)
.setDescription(
description
)
.setFooter({
text:
"Records are derived from persisted profile statistics."
})
.setTimestamp();
}
function getRecordChoices() {
return Object.entries(RECORDS)
.map(
([value, definition]) => ({
name:
definition.label,
value
})
);
}
module.exports = {
buildRecordsEmbed,
getRecordChoices
};
+3793 -235
View File
File diff suppressed because it is too large Load Diff