Compare commits
32 Commits
81ef97370c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6e3e1620d1 | |||
| dd4d6e4817 | |||
| b441de286c | |||
| 2ca18aae06 | |||
| 1ef2295c07 | |||
| 6342c4569b | |||
| c61e34c241 | |||
| 82cef1b34a | |||
| 136a5902c0 | |||
| 903a2f3081 | |||
| 995a2317b9 | |||
| e3dbb11e12 | |||
| 14a059034c | |||
| 9710fc65bf | |||
| 42fc848f8c | |||
| c2ac7737c0 | |||
| 6a519f3a2d | |||
| 338b32430d | |||
| 9f1a539501 | |||
| 425686ec40 | |||
| 4d858554be | |||
| 9c867b1adb | |||
| 030b4b8883 | |||
| 08d1a8c5cd | |||
| 9e0870c1ed | |||
| ca823726e3 | |||
| ae35f61522 | |||
| 985f481fae | |||
| e437c051b9 | |||
| c630543f9e | |||
| 9847e476b8 | |||
| 8aeab3fb5d |
@@ -1,2 +1,5 @@
|
|||||||
node_modules
|
node_modules
|
||||||
.env
|
.env
|
||||||
|
backup/
|
||||||
|
*.bak
|
||||||
|
tests/
|
||||||
@@ -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. 🍪
|
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
const {
|
||||||
|
closeInteractionSession
|
||||||
|
} = require("../../utils/interactionSession");
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
name: "sessionClose",
|
||||||
|
|
||||||
|
async execute(interaction) {
|
||||||
|
return closeInteractionSession(interaction);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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 {}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
const {
|
||||||
|
collectCookiesFromSaucer
|
||||||
|
} = require(
|
||||||
|
"../../utils/crumbCollect"
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
name:
|
||||||
|
"crumbSaucerCollect",
|
||||||
|
|
||||||
|
|
||||||
|
async execute(
|
||||||
|
interaction
|
||||||
|
) {
|
||||||
|
return collectCookiesFromSaucer(
|
||||||
|
interaction
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
const {
|
||||||
|
showCollectPage
|
||||||
|
} = require(
|
||||||
|
"../../utils/crumbCollect"
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
name:
|
||||||
|
"crumbSaucerCollectMenu",
|
||||||
|
|
||||||
|
|
||||||
|
async execute(
|
||||||
|
interaction
|
||||||
|
) {
|
||||||
|
return showCollectPage(
|
||||||
|
interaction
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
const {
|
const {
|
||||||
showSlots
|
showSlots
|
||||||
} = require(
|
} = require(
|
||||||
"../../utils/crumbSaucer"
|
"../../utils/crumbSlots.js"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
const {
|
const {
|
||||||
playSlots
|
playSlots
|
||||||
} = require(
|
} = require(
|
||||||
"../../utils/crumbSaucer"
|
"../../utils/crumbSlots.js"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
const {
|
const {
|
||||||
playSlots
|
playSlots
|
||||||
} = require(
|
} = require(
|
||||||
"../../utils/crumbSaucer"
|
"../../utils/crumbSlots.js"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
const {
|
const {
|
||||||
playSlots
|
playSlots
|
||||||
} = require(
|
} = require(
|
||||||
"../../utils/crumbSaucer"
|
"../../utils/crumbSlots.js"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
const {
|
const {
|
||||||
playSlots
|
playSlots
|
||||||
} = require(
|
} = require(
|
||||||
"../../utils/crumbSaucer"
|
"../../utils/crumbSlots.js"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
const {
|
const {
|
||||||
playSlots
|
playSlots
|
||||||
} = require(
|
} = require(
|
||||||
"../../utils/crumbSaucer"
|
"../../utils/crumbSlots.js"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
const {
|
||||||
|
runSlotsBack
|
||||||
|
} = require(
|
||||||
|
"../../utils/crumbSlots.js"
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
name:
|
||||||
|
"crumbSaucerSlotsRunItBack",
|
||||||
|
|
||||||
|
async execute(
|
||||||
|
interaction
|
||||||
|
) {
|
||||||
|
return runSlotsBack(
|
||||||
|
interaction
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
const {
|
||||||
|
executePokerAction
|
||||||
|
} = require("../../utils/pokerInteractions");
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
name: "pokerAllIn",
|
||||||
|
|
||||||
|
async execute(interaction) {
|
||||||
|
return executePokerAction(
|
||||||
|
interaction,
|
||||||
|
{
|
||||||
|
type: "all_in"
|
||||||
|
},
|
||||||
|
"✅ All-In."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
const {
|
||||||
|
executePokerAction
|
||||||
|
} = require("../../utils/pokerInteractions");
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
name: "pokerCall",
|
||||||
|
|
||||||
|
async execute(interaction) {
|
||||||
|
return executePokerAction(
|
||||||
|
interaction,
|
||||||
|
{
|
||||||
|
type: "call"
|
||||||
|
},
|
||||||
|
"✅ Called."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
const {
|
||||||
|
executePokerAction
|
||||||
|
} = require("../../utils/pokerInteractions");
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
name: "pokerCheck",
|
||||||
|
|
||||||
|
async execute(interaction) {
|
||||||
|
return executePokerAction(
|
||||||
|
interaction,
|
||||||
|
{
|
||||||
|
type: "check"
|
||||||
|
},
|
||||||
|
"✅ Checked."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
const {
|
||||||
|
executePokerAction
|
||||||
|
} = require("../../utils/pokerInteractions");
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
name: "pokerFold",
|
||||||
|
|
||||||
|
async execute(interaction) {
|
||||||
|
return executePokerAction(
|
||||||
|
interaction,
|
||||||
|
{
|
||||||
|
type: "fold"
|
||||||
|
},
|
||||||
|
"✅ Folded."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
const {
|
||||||
|
executeMinimumRaise
|
||||||
|
} = require("../../utils/pokerInteractions");
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
name: "pokerRaise",
|
||||||
|
|
||||||
|
async execute(interaction) {
|
||||||
|
return executeMinimumRaise(
|
||||||
|
interaction
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
const {
|
||||||
|
buildAchievementsEmbed,
|
||||||
|
buildProfileButtons,
|
||||||
|
getProfileContextFromFooter,
|
||||||
|
requireProfileOwner
|
||||||
|
} = require(
|
||||||
|
"../../utils/profileSystem"
|
||||||
|
);
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
name:
|
||||||
|
"profileAchievements",
|
||||||
|
|
||||||
|
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,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
const pageContext =
|
||||||
|
getProfileContextFromFooter(
|
||||||
|
embed.data.footer?.text
|
||||||
|
);
|
||||||
|
|
||||||
|
return interaction.update({
|
||||||
|
embeds: [embed],
|
||||||
|
components:
|
||||||
|
buildProfileButtons(
|
||||||
|
"achievements",
|
||||||
|
pageContext?.page ?? 0,
|
||||||
|
pageContext?.pageCount ?? 1
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
const {
|
||||||
|
buildBadgesEmbed,
|
||||||
|
buildProfileButtons,
|
||||||
|
getProfileContextFromFooter,
|
||||||
|
requireProfileOwner
|
||||||
|
} = require(
|
||||||
|
"../../utils/profileSystem"
|
||||||
|
);
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
name:
|
||||||
|
"profileBadges",
|
||||||
|
|
||||||
|
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,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
const pageContext =
|
||||||
|
getProfileContextFromFooter(
|
||||||
|
embed.data.footer?.text
|
||||||
|
);
|
||||||
|
|
||||||
|
return interaction.update({
|
||||||
|
embeds: [embed],
|
||||||
|
components:
|
||||||
|
buildProfileButtons(
|
||||||
|
"badges",
|
||||||
|
pageContext?.page ?? 0,
|
||||||
|
pageContext?.pageCount ?? 1
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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 {}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
const {
|
||||||
|
buildProfileOverview,
|
||||||
|
buildProfileButtons,
|
||||||
|
requireProfileOwner
|
||||||
|
} = require(
|
||||||
|
"../../utils/profileSystem"
|
||||||
|
);
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
name:
|
||||||
|
"profileOverview",
|
||||||
|
|
||||||
|
async execute(
|
||||||
|
interaction
|
||||||
|
) {
|
||||||
|
const context =
|
||||||
|
await requireProfileOwner(
|
||||||
|
interaction
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const user =
|
||||||
|
await interaction.client.users.fetch(
|
||||||
|
context.profileUserId
|
||||||
|
);
|
||||||
|
|
||||||
|
const embed =
|
||||||
|
await buildProfileOverview(
|
||||||
|
interaction.guildId,
|
||||||
|
user,
|
||||||
|
context.sessionOwnerId
|
||||||
|
);
|
||||||
|
|
||||||
|
return interaction.update({
|
||||||
|
embeds: [embed],
|
||||||
|
components:
|
||||||
|
buildProfileButtons(
|
||||||
|
"overview",
|
||||||
|
0,
|
||||||
|
1
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
+880
-866
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,63 @@
|
|||||||
|
const {
|
||||||
|
SlashCommandBuilder
|
||||||
|
} = require(
|
||||||
|
"discord.js"
|
||||||
|
);
|
||||||
|
|
||||||
|
const {
|
||||||
|
buildProfileOverview,
|
||||||
|
buildProfileButtons
|
||||||
|
} = require(
|
||||||
|
"../../utils/profileSystem"
|
||||||
|
);
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
data:
|
||||||
|
new SlashCommandBuilder()
|
||||||
|
.setName(
|
||||||
|
"profile"
|
||||||
|
)
|
||||||
|
.setDescription(
|
||||||
|
"View a player profile"
|
||||||
|
)
|
||||||
|
.addUserOption(
|
||||||
|
option =>
|
||||||
|
option
|
||||||
|
.setName(
|
||||||
|
"user"
|
||||||
|
)
|
||||||
|
.setDescription(
|
||||||
|
"Player to view"
|
||||||
|
)
|
||||||
|
),
|
||||||
|
|
||||||
|
async execute(
|
||||||
|
interaction
|
||||||
|
) {
|
||||||
|
const user =
|
||||||
|
interaction.options.getUser(
|
||||||
|
"user"
|
||||||
|
) ??
|
||||||
|
interaction.user;
|
||||||
|
|
||||||
|
const sessionOwnerId =
|
||||||
|
interaction.user.id;
|
||||||
|
|
||||||
|
const embed =
|
||||||
|
await buildProfileOverview(
|
||||||
|
interaction.guildId,
|
||||||
|
user,
|
||||||
|
sessionOwnerId
|
||||||
|
);
|
||||||
|
|
||||||
|
return interaction.reply({
|
||||||
|
embeds: [embed],
|
||||||
|
components:
|
||||||
|
buildProfileButtons(
|
||||||
|
"overview",
|
||||||
|
0,
|
||||||
|
1
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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."
|
||||||
|
)
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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
@@ -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
@@ -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.
|
||||||
@@ -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.
|
||||||
+61
-153
@@ -3,8 +3,11 @@ const {
|
|||||||
MessageFlags
|
MessageFlags
|
||||||
} = require("discord.js");
|
} = require("discord.js");
|
||||||
|
|
||||||
require("dotenv").config();
|
const {
|
||||||
|
guardSessionInteraction
|
||||||
|
} = require("../utils/interactionSession");
|
||||||
|
|
||||||
|
require("dotenv").config();
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
name: Events.InteractionCreate,
|
name: Events.InteractionCreate,
|
||||||
@@ -25,7 +28,8 @@ module.exports = {
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
interaction.isMentionableSelectMenu() ||
|
interaction.isMentionableSelectMenu() ||
|
||||||
interaction.isUserSelectMenu()
|
interaction.isUserSelectMenu() ||
|
||||||
|
interaction.isStringSelectMenu()
|
||||||
) {
|
) {
|
||||||
return selectMenuHandler(interaction);
|
return selectMenuHandler(interaction);
|
||||||
}
|
}
|
||||||
@@ -36,203 +40,110 @@ module.exports = {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
async function autocompleteHandler(interaction) {
|
async function autocompleteHandler(interaction) {
|
||||||
const command =
|
const command = interaction.client.commands.get(
|
||||||
interaction.client.commands.get(
|
|
||||||
interaction.commandName
|
interaction.commandName
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
if (!command) {
|
if (!command) {
|
||||||
return console.error(
|
return console.error(
|
||||||
`No command matching ${interaction.commandName} was found.`
|
`No command matching ${interaction.commandName} was found.`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (typeof command.autocomplete !== "function") {
|
||||||
if (
|
|
||||||
typeof command.autocomplete !==
|
|
||||||
"function"
|
|
||||||
) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await command.autocomplete(
|
await command.autocomplete(interaction);
|
||||||
interaction
|
|
||||||
);
|
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
console.error(
|
||||||
`Autocomplete error for /${interaction.commandName}:`,
|
`Autocomplete error for /${interaction.commandName}:`,
|
||||||
error
|
error
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Autocomplete cannot use normal interaction.reply().
|
|
||||||
* If something failed, send an empty set so Discord
|
|
||||||
* stops waiting.
|
|
||||||
*/
|
|
||||||
try {
|
try {
|
||||||
await interaction.respond([]);
|
await interaction.respond([]);
|
||||||
} catch {
|
} catch {}
|
||||||
// Interaction may already be acknowledged or expired.
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async function commandHandler(interaction) {
|
async function commandHandler(interaction) {
|
||||||
const command =
|
const command = interaction.client.commands.get(
|
||||||
interaction.client.commands.get(
|
|
||||||
interaction.commandName
|
interaction.commandName
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
if (!command) {
|
if (!command) {
|
||||||
return console.error(
|
return console.error(
|
||||||
`No command matching ${interaction.commandName} was found.`
|
`No command matching ${interaction.commandName} was found.`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await command.execute(
|
await command.execute(interaction);
|
||||||
interaction
|
|
||||||
);
|
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
|
await sendInteractionError(
|
||||||
if (
|
interaction,
|
||||||
interaction.replied ||
|
"There was an error while executing this command!"
|
||||||
interaction.deferred
|
);
|
||||||
) {
|
|
||||||
await interaction.followUp({
|
|
||||||
content:
|
|
||||||
"There was an error while executing this command!",
|
|
||||||
flags:
|
|
||||||
MessageFlags.Ephemeral
|
|
||||||
});
|
|
||||||
|
|
||||||
} else {
|
|
||||||
await interaction.reply({
|
|
||||||
content:
|
|
||||||
"There was an error while executing this command!",
|
|
||||||
flags:
|
|
||||||
MessageFlags.Ephemeral
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async function buttonHandler(interaction) {
|
async function buttonHandler(interaction) {
|
||||||
const button =
|
const allowed = await guardSessionInteraction(interaction);
|
||||||
interaction.client.buttons.get(
|
|
||||||
|
if (!allowed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const button = interaction.client.buttons.get(
|
||||||
interaction.customId
|
interaction.customId
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
if (!button) {
|
if (!button) {
|
||||||
return console.error(
|
return console.error(
|
||||||
`No button registered matching ${interaction.customId}.`
|
`No button registered matching ${interaction.customId}.`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await button.execute(
|
await button.execute(interaction);
|
||||||
interaction
|
|
||||||
);
|
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
|
await sendInteractionError(
|
||||||
if (
|
interaction,
|
||||||
interaction.replied ||
|
"There was an error while executing this button!"
|
||||||
interaction.deferred
|
);
|
||||||
) {
|
|
||||||
await interaction.followUp({
|
|
||||||
content:
|
|
||||||
"There was an error while executing this button!",
|
|
||||||
flags:
|
|
||||||
MessageFlags.Ephemeral
|
|
||||||
});
|
|
||||||
|
|
||||||
} else {
|
|
||||||
await interaction.reply({
|
|
||||||
content:
|
|
||||||
"There was an error while executing this button!",
|
|
||||||
flags:
|
|
||||||
MessageFlags.Ephemeral
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async function selectMenuHandler(interaction) {
|
async function selectMenuHandler(interaction) {
|
||||||
const selectMenu =
|
const selectMenu = interaction.client.selectMenus.get(
|
||||||
interaction.client.selectMenus.get(
|
|
||||||
interaction.customId
|
interaction.customId
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
if (!selectMenu) {
|
if (!selectMenu) {
|
||||||
return console.error(
|
return console.error(
|
||||||
`No Select Menu registered matching ${interaction.customId}.`
|
`No Select Menu registered matching ${interaction.customId}.`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await selectMenu.execute(
|
await selectMenu.execute(interaction);
|
||||||
interaction
|
|
||||||
);
|
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
|
await sendInteractionError(
|
||||||
if (
|
interaction,
|
||||||
interaction.replied ||
|
"There was an error while executing this select menu!"
|
||||||
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) {
|
async function modalHandler(interaction) {
|
||||||
const customId =
|
const customId = interaction.customId.split(":")[0];
|
||||||
interaction.customId
|
const modal = interaction.client.modals.get(customId);
|
||||||
.split(":")[0];
|
|
||||||
|
|
||||||
|
|
||||||
const modal =
|
|
||||||
interaction.client
|
|
||||||
.modals
|
|
||||||
.get(
|
|
||||||
customId
|
|
||||||
);
|
|
||||||
|
|
||||||
|
|
||||||
if (!modal) {
|
if (!modal) {
|
||||||
return console.error(
|
return console.error(
|
||||||
@@ -240,36 +151,33 @@ async function modalHandler(interaction) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await modal.execute(
|
await modal.execute(interaction);
|
||||||
interaction
|
|
||||||
);
|
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
|
await sendInteractionError(
|
||||||
|
interaction,
|
||||||
if (
|
"There was an error while executing this modal!"
|
||||||
interaction.replied ||
|
);
|
||||||
interaction.deferred
|
}
|
||||||
) {
|
}
|
||||||
await interaction.followUp({
|
|
||||||
content:
|
async function sendInteractionError(interaction, content) {
|
||||||
"There was an error while executing this modal!",
|
const payload = {
|
||||||
|
content,
|
||||||
flags:
|
flags: MessageFlags.Ephemeral
|
||||||
MessageFlags.Ephemeral
|
};
|
||||||
});
|
|
||||||
|
try {
|
||||||
} else {
|
if (interaction.replied || interaction.deferred) {
|
||||||
await interaction.reply({
|
await interaction.followUp(payload);
|
||||||
content:
|
} else {
|
||||||
"There was an error while executing this modal!",
|
await interaction.reply(payload);
|
||||||
|
}
|
||||||
flags:
|
} catch (error) {
|
||||||
MessageFlags.Ephemeral
|
console.error(
|
||||||
});
|
"Failed to send interaction error response:",
|
||||||
}
|
error
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,34 +5,18 @@ const {
|
|||||||
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(
|
||||||
/*
|
|
||||||
* MONGODB
|
|
||||||
*/
|
|
||||||
|
|
||||||
const mClient =
|
|
||||||
new MongoClient(
|
|
||||||
process.env.M_URI
|
process.env.M_URI
|
||||||
);
|
);
|
||||||
|
|
||||||
exports.mClient =
|
exports.mClient = mClient;
|
||||||
mClient;
|
|
||||||
|
|
||||||
|
const client = new Client({
|
||||||
/*
|
|
||||||
* DISCORD CLIENT
|
|
||||||
*/
|
|
||||||
|
|
||||||
const client =
|
|
||||||
new Client({
|
|
||||||
intents: [
|
intents: [
|
||||||
GatewayIntentBits.Guilds,
|
GatewayIntentBits.Guilds,
|
||||||
GatewayIntentBits.GuildMembers,
|
GatewayIntentBits.GuildMembers,
|
||||||
@@ -43,7 +27,6 @@ const client =
|
|||||||
GatewayIntentBits.DirectMessages,
|
GatewayIntentBits.DirectMessages,
|
||||||
GatewayIntentBits.MessageContent
|
GatewayIntentBits.MessageContent
|
||||||
],
|
],
|
||||||
|
|
||||||
partials: [
|
partials: [
|
||||||
Partials.Channel,
|
Partials.Channel,
|
||||||
Partials.Message,
|
Partials.Message,
|
||||||
@@ -51,82 +34,53 @@ const client =
|
|||||||
Partials.GuildMember,
|
Partials.GuildMember,
|
||||||
Partials.Reaction
|
Partials.Reaction
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|
||||||
|
exports.client = client;
|
||||||
|
|
||||||
exports.client =
|
client.commands = new Collection();
|
||||||
client;
|
client.legacyCommands = new Collection();
|
||||||
|
client.aliases = new Collection();
|
||||||
|
client.buttons = new Collection();
|
||||||
/*
|
client.selectMenus = new Collection();
|
||||||
* COLLECTIONS
|
client.modals = new Collection();
|
||||||
*/
|
|
||||||
|
|
||||||
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 {
|
||||||
|
|
||||||
/*
|
|
||||||
* Connect Mongo first.
|
|
||||||
*/
|
|
||||||
|
|
||||||
await mClient.connect();
|
await mClient.connect();
|
||||||
|
console.log("MongoDB connected");
|
||||||
|
|
||||||
console.log(
|
const {
|
||||||
"MongoDB connected"
|
ensureCookieIndexes
|
||||||
);
|
} = require("./utils/cookieEconomy");
|
||||||
|
const {
|
||||||
|
ensureProfileSystem
|
||||||
|
} = require("./utils/profileSystem");
|
||||||
|
const {
|
||||||
|
ensureInteractionSessionIndexes
|
||||||
|
} = require("./utils/interactionSession");
|
||||||
|
const {
|
||||||
|
ensureBankruptcyIndexes
|
||||||
|
} = require("./utils/bankruptcy");
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
ensureCookieIndexes(),
|
||||||
|
ensureProfileSystem(),
|
||||||
|
ensureInteractionSessionIndexes(),
|
||||||
|
ensureBankruptcyIndexes()
|
||||||
|
]);
|
||||||
|
|
||||||
/*
|
console.log("Database indexes initialized");
|
||||||
* Load handlers.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const handlerFiles =
|
const handlerFiles = fs.readdirSync("./handlers");
|
||||||
fs.readdirSync(
|
|
||||||
"./handlers"
|
|
||||||
);
|
|
||||||
|
|
||||||
|
for (const handler of handlerFiles) {
|
||||||
for (
|
require(`./handlers/${handler}`)(client);
|
||||||
const handler
|
|
||||||
of handlerFiles
|
|
||||||
) {
|
|
||||||
require(
|
|
||||||
`./handlers/${handler}`
|
|
||||||
)(client);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Login Discord.
|
|
||||||
*/
|
|
||||||
|
|
||||||
await client.login(
|
await client.login(
|
||||||
process.env.D_TOKEN
|
process.env.D_TOKEN
|
||||||
);
|
);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
console.error(
|
||||||
"Failed to start ShaiBot:",
|
"Failed to start ShaiBot:",
|
||||||
@@ -137,5 +91,4 @@ async function start() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
start();
|
start();
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
|||||||
+87
-334
@@ -14,101 +14,63 @@ 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
|
||||||
) {
|
) {
|
||||||
@@ -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,13 +689,6 @@ function sleep(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/*
|
|
||||||
* ============================================================
|
|
||||||
* EXPORTS
|
|
||||||
* ============================================================
|
|
||||||
*/
|
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
resolveCookieHeist,
|
resolveCookieHeist,
|
||||||
HEIST_COOLDOWN
|
HEIST_COOLDOWN
|
||||||
|
|||||||
+218
-810
File diff suppressed because it is too large
Load Diff
@@ -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
File diff suppressed because it is too large
Load Diff
+128
-562
File diff suppressed because it is too large
Load Diff
+81
-1233
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,791 @@
|
|||||||
|
const {
|
||||||
|
EmbedBuilder,
|
||||||
|
ActionRowBuilder,
|
||||||
|
ButtonBuilder,
|
||||||
|
ButtonStyle
|
||||||
|
} = require("discord.js");
|
||||||
|
|
||||||
|
const {
|
||||||
|
getCookieBalance,
|
||||||
|
transferCookies,
|
||||||
|
temporaryEphemeralReply,
|
||||||
|
randomItem
|
||||||
|
} = require("./cookieEconomy");
|
||||||
|
|
||||||
|
const {
|
||||||
|
recordGameOutcome,
|
||||||
|
trackProfileEvent
|
||||||
|
} = require("./profileSystem");
|
||||||
|
|
||||||
|
const SLOT_BETS = [
|
||||||
|
10,
|
||||||
|
50,
|
||||||
|
100,
|
||||||
|
500,
|
||||||
|
1000
|
||||||
|
];
|
||||||
|
|
||||||
|
const SLOT_SYMBOLS = [
|
||||||
|
"🍒",
|
||||||
|
"🍋",
|
||||||
|
"🍪",
|
||||||
|
"⭐",
|
||||||
|
"💎"
|
||||||
|
];
|
||||||
|
|
||||||
|
const SLOT_OUTCOMES = [
|
||||||
|
{ weight: 300, type: "loss", multiplier: 0 },
|
||||||
|
{ weight: 390, type: "pair", multiplier: 0.5 },
|
||||||
|
{ weight: 150, type: "triple", symbol: "🍋", multiplier: 1.5 },
|
||||||
|
{ weight: 80, type: "triple", symbol: "🍒", multiplier: 2 },
|
||||||
|
{ weight: 45, type: "triple", symbol: "🍪", multiplier: 3 },
|
||||||
|
{ weight: 25, type: "triple", symbol: "⭐", multiplier: 5 },
|
||||||
|
{ weight: 10, type: "triple", symbol: "💎", multiplier: 10 }
|
||||||
|
];
|
||||||
|
|
||||||
|
async function showSlots(interaction) {
|
||||||
|
const ownerId =
|
||||||
|
getSlotsSessionOwner(
|
||||||
|
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 Slots"
|
||||||
|
)
|
||||||
|
.setDescription(
|
||||||
|
[
|
||||||
|
"Welcome to the Cookie reels.",
|
||||||
|
"",
|
||||||
|
"Choose your wager below.",
|
||||||
|
"",
|
||||||
|
"🍋 **Triple Lemon** — x1.5",
|
||||||
|
"🍒 **Triple Cherry** — x2",
|
||||||
|
"🍪 **Triple Cookie** — x3",
|
||||||
|
"⭐ **Triple Star** — x5",
|
||||||
|
"💎 **Jackpot** — x10"
|
||||||
|
].join("\n")
|
||||||
|
)
|
||||||
|
.addFields({
|
||||||
|
name:
|
||||||
|
"Your Vault",
|
||||||
|
value:
|
||||||
|
`**${balance.toLocaleString()} 🍪**`
|
||||||
|
})
|
||||||
|
.setFooter({
|
||||||
|
text:
|
||||||
|
`crumbSaucerSlots:${interaction.user.id}`
|
||||||
|
});
|
||||||
|
|
||||||
|
return interaction.update({
|
||||||
|
embeds: [embed],
|
||||||
|
components:
|
||||||
|
buildSlotBetButtons()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function playSlots(
|
||||||
|
interaction,
|
||||||
|
bet,
|
||||||
|
source = "normal",
|
||||||
|
previousAllIn = false,
|
||||||
|
previousBet = 0
|
||||||
|
) {
|
||||||
|
const ownerId =
|
||||||
|
getSlotsSessionOwner(
|
||||||
|
interaction
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
ownerId !==
|
||||||
|
interaction.user.id
|
||||||
|
) {
|
||||||
|
return temporaryEphemeralReply(
|
||||||
|
interaction,
|
||||||
|
{
|
||||||
|
content:
|
||||||
|
"These aren't your slots. Open your own Crumb Saucer session."
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!SLOT_BETS.includes(bet)) {
|
||||||
|
return temporaryEphemeralReply(
|
||||||
|
interaction,
|
||||||
|
{
|
||||||
|
content:
|
||||||
|
"That wager isn't available."
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const playerId =
|
||||||
|
interaction.user.id;
|
||||||
|
|
||||||
|
const vaultId =
|
||||||
|
interaction.client.user.id;
|
||||||
|
|
||||||
|
const [
|
||||||
|
playerBalance,
|
||||||
|
vaultBalance
|
||||||
|
] = await Promise.all([
|
||||||
|
getCookieBalance(playerId),
|
||||||
|
getCookieBalance(vaultId)
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (playerBalance < bet) {
|
||||||
|
return temporaryEphemeralReply(
|
||||||
|
interaction,
|
||||||
|
{
|
||||||
|
content:
|
||||||
|
`You need **${bet.toLocaleString()} Cookies** for that wager.`
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxPayout =
|
||||||
|
bet * 10;
|
||||||
|
|
||||||
|
if (
|
||||||
|
vaultBalance + bet <
|
||||||
|
maxPayout
|
||||||
|
) {
|
||||||
|
return temporaryEphemeralReply(
|
||||||
|
interaction,
|
||||||
|
{
|
||||||
|
content:
|
||||||
|
"The Crumb Saucer vault doesn't currently have enough Cookies to cover that wager."
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await transferCookies(
|
||||||
|
playerId,
|
||||||
|
vaultId,
|
||||||
|
bet
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (
|
||||||
|
error.message ===
|
||||||
|
"INSUFFICIENT_COOKIES"
|
||||||
|
) {
|
||||||
|
return temporaryEphemeralReply(
|
||||||
|
interaction,
|
||||||
|
{
|
||||||
|
content:
|
||||||
|
"Your Cookie balance changed before the spin could begin."
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
await trackProfileEvent(
|
||||||
|
interaction,
|
||||||
|
playerId,
|
||||||
|
{
|
||||||
|
type: "game_start",
|
||||||
|
game: "slots",
|
||||||
|
source,
|
||||||
|
wager: bet,
|
||||||
|
allIn:
|
||||||
|
bet === playerBalance,
|
||||||
|
previousAllIn,
|
||||||
|
previousBet
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
await interaction.update({
|
||||||
|
embeds: [
|
||||||
|
buildSpinningEmbed(
|
||||||
|
bet,
|
||||||
|
["❔", "❔", "❔"],
|
||||||
|
"The reels are spinning..."
|
||||||
|
)
|
||||||
|
],
|
||||||
|
components: []
|
||||||
|
});
|
||||||
|
|
||||||
|
await sleep(500);
|
||||||
|
|
||||||
|
const firstPreview =
|
||||||
|
randomItem(SLOT_SYMBOLS);
|
||||||
|
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [
|
||||||
|
buildSpinningEmbed(
|
||||||
|
bet,
|
||||||
|
[
|
||||||
|
firstPreview,
|
||||||
|
"❔",
|
||||||
|
"❔"
|
||||||
|
],
|
||||||
|
"The first reel stops..."
|
||||||
|
)
|
||||||
|
],
|
||||||
|
components: []
|
||||||
|
});
|
||||||
|
|
||||||
|
await sleep(500);
|
||||||
|
|
||||||
|
const secondPreview =
|
||||||
|
randomItem(SLOT_SYMBOLS);
|
||||||
|
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [
|
||||||
|
buildSpinningEmbed(
|
||||||
|
bet,
|
||||||
|
[
|
||||||
|
firstPreview,
|
||||||
|
secondPreview,
|
||||||
|
"❔"
|
||||||
|
],
|
||||||
|
"One more..."
|
||||||
|
)
|
||||||
|
],
|
||||||
|
components: []
|
||||||
|
});
|
||||||
|
|
||||||
|
await sleep(600);
|
||||||
|
|
||||||
|
const outcome =
|
||||||
|
rollSlotOutcome();
|
||||||
|
|
||||||
|
const result =
|
||||||
|
generateOutcomeLine(
|
||||||
|
outcome
|
||||||
|
);
|
||||||
|
|
||||||
|
const payout =
|
||||||
|
Math.floor(
|
||||||
|
bet *
|
||||||
|
outcome.multiplier
|
||||||
|
);
|
||||||
|
|
||||||
|
if (payout > 0) {
|
||||||
|
try {
|
||||||
|
await transferCookies(
|
||||||
|
vaultId,
|
||||||
|
playerId,
|
||||||
|
payout
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
"CRITICAL: Crumb Saucer Slots payout failed:",
|
||||||
|
error
|
||||||
|
);
|
||||||
|
|
||||||
|
return interaction.editReply({
|
||||||
|
embeds: [
|
||||||
|
new EmbedBuilder()
|
||||||
|
.setTitle(
|
||||||
|
"🎰 Crumb Saucer Error"
|
||||||
|
)
|
||||||
|
.setDescription(
|
||||||
|
"The reels stopped, but the payout system had a problem. Please contact an administrator."
|
||||||
|
)
|
||||||
|
.setFooter({
|
||||||
|
text:
|
||||||
|
`crumbSaucerSlotsResult:${playerId}:${bet}`
|
||||||
|
})
|
||||||
|
],
|
||||||
|
components:
|
||||||
|
buildResultButtons()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [
|
||||||
|
playerAfter,
|
||||||
|
vaultAfter
|
||||||
|
] = await Promise.all([
|
||||||
|
getCookieBalance(playerId),
|
||||||
|
getCookieBalance(vaultId)
|
||||||
|
]);
|
||||||
|
|
||||||
|
const profit =
|
||||||
|
payout - bet;
|
||||||
|
|
||||||
|
await recordGameOutcome(
|
||||||
|
interaction,
|
||||||
|
playerId,
|
||||||
|
{
|
||||||
|
game:
|
||||||
|
"slots",
|
||||||
|
result:
|
||||||
|
profit > 0
|
||||||
|
? "win"
|
||||||
|
: profit < 0
|
||||||
|
? "loss"
|
||||||
|
: "push",
|
||||||
|
profit,
|
||||||
|
triplet:
|
||||||
|
outcome.type === "triple"
|
||||||
|
? outcome.symbol
|
||||||
|
: null,
|
||||||
|
jackpot:
|
||||||
|
outcome.type ===
|
||||||
|
"triple" &&
|
||||||
|
outcome.symbol ===
|
||||||
|
"💎"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const resultEmbed =
|
||||||
|
new EmbedBuilder()
|
||||||
|
.setTitle(
|
||||||
|
"🎰 Crumb Saucer Slots"
|
||||||
|
)
|
||||||
|
.setDescription(
|
||||||
|
[
|
||||||
|
"## " +
|
||||||
|
result.join(" "),
|
||||||
|
"",
|
||||||
|
`### ${getSlotResultName(outcome)}`
|
||||||
|
].join("\n")
|
||||||
|
)
|
||||||
|
.addFields(
|
||||||
|
{
|
||||||
|
name:
|
||||||
|
"Bet",
|
||||||
|
value:
|
||||||
|
`${bet.toLocaleString()} 🍪`,
|
||||||
|
inline:
|
||||||
|
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:
|
||||||
|
"Your Vault",
|
||||||
|
value:
|
||||||
|
`${playerAfter.toLocaleString()} 🍪`,
|
||||||
|
inline:
|
||||||
|
true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name:
|
||||||
|
"House Vault",
|
||||||
|
value:
|
||||||
|
`${vaultAfter.toLocaleString()} 🍪`,
|
||||||
|
inline:
|
||||||
|
true
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.setFooter({
|
||||||
|
text:
|
||||||
|
`crumbSaucerSlotsResult:${playerId}:${bet}`
|
||||||
|
});
|
||||||
|
|
||||||
|
return interaction.editReply({
|
||||||
|
embeds: [resultEmbed],
|
||||||
|
components:
|
||||||
|
buildResultButtons()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runSlotsBack(
|
||||||
|
interaction
|
||||||
|
) {
|
||||||
|
const footer =
|
||||||
|
interaction.message
|
||||||
|
?.embeds?.[0]
|
||||||
|
?.footer
|
||||||
|
?.text;
|
||||||
|
|
||||||
|
const parts =
|
||||||
|
footer?.split(":") ?? [];
|
||||||
|
|
||||||
|
if (
|
||||||
|
parts[0] !==
|
||||||
|
"crumbSaucerSlotsResult"
|
||||||
|
) {
|
||||||
|
return temporaryEphemeralReply(
|
||||||
|
interaction,
|
||||||
|
{
|
||||||
|
content:
|
||||||
|
"This Slots result can no longer be replayed."
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ownerId =
|
||||||
|
parts[1];
|
||||||
|
|
||||||
|
const bet =
|
||||||
|
Number(parts[2]);
|
||||||
|
|
||||||
|
if (
|
||||||
|
ownerId !==
|
||||||
|
interaction.user.id
|
||||||
|
) {
|
||||||
|
return temporaryEphemeralReply(
|
||||||
|
interaction,
|
||||||
|
{
|
||||||
|
content:
|
||||||
|
"These aren't your slots."
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!SLOT_BETS.includes(bet)) {
|
||||||
|
return temporaryEphemeralReply(
|
||||||
|
interaction,
|
||||||
|
{
|
||||||
|
content:
|
||||||
|
"The previous wager is no longer available."
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return playSlots(
|
||||||
|
interaction,
|
||||||
|
bet,
|
||||||
|
"run_it_back"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSlotBetButtons() {
|
||||||
|
return [
|
||||||
|
new ActionRowBuilder()
|
||||||
|
.addComponents(
|
||||||
|
new ButtonBuilder()
|
||||||
|
.setCustomId(
|
||||||
|
"crumbSaucerSlots10"
|
||||||
|
)
|
||||||
|
.setLabel("10")
|
||||||
|
.setEmoji("🍪")
|
||||||
|
.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() {
|
||||||
|
return [
|
||||||
|
new ActionRowBuilder()
|
||||||
|
.addComponents(
|
||||||
|
new ButtonBuilder()
|
||||||
|
.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() {
|
||||||
|
const totalWeight =
|
||||||
|
SLOT_OUTCOMES.reduce(
|
||||||
|
(total, outcome) =>
|
||||||
|
total +
|
||||||
|
outcome.weight,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
let roll =
|
||||||
|
Math.random() *
|
||||||
|
totalWeight;
|
||||||
|
|
||||||
|
for (const outcome of SLOT_OUTCOMES) {
|
||||||
|
roll -= outcome.weight;
|
||||||
|
|
||||||
|
if (roll <= 0) {
|
||||||
|
return outcome;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return SLOT_OUTCOMES[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateRandomLine() {
|
||||||
|
return [
|
||||||
|
randomItem(SLOT_SYMBOLS),
|
||||||
|
randomItem(SLOT_SYMBOLS),
|
||||||
|
randomItem(SLOT_SYMBOLS)
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateOutcomeLine(
|
||||||
|
outcome
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
outcome.type ===
|
||||||
|
"triple"
|
||||||
|
) {
|
||||||
|
return [
|
||||||
|
outcome.symbol,
|
||||||
|
outcome.symbol,
|
||||||
|
outcome.symbol
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
outcome.type ===
|
||||||
|
"pair"
|
||||||
|
) {
|
||||||
|
const symbol =
|
||||||
|
randomItem(SLOT_SYMBOLS);
|
||||||
|
|
||||||
|
let other;
|
||||||
|
|
||||||
|
do {
|
||||||
|
other =
|
||||||
|
randomItem(SLOT_SYMBOLS);
|
||||||
|
} while (other === symbol);
|
||||||
|
|
||||||
|
const result = [
|
||||||
|
symbol,
|
||||||
|
symbol,
|
||||||
|
symbol
|
||||||
|
];
|
||||||
|
|
||||||
|
result[
|
||||||
|
Math.floor(
|
||||||
|
Math.random() * 3
|
||||||
|
)
|
||||||
|
] = other;
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
let result;
|
||||||
|
|
||||||
|
do {
|
||||||
|
result =
|
||||||
|
generateRandomLine();
|
||||||
|
} while (
|
||||||
|
new Set(result).size < 3
|
||||||
|
);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSpinningEmbed(
|
||||||
|
bet,
|
||||||
|
symbols,
|
||||||
|
status
|
||||||
|
) {
|
||||||
|
return new EmbedBuilder()
|
||||||
|
.setTitle(
|
||||||
|
"🎰 Crumb Saucer Slots"
|
||||||
|
)
|
||||||
|
.setDescription(
|
||||||
|
[
|
||||||
|
"## " +
|
||||||
|
symbols.join(" "),
|
||||||
|
"",
|
||||||
|
`**${status}**`
|
||||||
|
].join("\n")
|
||||||
|
)
|
||||||
|
.addFields({
|
||||||
|
name:
|
||||||
|
"Bet",
|
||||||
|
value:
|
||||||
|
`**${bet.toLocaleString()} 🍪**`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSlotResultName(
|
||||||
|
outcome
|
||||||
|
) {
|
||||||
|
if (outcome.type === "loss") {
|
||||||
|
return "No luck this time.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (outcome.type === "pair") {
|
||||||
|
return "Pair!";
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (outcome.symbol) {
|
||||||
|
case "🍋":
|
||||||
|
return "🍋 Triple Lemon!";
|
||||||
|
case "🍒":
|
||||||
|
return "🍒 Triple Cherry!";
|
||||||
|
case "🍪":
|
||||||
|
return "🍪 Triple Cookie!";
|
||||||
|
case "⭐":
|
||||||
|
return "⭐ Triple Star!";
|
||||||
|
case "💎":
|
||||||
|
return "💎 JACKPOT! 💎";
|
||||||
|
default:
|
||||||
|
return "Winner!";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSlotsSessionOwner(
|
||||||
|
interaction
|
||||||
|
) {
|
||||||
|
const footer =
|
||||||
|
interaction.message
|
||||||
|
?.embeds?.[0]
|
||||||
|
?.footer
|
||||||
|
?.text;
|
||||||
|
|
||||||
|
const parts =
|
||||||
|
footer?.split(":") ?? [];
|
||||||
|
|
||||||
|
if (
|
||||||
|
parts[0] ===
|
||||||
|
"crumbSaucer"
|
||||||
|
) {
|
||||||
|
return parts[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
parts[0] ===
|
||||||
|
"crumbSaucerSlots"
|
||||||
|
) {
|
||||||
|
return parts[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
parts[0] ===
|
||||||
|
"crumbSaucerSlotsResult"
|
||||||
|
) {
|
||||||
|
return parts[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms) {
|
||||||
|
return new Promise(
|
||||||
|
resolve =>
|
||||||
|
setTimeout(
|
||||||
|
resolve,
|
||||||
|
ms
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
showSlots,
|
||||||
|
playSlots,
|
||||||
|
runSlotsBack
|
||||||
|
};
|
||||||
@@ -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
|
||||||
|
};
|
||||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
|
||||||
|
};
|
||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user