Compare commits
13 Commits
9710fc65bf
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6e3e1620d1 | |||
| dd4d6e4817 | |||
| b441de286c | |||
| 2ca18aae06 | |||
| 1ef2295c07 | |||
| 6342c4569b | |||
| c61e34c241 | |||
| 82cef1b34a | |||
| 136a5902c0 | |||
| 903a2f3081 | |||
| 995a2317b9 | |||
| e3dbb11e12 | |||
| 14a059034c |
@@ -1,3 +1,5 @@
|
||||
node_modules
|
||||
.env
|
||||
backup/
|
||||
*.bak
|
||||
tests/
|
||||
@@ -1,611 +1,280 @@
|
||||
# ShaiBot
|
||||
|
||||
A modular **Discord.js** bot built around a shared Cookie economy,
|
||||
interactive Crumb Saucer games, player profiles, achievements, global
|
||||
badges, cooperative boss battles, raid planning, and community
|
||||
interactions.
|
||||
ShaiBot is a modular Discord community bot built around a persistent
|
||||
**Cookie economy**, interactive games, player progression, and
|
||||
multiplayer activities.
|
||||
|
||||
> **Status:** Active development. Features, achievements, and game
|
||||
> balance may change.
|
||||
The bot's main entertainment hub is the **Crumb Saucer**, where players
|
||||
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
|
||||
persistent balances, atomic balance checks, transfers, Mongo-backed
|
||||
cooldowns, and a shared bot/house vault.
|
||||
## Highlights
|
||||
|
||||
The bot itself acts as the general Cookie vault: gambling losses and
|
||||
penalties can feed the vault, while wins, heists, collection rewards,
|
||||
and other systems can pay Cookies back out.
|
||||
- 🍪 Persistent Cookie economy
|
||||
- 🎰 Crumb Saucer game hub
|
||||
- 🎰 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
|
||||
|
||||
Cookie collection is shared between `/cookies collect` and the **Crumb
|
||||
Saucer Cookie Bakery**, using the same cooldown and reward logic.
|
||||
## The Crumb Saucer
|
||||
|
||||
### 👤 Player Profiles
|
||||
The **Crumb Saucer** is the main home for ShaiBot's Cookie-powered
|
||||
games.
|
||||
|
||||
ShaiBot includes persistent player profiles that bring together Cookie
|
||||
and game activity.
|
||||
### 🍪 Cookie Bakery
|
||||
|
||||
Profiles can expose:
|
||||
Collect Cookies using the same shared collection system as
|
||||
`/cookies collect`.
|
||||
|
||||
- Cookie economy statistics
|
||||
### 🎰 Slots
|
||||
|
||||
Spin for symbol combinations, jackpots, All-In wagers, and Run It Back
|
||||
streaks.
|
||||
|
||||
### 🃏 Blackjack
|
||||
|
||||
Includes Hit, Stand, Double, natural Blackjack, pushes, All-In, and Run
|
||||
It Back.
|
||||
|
||||
### 🎡 Roulette
|
||||
|
||||
A compact European Roulette game using numbers `0–36`, with Red, Black,
|
||||
Odd, Even, and Zero bets.
|
||||
|
||||
### 🏦 Vault Heist
|
||||
|
||||
Attempt to steal from ShaiBot's house vault using different risk/reward
|
||||
approaches.
|
||||
|
||||
### 🆘 Bankruptcy
|
||||
|
||||
A recovery mechanic for players who need a fresh start, with profile
|
||||
progression for rebuilding afterward.
|
||||
|
||||
### ♠️ Shai Poker
|
||||
|
||||
A persistent multiplayer Texas Hold'em game for **2--6 players**.
|
||||
|
||||
Current defaults:
|
||||
|
||||
``` text
|
||||
Buy-in: 1,000 Cookies
|
||||
Blinds: 10 / 20
|
||||
Turn timer: 90 seconds
|
||||
Pot model: single pot
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
Side pots, tournaments, bots, rake, and player-facing equity assistance
|
||||
are intentionally outside the current V1 design.
|
||||
|
||||
## Player Profiles
|
||||
|
||||
Profiles provide the permanent history behind ShaiBot's progression
|
||||
systems.
|
||||
|
||||
They track things such as:
|
||||
|
||||
- Game wins and losses
|
||||
- Per-game statistics
|
||||
- Lifetime gains and losses
|
||||
- Stealing and giving activity
|
||||
- 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
|
||||
- Collection progress
|
||||
|
||||
Profile menus are public so other members can view them, while
|
||||
interactive controls are intended to remain restricted to the session
|
||||
owner. Errors, cooldown/debug-style feedback, and unauthorized
|
||||
interaction messages may be ephemeral.
|
||||
Completed multiplayer game documents do not need to become permanent
|
||||
history when the meaningful result already belongs in the profile
|
||||
system.
|
||||
|
||||
### 🏆 Achievements
|
||||
## Achievements & Global Badges
|
||||
|
||||
Achievements are repeatable across the community: every eligible player
|
||||
can earn their own copy.
|
||||
**Achievements** are personal progression. Any eligible player can earn
|
||||
them.
|
||||
|
||||
Achievement tracking can use persistent profile statistics as well as
|
||||
explicit game events. Current and planned categories include:
|
||||
**Global badges** are guild-unique distinctions, usually awarded to the
|
||||
first player in a guild to accomplish something notable.
|
||||
|
||||
- Cookie collection and economy milestones
|
||||
- Giving and stealing milestones
|
||||
- Game wins and losses
|
||||
- Game-specific loss milestones
|
||||
- All-In accomplishments
|
||||
- Run It Back streaks
|
||||
- Cross-game special challenges
|
||||
- Collector achievements for earning a portion or all available
|
||||
achievements
|
||||
Progression spans the Cookie economy, stealing, Crumb Saucer games,
|
||||
activity, social interactions, Poker, and special cross-game challenges.
|
||||
|
||||
Game-specific achievements use names such as:
|
||||
## Cookie Stealing
|
||||
|
||||
``` text
|
||||
The House Always Wins: Blackjack
|
||||
```
|
||||
Player-to-player stealing is separate from Vault Heist.
|
||||
|
||||
where sharing a generalized achievement concept between games makes
|
||||
sense.
|
||||
The current system accounts for wealth differences, recent pressure on
|
||||
the victim, percentage-based loot, hard caps, and an hourly exposure
|
||||
budget.
|
||||
|
||||
### 🎖️ Global Badges
|
||||
Detailed balancing and mechanics are documented in
|
||||
[`COOKIE_LOGIC.md`](COOKIE_LOGIC.md).
|
||||
|
||||
Global badges are guild-wide collectibles that can have a single owner.
|
||||
They are intended for special "first player to..." accomplishments and
|
||||
other unique distinctions.
|
||||
## ⚔️ Crumble Strike
|
||||
|
||||
Examples include:
|
||||
Crumble Strike is ShaiBot's cooperative boss-battle system.
|
||||
|
||||
- First-time game accomplishments
|
||||
- Slots triplet combinations
|
||||
- All-In / Run It Back feats
|
||||
- Special or hidden manually awarded badges
|
||||
- Achievement collection milestones
|
||||
Players recruit a party, choose combat roles, commit Cookies, and work
|
||||
together using actions such as Strike, Defend, Heal, Revive, and Limit
|
||||
Break.
|
||||
|
||||
Badges may be hidden until discovered. Some special badges are
|
||||
intentionally managed manually in MongoDB rather than automatically
|
||||
awarded by gameplay.
|
||||
Encounters use persistent state because they can span many players and
|
||||
interactions.
|
||||
|
||||
### 🎰 The Crumb Saucer
|
||||
## 📅 Raid Planner & Community Features
|
||||
|
||||
The **Crumb Saucer** is ShaiBot's Cookie-powered entertainment hub.
|
||||
ShaiBot also includes raid-planning tools and smaller social/community
|
||||
commands alongside its economy and progression systems.
|
||||
|
||||
#### Cookie Bakery
|
||||
------------------------------------------------------------------------
|
||||
|
||||
Players can collect a fresh batch of Cookies from the Saucer.
|
||||
|
||||
The Bakery and `/cookies collect` use the same shared collection
|
||||
utility, meaning they share:
|
||||
|
||||
- The same Mongo-backed cooldown
|
||||
- The same reward range
|
||||
- The same balance mutation
|
||||
- The same profile/stat tracking
|
||||
|
||||
The current default collection range is **1--1000 Cookies**.
|
||||
|
||||
#### Slots
|
||||
|
||||
- Multiple Cookie wagers
|
||||
- Embed-based reel presentation
|
||||
- Different symbol payouts
|
||||
- Triplet combinations
|
||||
- Jackpot support
|
||||
- Run It Back support
|
||||
- Shared Cookie vault
|
||||
- Game-specific achievements and first-time global badges
|
||||
|
||||
Slots triplets can participate in unique global badge progression,
|
||||
including Lemon, Cherry, Cookie, Star, and Diamond combinations.
|
||||
|
||||
#### Blackjack
|
||||
|
||||
- Hit, Stand, and Double
|
||||
- Dealer stands on 17
|
||||
- Natural Blackjack pays 3:2
|
||||
- Push returns the wager
|
||||
- All-In support
|
||||
- Run It Back support
|
||||
- Persistent active hands
|
||||
- Per-player session ownership
|
||||
- Safe simultaneous sessions
|
||||
- Profile, achievement, and badge integration
|
||||
|
||||
All-In state should be derived from the committed wager and the player's
|
||||
vault at the time the wager is made rather than solely from which button
|
||||
was pressed.
|
||||
|
||||
Run It Back preserves immutable metadata from the previous hand. This
|
||||
allows historical challenges to remain valid even if another player
|
||||
changes the user's Cookie balance between rounds.
|
||||
|
||||
For example, an All-In that pushes can be followed by Run It Back and
|
||||
still qualify for an All-In → Run It Back challenge even if Cookies were
|
||||
gifted to the player in between.
|
||||
|
||||
Blackjack resolution is designed to guard against rapid repeated
|
||||
interactions so a hand cannot be resolved and paid multiple times.
|
||||
|
||||
#### Roulette
|
||||
|
||||
A compact European Roulette implementation using **0--36**.
|
||||
|
||||
Available bets include:
|
||||
|
||||
- 🔴 Red
|
||||
- ⚫ Black
|
||||
- Odd
|
||||
- Even
|
||||
- 🟢 Zero
|
||||
|
||||
Roulette uses repeated edits of the same embed for a
|
||||
rate-limit-conscious pseudo-animation. Zero loses on Red, Black, Odd,
|
||||
and Even.
|
||||
|
||||
Roulette supports All-In and Run It Back tracking for profile
|
||||
achievements and special accomplishments.
|
||||
|
||||
#### Vault Heist
|
||||
|
||||
Players can attempt to steal directly from ShaiBot's Cookie vault using
|
||||
different risk/reward approaches such as **Sneak**, **Hack**, and
|
||||
**Smash**.
|
||||
|
||||
Heist actions share a per-player cooldown so changing approach does not
|
||||
bypass it.
|
||||
|
||||
Heist rewards and fines use percentage-based mechanics with hard caps so
|
||||
the system remains meaningful for wealthy players without allowing
|
||||
extreme balances to produce unreasonable penalties.
|
||||
|
||||
### 🔁 Run It Back
|
||||
|
||||
Supported Crumb Saucer games can track consecutive use of **Run It
|
||||
Back**.
|
||||
|
||||
The streak represents the player's choice to immediately replay through
|
||||
the Run It Back action; wins, losses, pushes, jackpots, or other
|
||||
outcomes do not inherently break the streak.
|
||||
|
||||
Current achievement concepts include using Run It Back five consecutive
|
||||
times in supported games:
|
||||
|
||||
``` text
|
||||
One More Spin: Slots
|
||||
One More Bet: Roulette
|
||||
One More Hand: Blackjack
|
||||
```
|
||||
|
||||
A normal/new wager breaks that game's Run It Back chain.
|
||||
|
||||
### ♾️ I Can Do This All Day
|
||||
|
||||
A special cross-game accomplishment tracks the sequence:
|
||||
|
||||
``` text
|
||||
All-In
|
||||
→ round resolves
|
||||
→ Run It Back with the same committed wager
|
||||
```
|
||||
|
||||
The original All-In is determined from immutable wager-time data. The
|
||||
Run It Back relationship is determined from the previous game's stored
|
||||
bet and All-In state rather than relying on the player's live vault
|
||||
remaining unchanged.
|
||||
|
||||
This prevents another player gifting Cookies between rounds from
|
||||
invalidating a legitimate sequence.
|
||||
|
||||
The accomplishment can exist both as a normal achievement available to
|
||||
everyone and as a global first-time badge for the first player to
|
||||
perform it.
|
||||
|
||||
### 🏴 Cookie Stealing
|
||||
|
||||
Normal player-to-player stealing is separate from Vault Heist.
|
||||
|
||||
Steal difficulty can account for attacker and target wealth. Successful
|
||||
steals use randomized percentages with hard caps, while failed attempts
|
||||
can apply a percentage-based penalty.
|
||||
|
||||
Profile tracking records relevant Cookie gains/losses and stealing
|
||||
activity.
|
||||
|
||||
### ⚔️ Crumble Strike
|
||||
|
||||
**Crumble Strike** is a cooperative Cookie-powered boss battle.
|
||||
|
||||
- A player spawns an encounter.
|
||||
- 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:
|
||||
|
||||
- **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**.
|
||||
|
||||
### 📅 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
|
||||
ShaiBot/
|
||||
├── buttons/
|
||||
│ ├── crumbSaucer/
|
||||
│ ├── crumbleStrike/
|
||||
│ └── profile/
|
||||
├── commands/
|
||||
│ └── applications/
|
||||
├── events/
|
||||
├── handlers/
|
||||
├── modals/
|
||||
├── utils/
|
||||
│ ├── cookieEconomy.js
|
||||
│ ├── crumbCollect.js
|
||||
│ ├── crumbSlots.js
|
||||
│ ├── crumbBlackjack.js
|
||||
│ ├── crumbRoulette.js
|
||||
│ ├── cookieHeist.js
|
||||
│ ├── profileSystem.js
|
||||
│ └── crumbleStrike.js
|
||||
├── index.js
|
||||
├── package.json
|
||||
└── .env
|
||||
```
|
||||
|
||||
Interaction modules are loaded into Discord.js collections such as:
|
||||
|
||||
``` js
|
||||
client.commands = new Collection();
|
||||
client.legacyCommands = new Collection();
|
||||
client.aliases = new Collection();
|
||||
client.buttons = new Collection();
|
||||
client.selectMenus = new Collection();
|
||||
client.modals = new Collection();
|
||||
```
|
||||
|
||||
## Shared Utility Architecture
|
||||
|
||||
Game/application files should remain thin wherever practical.
|
||||
|
||||
Shared behavior belongs in `utils/`, particularly:
|
||||
|
||||
- `cookieEconomy.js` --- balances, transfers, cooldowns, and economy
|
||||
primitives
|
||||
- `crumbCollect.js` --- authoritative Cookie collection behavior
|
||||
- `crumbSlots.js` --- Slots state and resolution
|
||||
- `crumbBlackjack.js` --- Blackjack state and resolution
|
||||
- `crumbRoulette.js` --- Roulette state and resolution
|
||||
- `cookieHeist.js` --- Vault Heist behavior
|
||||
- `profileSystem.js` --- profile statistics, achievements, badges,
|
||||
rendering, and audit helpers
|
||||
|
||||
Application commands and button handlers should call these utilities
|
||||
rather than independently reimplementing economy or progression logic.
|
||||
|
||||
This is especially important for features reachable from multiple
|
||||
interfaces. For example:
|
||||
|
||||
``` text
|
||||
/cookies collect
|
||||
│
|
||||
▼
|
||||
crumbCollect.js
|
||||
▲
|
||||
│
|
||||
Crumb Saucer Bakery
|
||||
```
|
||||
|
||||
Both interfaces therefore share one authoritative implementation.
|
||||
# Installation
|
||||
|
||||
## Requirements
|
||||
|
||||
- Node.js
|
||||
- Discord.js
|
||||
- MongoDB
|
||||
- A Discord bot application/token
|
||||
- `dotenv`
|
||||
- Dependencies listed in `package.json`
|
||||
|
||||
Use the Node.js version required by the dependencies in `package.json`.
|
||||
|
||||
## Installation
|
||||
|
||||
Clone the repository:
|
||||
Clone and install:
|
||||
|
||||
``` bash
|
||||
git clone https://git.desq-gaming.de/Shaiwase/ShaiBot.git
|
||||
cd ShaiBot
|
||||
```
|
||||
|
||||
Install dependencies:
|
||||
|
||||
``` bash
|
||||
npm install
|
||||
```
|
||||
|
||||
Create `.env` in the project root:
|
||||
Create `.env` in the repository root:
|
||||
|
||||
``` env
|
||||
D_TOKEN=YOUR_DISCORD_BOT_TOKEN
|
||||
M_URI=YOUR_MONGODB_CONNECTION_STRING
|
||||
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
|
||||
node index.js
|
||||
```
|
||||
|
||||
For PM2, a typical command is:
|
||||
Or with PM2:
|
||||
|
||||
``` bash
|
||||
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
|
||||
|
||||
ShaiBot's Cookie economy intentionally does **not** depend on MongoDB
|
||||
transactions, allowing it to work with deployments that do not support
|
||||
replica-set transactions.
|
||||
ShaiBot does not require MongoDB multi-document transactions for its
|
||||
core economy.
|
||||
|
||||
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
|
||||
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.
|
||||
|
||||
Important collections include Cookie balances, cooldowns, persistent
|
||||
game state, profile statistics, achievements, and global badge
|
||||
definitions/ownership.
|
||||
------------------------------------------------------------------------
|
||||
|
||||
Core economy collections include:
|
||||
# Documentation
|
||||
|
||||
``` text
|
||||
items_cookies
|
||||
cooldown_cookies
|
||||
items_games
|
||||
```
|
||||
The central README intentionally stays high-level. Detailed
|
||||
implementation rules live in focused manuals:
|
||||
|
||||
`items_cookies` stores balances, `cooldown_cookies` stores action
|
||||
cooldown timestamps, and `items_games` can store persistent game state.
|
||||
------------------------------------------------------------------------------------
|
||||
Document Purpose
|
||||
------------------------------------------------ -----------------------------------
|
||||
[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) Runtime structure, handlers,
|
||||
utilities, Mongo persistence,
|
||||
concurrency, and recovery
|
||||
|
||||
Profile-related collection names should be treated as part of the
|
||||
profile utility's implementation contract rather than duplicated
|
||||
throughout command/button modules.
|
||||
[`docs/ECONOMY.md`](docs/ECONOMY.md) Cookie balances, transfers,
|
||||
cooldowns, house vault, settlement,
|
||||
and idempotency
|
||||
|
||||
### Recommended Indexes
|
||||
[`docs/PROGRESSION.md`](docs/PROGRESSION.md) Profiles, achievements, badges,
|
||||
activity, social progression, and
|
||||
maintenance
|
||||
|
||||
Run the shared index initializer after MongoDB connects:
|
||||
[`docs/GAMES.md`](docs/GAMES.md) Crumb Saucer games, Poker, Crumble
|
||||
Strike, and important game rules
|
||||
|
||||
``` js
|
||||
const {
|
||||
ensureCookieIndexes
|
||||
} = require("./utils/cookieEconomy");
|
||||
[`COOKIE_LOGIC.md`](COOKIE_LOGIC.md) Detailed PvP Cookie stealing rules
|
||||
and balancing
|
||||
|
||||
await mClient.connect();
|
||||
await ensureCookieIndexes();
|
||||
```
|
||||
[`AGENTS.md`](AGENTS.md) Repository-wide safety and
|
||||
architecture guide for AI coding
|
||||
agents
|
||||
------------------------------------------------------------------------------------
|
||||
|
||||
The intended unique keys include:
|
||||
The current repository code remains the final source of truth.
|
||||
|
||||
``` text
|
||||
items_cookies → userId
|
||||
cooldown_cookies → userId + guildId
|
||||
```
|
||||
------------------------------------------------------------------------
|
||||
|
||||
Profile and badge utilities may establish their own required indexes.
|
||||
# Development Principles
|
||||
|
||||
## Discord Intents
|
||||
ShaiBot generally favors:
|
||||
|
||||
The current client uses intents including:
|
||||
- Shared utilities instead of duplicated game/economy logic
|
||||
- Thin Discord command/button wrappers
|
||||
- Persistent authoritative state where recovery matters
|
||||
- Atomic and idempotent economy operations
|
||||
- Centralized profile progression
|
||||
- Public game experiences with private sensitive information
|
||||
- Small explicit solutions over unnecessary abstraction
|
||||
|
||||
``` js
|
||||
GatewayIntentBits.Guilds
|
||||
GatewayIntentBits.GuildMembers
|
||||
GatewayIntentBits.GuildMessages
|
||||
GatewayIntentBits.GuildPresences
|
||||
GatewayIntentBits.GuildMessageReactions
|
||||
GatewayIntentBits.GuildVoiceStates
|
||||
GatewayIntentBits.DirectMessages
|
||||
GatewayIntentBits.MessageContent
|
||||
```
|
||||
|
||||
and partials including:
|
||||
|
||||
``` js
|
||||
Partials.Channel
|
||||
Partials.Message
|
||||
Partials.User
|
||||
Partials.GuildMember
|
||||
Partials.Reaction
|
||||
```
|
||||
|
||||
Enable any required privileged intents in the Discord Developer Portal.
|
||||
|
||||
## Interaction Design
|
||||
|
||||
ShaiBot generally follows these conventions:
|
||||
|
||||
- Normal user-facing messages, achievements, menus, and game output
|
||||
are **public by default**.
|
||||
- Errors, unauthorized interaction feedback, cooldown/debug-style
|
||||
notices, and similar operational messages may be **ephemeral**.
|
||||
- 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.
|
||||
- Public menus may be visible to everyone without allowing other users
|
||||
to control the owner's session.
|
||||
- Animation-like effects use rate-limit-conscious message edits.
|
||||
- Embed state should not be treated as the only authoritative source
|
||||
for critical economy/game resolution data.
|
||||
|
||||
## 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.
|
||||
|
||||
Game resolution should be protected against rapid repeated interactions.
|
||||
A result must never be paid twice simply because two button interactions
|
||||
reached the server close together.
|
||||
|
||||
Historical game facts such as wager amount, vault-at-wager, All-In
|
||||
state, and replay origin should be persisted when needed rather than
|
||||
reconstructed later from a player's live balance.
|
||||
|
||||
## 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. Reuse `profileSystem.js` for statistics, achievements, and badges.
|
||||
5. Preserve the player/session owner through every screen.
|
||||
6. Check house liquidity before accepting a wager.
|
||||
7. Prefer editing the current embed for animation/state transitions.
|
||||
8. Protect wager resolution against rapid repeated interactions.
|
||||
9. Persist immutable wager metadata required by achievements or replay
|
||||
behavior.
|
||||
10. Keep application/button handlers thin and avoid duplicating shared
|
||||
rules.
|
||||
|
||||
## Achievement and Badge Design
|
||||
|
||||
Achievements and global badges serve different purposes:
|
||||
|
||||
**Achievements** are personal progression. Multiple players can earn the
|
||||
same achievement.
|
||||
|
||||
**Global badges** are unique guild-wide distinctions and may have a
|
||||
single owner.
|
||||
|
||||
When adding progression:
|
||||
|
||||
- Prefer stat-backed achievements for durable numeric milestones.
|
||||
- Use explicit event tracking when the accomplishment depends on a
|
||||
particular sequence or action.
|
||||
- Keep generalized achievement naming consistent between games where
|
||||
appropriate.
|
||||
- Do not make collector completion depend on achievements that cannot
|
||||
actually be obtained.
|
||||
- Use atomic badge claiming so two players cannot both receive a
|
||||
guild-first badge.
|
||||
- Hidden/manual badges do not need gameplay evaluators if they are
|
||||
intentionally awarded directly in MongoDB.
|
||||
|
||||
## Administration and Auditing
|
||||
|
||||
The profile system includes administrative/audit concepts for validating
|
||||
profile progression data and expected badge definitions.
|
||||
|
||||
When changing achievement or badge schemas:
|
||||
|
||||
- Keep existing IDs stable once players may have unlocked them.
|
||||
- Prefer adding new IDs over renaming historical IDs.
|
||||
- Treat legacy/disabled definitions explicitly.
|
||||
- Audit missing or malformed definitions after database changes.
|
||||
- Avoid hardcoding optional manually managed badges as mandatory
|
||||
configuration unless they are intended to exist in every guild.
|
||||
|
||||
## Roadmap / TODO
|
||||
|
||||
ShaiBot is currently focused on testing, balancing, hardening, and
|
||||
expanding progression.
|
||||
|
||||
Near-term work includes:
|
||||
|
||||
- [ ] Continue stress-testing simultaneous gambling interactions
|
||||
- [ ] Continue testing repeated/double button presses
|
||||
- [ ] Test insufficient-balance races
|
||||
- [ ] Test behavior with a nearly empty house vault
|
||||
- [ ] Validate All-In and Run It Back achievement edge cases
|
||||
- [ ] Validate Run It Back streak progression across supported games
|
||||
- [ ] Validate Slots triplet badge claiming
|
||||
- [ ] Continue balancing Vault Heist rewards and fines
|
||||
- [ ] Add Bankruptcy / starter recovery behavior
|
||||
- [ ] Continue centralizing persistent interaction/session ownership
|
||||
- [ ] Consider an economy audit/log collection
|
||||
- [ ] Verify Crumble Strike AoE Revive behavior
|
||||
- [ ] Verify Crumble Strike expiration/failure cleanup survives bot
|
||||
restarts
|
||||
- [ ] Balance Crumble Strike boss HP, damage, healing, Energy, Limit
|
||||
generation, entry costs, and rewards
|
||||
- [ ] Add more Crumble Strike bosses, lore, and encounter variety
|
||||
- [ ] Add more achievements, hidden badges, and Crumb Saucer
|
||||
attractions
|
||||
For stateful changes, always consider duplicate interactions,
|
||||
simultaneous users, insufficient-balance races, and bot restarts during
|
||||
settlement.
|
||||
|
||||
## Security
|
||||
|
||||
A suitable `.gitignore` should include at least:
|
||||
Keep at least the following out of version control:
|
||||
|
||||
``` gitignore
|
||||
node_modules/
|
||||
@@ -613,46 +282,8 @@ node_modules/
|
||||
*.log
|
||||
```
|
||||
|
||||
If a Discord token or MongoDB credential is accidentally committed,
|
||||
rotate it immediately.
|
||||
|
||||
Do not expose administrative secrets or database credentials through
|
||||
embeds, logs intended for public channels, or repository commits.
|
||||
|
||||
## 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 and profile utilities.
|
||||
- Avoid blocking the event loop.
|
||||
- Be mindful of Discord API rate limits.
|
||||
- Validate interaction ownership.
|
||||
- Keep database operations safe under concurrent interactions.
|
||||
- Preserve existing achievement/badge IDs where users may already own
|
||||
them.
|
||||
- Avoid introducing a second implementation of behavior that already
|
||||
has a shared utility.
|
||||
Rotate Discord or MongoDB credentials immediately if they are exposed.
|
||||
|
||||
## License
|
||||
|
||||
Add the license used by the repository here. If the project is open
|
||||
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. 🍪
|
||||
See the repository's current license information, if present.
|
||||
|
||||
@@ -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,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
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
+698
-793
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,11 @@ const {
|
||||
const {
|
||||
reconcileUserAchievements,
|
||||
reconcileAllAchievements,
|
||||
auditProfileData
|
||||
grantMaintenanceReward,
|
||||
getMaintenanceRewardStatus,
|
||||
auditProfileData,
|
||||
DEFAULT_MAINTENANCE_REWARD_ID,
|
||||
DEFAULT_MAINTENANCE_REWARD_AMOUNT
|
||||
} = require(
|
||||
"../../utils/profileMaintenance"
|
||||
);
|
||||
@@ -67,6 +71,69 @@ module.exports = {
|
||||
.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(
|
||||
@@ -98,6 +165,7 @@ module.exports = {
|
||||
});
|
||||
|
||||
|
||||
try {
|
||||
if (
|
||||
subcommand ===
|
||||
"reconcile"
|
||||
@@ -228,6 +296,222 @@ module.exports = {
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
@@ -321,4 +605,25 @@ module.exports = {
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
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,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.
|
||||
@@ -28,7 +28,8 @@ module.exports = {
|
||||
|
||||
if (
|
||||
interaction.isMentionableSelectMenu() ||
|
||||
interaction.isUserSelectMenu()
|
||||
interaction.isUserSelectMenu() ||
|
||||
interaction.isStringSelectMenu()
|
||||
) {
|
||||
return selectMenuHandler(interaction);
|
||||
}
|
||||
@@ -174,6 +175,9 @@ async function sendInteractionError(interaction, content) {
|
||||
await interaction.reply(payload);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to send interaction error response:", error);
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,6 @@
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"repository": {
|
||||
"type": "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
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -166,6 +166,18 @@ async function declareBankruptcy(interaction, userId = interaction.user.id) {
|
||||
userId,
|
||||
{
|
||||
type: "cookie_gain",
|
||||
source: "bankruptcy",
|
||||
amount:
|
||||
BANKRUPTCY_GRANT
|
||||
}
|
||||
);
|
||||
|
||||
await trackProfileEvent(
|
||||
interaction,
|
||||
userId,
|
||||
{
|
||||
type: "bankruptcy",
|
||||
source: "bankruptcy",
|
||||
amount: BANKRUPTCY_GRANT
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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
|
||||
@@ -1188,6 +1493,9 @@ module.exports = {
|
||||
|
||||
getCookieBalance,
|
||||
changeCookies,
|
||||
changeCookiesOnce,
|
||||
hasCookieOperation,
|
||||
clearCookieOperation,
|
||||
transferCookies,
|
||||
|
||||
getCooldown,
|
||||
|
||||
+10
-3
@@ -269,17 +269,24 @@ async function resolveCookieHeist(
|
||||
}
|
||||
);
|
||||
|
||||
/*
|
||||
* ============================================================
|
||||
* HEIST
|
||||
* ============================================================
|
||||
*/
|
||||
|
||||
await trackProfileEvent(
|
||||
interaction,
|
||||
playerId,
|
||||
{
|
||||
type:
|
||||
"vault_steal",
|
||||
type: "vault_steal",
|
||||
source: "heist",
|
||||
game: "heist",
|
||||
result: "win",
|
||||
amount
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
const [
|
||||
playerAfter,
|
||||
vaultAfter
|
||||
|
||||
@@ -303,7 +303,7 @@ async function startBlackjack(
|
||||
type: "game_start",
|
||||
game: "blackjack",
|
||||
source,
|
||||
bet,
|
||||
wager: bet,
|
||||
allIn,
|
||||
previousAllIn,
|
||||
previousBet
|
||||
|
||||
@@ -87,14 +87,15 @@ async function collectCookies(
|
||||
);
|
||||
|
||||
|
||||
await trackProfileEvent(
|
||||
await trackProfileEvent(
|
||||
interaction,
|
||||
userId,
|
||||
{
|
||||
type: "cookie_gain",
|
||||
source: "collect",
|
||||
amount
|
||||
}
|
||||
);
|
||||
);
|
||||
|
||||
|
||||
const balance = await getCookieBalance(
|
||||
|
||||
+3148
File diff suppressed because it is too large
Load Diff
@@ -495,19 +495,19 @@ async function playRoulette(
|
||||
const allIn =
|
||||
bet === playerBalance;
|
||||
|
||||
await trackProfileEvent(
|
||||
await trackProfileEvent(
|
||||
interaction,
|
||||
playerId,
|
||||
{
|
||||
type: "game_start",
|
||||
game: "roulette",
|
||||
source,
|
||||
bet,
|
||||
wager: bet,
|
||||
allIn,
|
||||
previousAllIn,
|
||||
previousBet
|
||||
}
|
||||
);
|
||||
);
|
||||
|
||||
await interaction.update({
|
||||
embeds: [
|
||||
|
||||
+198
-56
@@ -14,48 +14,88 @@ const {
|
||||
requireInteractionSession
|
||||
} = require("./interactionSession");
|
||||
|
||||
async function showCrumbSaucer(interaction) {
|
||||
if (interaction.isButton()) {
|
||||
const session = await requireInteractionSession(interaction);
|
||||
|
||||
async function showCrumbSaucer(
|
||||
interaction
|
||||
) {
|
||||
if (
|
||||
interaction.isButton()
|
||||
) {
|
||||
const session =
|
||||
await requireInteractionSession(
|
||||
interaction
|
||||
);
|
||||
|
||||
if (!session) {
|
||||
return;
|
||||
}
|
||||
|
||||
const balance = await getCookieBalance(interaction.user.id);
|
||||
const embed = buildMainMenuEmbed(interaction.user, balance);
|
||||
const balance =
|
||||
await getCookieBalance(
|
||||
interaction.user.id
|
||||
);
|
||||
|
||||
const embed =
|
||||
buildMainMenuEmbed(
|
||||
interaction.user,
|
||||
balance
|
||||
);
|
||||
|
||||
return interaction.update({
|
||||
embeds: [embed],
|
||||
components: buildMainMenuButtons()
|
||||
embeds: [
|
||||
embed
|
||||
],
|
||||
components:
|
||||
buildMainMenuButtons()
|
||||
});
|
||||
}
|
||||
|
||||
const balance = await getCookieBalance(interaction.user.id);
|
||||
const embed = buildMainMenuEmbed(interaction.user, balance);
|
||||
const balance =
|
||||
await getCookieBalance(
|
||||
interaction.user.id
|
||||
);
|
||||
|
||||
const embed =
|
||||
buildMainMenuEmbed(
|
||||
interaction.user,
|
||||
balance
|
||||
);
|
||||
|
||||
await interaction.reply({
|
||||
embeds: [embed],
|
||||
components: buildMainMenuButtons()
|
||||
embeds: [
|
||||
embed
|
||||
],
|
||||
components:
|
||||
buildMainMenuButtons()
|
||||
});
|
||||
|
||||
const message = await interaction.fetchReply();
|
||||
const message =
|
||||
await interaction.fetchReply();
|
||||
|
||||
await createInteractionSession(
|
||||
interaction,
|
||||
{
|
||||
messageId: message.id,
|
||||
ownerId: interaction.user.id,
|
||||
kind: "crumbSaucer"
|
||||
messageId:
|
||||
message.id,
|
||||
ownerId:
|
||||
interaction.user.id,
|
||||
kind:
|
||||
"crumbSaucer"
|
||||
}
|
||||
);
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
function buildMainMenuEmbed(user, balance) {
|
||||
|
||||
function buildMainMenuEmbed(
|
||||
user,
|
||||
balance
|
||||
) {
|
||||
return new EmbedBuilder()
|
||||
.setTitle("🎰 The Crumb Saucer")
|
||||
.setTitle(
|
||||
"🎰 The Crumb Saucer"
|
||||
)
|
||||
.setDescription(
|
||||
[
|
||||
`Welcome, ${user}!`,
|
||||
@@ -75,6 +115,9 @@ function buildMainMenuEmbed(user, balance) {
|
||||
"### 🎡 Roulette",
|
||||
"Bet on Red, Black, Odd, Even, or Zero.",
|
||||
"",
|
||||
"### ♠️ Poker",
|
||||
"Create or join a 2–6 player Cookie Poker table.",
|
||||
"",
|
||||
"### 🏦 Vault Heist",
|
||||
"Attempt to breach ShaiBot's Cookie Vault.",
|
||||
"",
|
||||
@@ -83,74 +126,172 @@ function buildMainMenuEmbed(user, balance) {
|
||||
].join("\n")
|
||||
)
|
||||
.addFields({
|
||||
name: "Your Vault",
|
||||
value: `**${balance.toLocaleString()} 🍪**`
|
||||
name:
|
||||
"Your Vault",
|
||||
value:
|
||||
`**${balance.toLocaleString()} 🍪**`
|
||||
})
|
||||
.setFooter({
|
||||
text: `crumbSaucer:${user.id}`
|
||||
text:
|
||||
`crumbSaucer:${user.id}`
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
function buildMainMenuButtons() {
|
||||
return [
|
||||
new ActionRowBuilder()
|
||||
.addComponents(
|
||||
new ButtonBuilder()
|
||||
.setCustomId("crumbSaucerSlots")
|
||||
.setLabel("Slots")
|
||||
.setEmoji("🎰")
|
||||
.setStyle(ButtonStyle.Primary),
|
||||
.setCustomId(
|
||||
"crumbSaucerSlots"
|
||||
)
|
||||
.setLabel(
|
||||
"Slots"
|
||||
)
|
||||
.setEmoji(
|
||||
"🎰"
|
||||
)
|
||||
.setStyle(
|
||||
ButtonStyle.Primary
|
||||
),
|
||||
|
||||
new ButtonBuilder()
|
||||
.setCustomId("crumbSaucerBlackjack")
|
||||
.setLabel("Blackjack")
|
||||
.setEmoji("🃏")
|
||||
.setStyle(ButtonStyle.Primary),
|
||||
.setCustomId(
|
||||
"crumbSaucerBlackjack"
|
||||
)
|
||||
.setLabel(
|
||||
"Blackjack"
|
||||
)
|
||||
.setEmoji(
|
||||
"🃏"
|
||||
)
|
||||
.setStyle(
|
||||
ButtonStyle.Primary
|
||||
),
|
||||
|
||||
new ButtonBuilder()
|
||||
.setCustomId("crumbSaucerRoulette")
|
||||
.setLabel("Roulette")
|
||||
.setEmoji("🎡")
|
||||
.setStyle(ButtonStyle.Primary),
|
||||
.setCustomId(
|
||||
"crumbSaucerRoulette"
|
||||
)
|
||||
.setLabel(
|
||||
"Roulette"
|
||||
)
|
||||
.setEmoji(
|
||||
"🎡"
|
||||
)
|
||||
.setStyle(
|
||||
ButtonStyle.Primary
|
||||
),
|
||||
|
||||
|
||||
new ButtonBuilder()
|
||||
.setCustomId("crumbSaucerHeist")
|
||||
.setLabel("Vault Heist")
|
||||
.setEmoji("🏦")
|
||||
.setStyle(ButtonStyle.Danger)
|
||||
.setCustomId(
|
||||
"crumbSaucerPoker"
|
||||
)
|
||||
.setLabel(
|
||||
"Poker"
|
||||
)
|
||||
.setEmoji(
|
||||
"♠️"
|
||||
)
|
||||
.setStyle(
|
||||
ButtonStyle.Primary
|
||||
),
|
||||
|
||||
|
||||
new ButtonBuilder()
|
||||
.setCustomId(
|
||||
"crumbSaucerHeist"
|
||||
)
|
||||
.setLabel(
|
||||
"Vault Heist"
|
||||
)
|
||||
.setEmoji(
|
||||
"🏦"
|
||||
)
|
||||
.setStyle(
|
||||
ButtonStyle.Danger
|
||||
)
|
||||
),
|
||||
|
||||
new ActionRowBuilder()
|
||||
.addComponents(
|
||||
new ButtonBuilder()
|
||||
.setCustomId("crumbSaucerCollectMenu")
|
||||
.setLabel("Cookie Bakery")
|
||||
.setEmoji("🍪")
|
||||
.setStyle(ButtonStyle.Secondary),
|
||||
.setCustomId(
|
||||
"crumbSaucerCollectMenu"
|
||||
)
|
||||
.setLabel(
|
||||
"Cookie Bakery"
|
||||
)
|
||||
.setEmoji(
|
||||
"🍪"
|
||||
)
|
||||
.setStyle(
|
||||
ButtonStyle.Secondary
|
||||
),
|
||||
|
||||
new ButtonBuilder()
|
||||
.setCustomId("crumbSaucerBankruptcy")
|
||||
.setLabel("Bankruptcy")
|
||||
.setEmoji("🛟")
|
||||
.setStyle(ButtonStyle.Secondary),
|
||||
.setCustomId(
|
||||
"crumbSaucerBankruptcy"
|
||||
)
|
||||
.setLabel(
|
||||
"Bankruptcy"
|
||||
)
|
||||
.setEmoji(
|
||||
"🛟"
|
||||
)
|
||||
.setStyle(
|
||||
ButtonStyle.Secondary
|
||||
),
|
||||
|
||||
new ButtonBuilder()
|
||||
.setCustomId("crumbSaucerProfile")
|
||||
.setLabel("Profile")
|
||||
.setEmoji("👤")
|
||||
.setStyle(ButtonStyle.Secondary),
|
||||
.setCustomId(
|
||||
"crumbSaucerProfile"
|
||||
)
|
||||
.setLabel(
|
||||
"Profile"
|
||||
)
|
||||
.setEmoji(
|
||||
"👤"
|
||||
)
|
||||
.setStyle(
|
||||
ButtonStyle.Secondary
|
||||
),
|
||||
|
||||
new ButtonBuilder()
|
||||
.setCustomId("sessionClose")
|
||||
.setLabel("Close")
|
||||
.setEmoji("✖️")
|
||||
.setStyle(ButtonStyle.Danger)
|
||||
.setCustomId(
|
||||
"sessionClose"
|
||||
)
|
||||
.setLabel(
|
||||
"Close"
|
||||
)
|
||||
.setEmoji(
|
||||
"✖️"
|
||||
)
|
||||
.setStyle(
|
||||
ButtonStyle.Danger
|
||||
)
|
||||
)
|
||||
];
|
||||
}
|
||||
|
||||
function getCrumbSaucerSessionOwner(interaction) {
|
||||
const footer = interaction.message?.embeds?.[0]?.footer?.text;
|
||||
const parts = footer?.split(":") ?? [];
|
||||
|
||||
switch (parts[0]) {
|
||||
function getCrumbSaucerSessionOwner(
|
||||
interaction
|
||||
) {
|
||||
const footer =
|
||||
interaction.message
|
||||
?.embeds?.[0]
|
||||
?.footer
|
||||
?.text;
|
||||
|
||||
const parts =
|
||||
footer?.split(":") ?? [];
|
||||
|
||||
switch (
|
||||
parts[0]
|
||||
) {
|
||||
case "crumbSaucer":
|
||||
case "crumbSaucerCollect":
|
||||
case "crumbSaucerSlots":
|
||||
@@ -171,6 +312,7 @@ function getCrumbSaucerSessionOwner(interaction) {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
module.exports = {
|
||||
showCrumbSaucer,
|
||||
buildMainMenuEmbed,
|
||||
|
||||
+10
-4
@@ -106,7 +106,9 @@ async function showSlots(interaction) {
|
||||
async function playSlots(
|
||||
interaction,
|
||||
bet,
|
||||
source = "normal"
|
||||
source = "normal",
|
||||
previousAllIn = false,
|
||||
previousBet = 0
|
||||
) {
|
||||
const ownerId =
|
||||
getSlotsSessionOwner(
|
||||
@@ -199,16 +201,20 @@ async function playSlots(
|
||||
throw error;
|
||||
}
|
||||
|
||||
await trackProfileEvent(
|
||||
await trackProfileEvent(
|
||||
interaction,
|
||||
playerId,
|
||||
{
|
||||
type: "game_start",
|
||||
game: "slots",
|
||||
source,
|
||||
bet
|
||||
wager: bet,
|
||||
allIn:
|
||||
bet === playerBalance,
|
||||
previousAllIn,
|
||||
previousBet
|
||||
}
|
||||
);
|
||||
);
|
||||
|
||||
await interaction.update({
|
||||
embeds: [
|
||||
|
||||
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
+362
-3
@@ -6,10 +6,18 @@ const {
|
||||
} = require("./profileSystem");
|
||||
|
||||
const {
|
||||
getUnixTime
|
||||
getUnixTime,
|
||||
changeCookies
|
||||
} = require("./cookieEconomy");
|
||||
|
||||
|
||||
const DEFAULT_MAINTENANCE_REWARD_ID =
|
||||
"maintenance_2026_08_27";
|
||||
|
||||
const DEFAULT_MAINTENANCE_REWARD_AMOUNT =
|
||||
5000;
|
||||
|
||||
|
||||
function getActiveDefinitions(
|
||||
achievementConfig
|
||||
) {
|
||||
@@ -356,6 +364,292 @@ async function reconcileAllAchievements({
|
||||
}
|
||||
|
||||
|
||||
|
||||
async function grantMaintenanceReward({
|
||||
guildId,
|
||||
rewardId =
|
||||
DEFAULT_MAINTENANCE_REWARD_ID,
|
||||
amount =
|
||||
DEFAULT_MAINTENANCE_REWARD_AMOUNT,
|
||||
dryRun = false
|
||||
}) {
|
||||
if (!guildId) {
|
||||
throw new Error(
|
||||
"guildId is required."
|
||||
);
|
||||
}
|
||||
|
||||
const normalizedRewardId =
|
||||
String(
|
||||
rewardId ?? ""
|
||||
).trim();
|
||||
|
||||
if (!normalizedRewardId) {
|
||||
throw new Error(
|
||||
"rewardId is required."
|
||||
);
|
||||
}
|
||||
|
||||
const normalizedAmount =
|
||||
Number(
|
||||
amount
|
||||
);
|
||||
|
||||
if (
|
||||
!Number.isSafeInteger(
|
||||
normalizedAmount
|
||||
)
|
||||
||
|
||||
normalizedAmount <= 0
|
||||
) {
|
||||
throw new Error(
|
||||
"Maintenance reward amount must be a positive safe integer."
|
||||
);
|
||||
}
|
||||
|
||||
const {
|
||||
stats
|
||||
} =
|
||||
getProfileCollections();
|
||||
|
||||
const userIds =
|
||||
await stats.distinct(
|
||||
"userId",
|
||||
{
|
||||
guildId
|
||||
}
|
||||
);
|
||||
|
||||
const summary = {
|
||||
guildId,
|
||||
rewardId:
|
||||
normalizedRewardId,
|
||||
amount:
|
||||
normalizedAmount,
|
||||
users:
|
||||
userIds.length,
|
||||
eligible:
|
||||
0,
|
||||
granted:
|
||||
0,
|
||||
alreadyClaimed:
|
||||
0,
|
||||
failed:
|
||||
0,
|
||||
dryRun,
|
||||
results: []
|
||||
};
|
||||
|
||||
for (const userId of userIds) {
|
||||
const existing =
|
||||
await stats.findOne(
|
||||
{
|
||||
userId,
|
||||
guildId
|
||||
},
|
||||
{
|
||||
projection: {
|
||||
"maintenance.rewardsClaimed":
|
||||
1
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const rewardsClaimed =
|
||||
Array.isArray(
|
||||
existing
|
||||
?.maintenance
|
||||
?.rewardsClaimed
|
||||
)
|
||||
? existing
|
||||
.maintenance
|
||||
.rewardsClaimed
|
||||
: [];
|
||||
|
||||
if (
|
||||
rewardsClaimed.includes(
|
||||
normalizedRewardId
|
||||
)
|
||||
) {
|
||||
summary.alreadyClaimed++;
|
||||
|
||||
summary.results.push({
|
||||
userId,
|
||||
status:
|
||||
"already_claimed"
|
||||
});
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
summary.eligible++;
|
||||
|
||||
if (dryRun) {
|
||||
summary.results.push({
|
||||
userId,
|
||||
status:
|
||||
"would_grant"
|
||||
});
|
||||
|
||||
console.log(
|
||||
`[ProfileMaintenance] Dry run: would grant ${normalizedAmount} Cookies to ${userId} for ${normalizedRewardId}.`
|
||||
);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
const reservation =
|
||||
await stats.updateOne(
|
||||
{
|
||||
userId,
|
||||
guildId,
|
||||
"maintenance.rewardsClaimed": {
|
||||
$ne:
|
||||
normalizedRewardId
|
||||
}
|
||||
},
|
||||
{
|
||||
$addToSet: {
|
||||
"maintenance.rewardsClaimed":
|
||||
normalizedRewardId
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (
|
||||
reservation.modifiedCount !==
|
||||
1
|
||||
) {
|
||||
summary.alreadyClaimed++;
|
||||
|
||||
summary.results.push({
|
||||
userId,
|
||||
status:
|
||||
"already_claimed"
|
||||
});
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await changeCookies(
|
||||
userId,
|
||||
normalizedAmount
|
||||
);
|
||||
|
||||
summary.granted++;
|
||||
|
||||
summary.results.push({
|
||||
userId,
|
||||
status:
|
||||
"granted"
|
||||
});
|
||||
|
||||
console.log(
|
||||
`[ProfileMaintenance] Granted ${normalizedAmount} Cookies to ${userId} for ${normalizedRewardId}.`
|
||||
);
|
||||
}
|
||||
|
||||
catch (error) {
|
||||
await stats.updateOne(
|
||||
{
|
||||
userId,
|
||||
guildId
|
||||
},
|
||||
{
|
||||
$pull: {
|
||||
"maintenance.rewardsClaimed":
|
||||
normalizedRewardId
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
summary.failed++;
|
||||
|
||||
summary.results.push({
|
||||
userId,
|
||||
status:
|
||||
"failed",
|
||||
error:
|
||||
error?.message ??
|
||||
String(error)
|
||||
});
|
||||
|
||||
console.error(
|
||||
`[ProfileMaintenance] Failed to grant ${normalizedAmount} Cookies to ${userId} for ${normalizedRewardId}:`,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[ProfileMaintenance] Maintenance reward ${normalizedRewardId}: ${summary.granted} granted, ${summary.alreadyClaimed} already claimed, ${summary.failed} failed, ${summary.users} stored profile user(s) checked.`
|
||||
);
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
|
||||
async function getMaintenanceRewardStatus({
|
||||
guildId,
|
||||
rewardId =
|
||||
DEFAULT_MAINTENANCE_REWARD_ID
|
||||
}) {
|
||||
if (!guildId) {
|
||||
throw new Error(
|
||||
"guildId is required."
|
||||
);
|
||||
}
|
||||
|
||||
const normalizedRewardId =
|
||||
String(
|
||||
rewardId ?? ""
|
||||
).trim();
|
||||
|
||||
if (!normalizedRewardId) {
|
||||
throw new Error(
|
||||
"rewardId is required."
|
||||
);
|
||||
}
|
||||
|
||||
const {
|
||||
stats
|
||||
} =
|
||||
getProfileCollections();
|
||||
|
||||
const [
|
||||
users,
|
||||
claimed
|
||||
] =
|
||||
await Promise.all([
|
||||
stats.countDocuments({
|
||||
guildId
|
||||
}),
|
||||
|
||||
stats.countDocuments({
|
||||
guildId,
|
||||
"maintenance.rewardsClaimed":
|
||||
normalizedRewardId
|
||||
})
|
||||
]);
|
||||
|
||||
return {
|
||||
guildId,
|
||||
rewardId:
|
||||
normalizedRewardId,
|
||||
amount:
|
||||
DEFAULT_MAINTENANCE_REWARD_AMOUNT,
|
||||
users,
|
||||
claimed,
|
||||
remaining:
|
||||
Math.max(
|
||||
0,
|
||||
users - claimed
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
async function auditProfileData(
|
||||
guildId
|
||||
) {
|
||||
@@ -460,7 +754,8 @@ async function auditProfileData(
|
||||
"slots",
|
||||
"blackjack",
|
||||
"roulette",
|
||||
"heist"
|
||||
"heist",
|
||||
"poker"
|
||||
]
|
||||
) {
|
||||
const value =
|
||||
@@ -783,6 +1078,65 @@ async function auditProfileData(
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* ----------------------------------------------------
|
||||
* MAINTENANCE REWARDS
|
||||
* ----------------------------------------------------
|
||||
*/
|
||||
|
||||
const rewardsClaimed =
|
||||
document
|
||||
.maintenance
|
||||
?.rewardsClaimed;
|
||||
|
||||
if (
|
||||
rewardsClaimed !==
|
||||
undefined
|
||||
&&
|
||||
!Array.isArray(
|
||||
rewardsClaimed
|
||||
)
|
||||
) {
|
||||
issues.push(
|
||||
`${document.userId}: maintenance.rewardsClaimed is not an array.`
|
||||
);
|
||||
}
|
||||
|
||||
else if (
|
||||
Array.isArray(
|
||||
rewardsClaimed
|
||||
)
|
||||
) {
|
||||
const uniqueRewards =
|
||||
new Set(
|
||||
rewardsClaimed
|
||||
);
|
||||
|
||||
if (
|
||||
uniqueRewards.size !==
|
||||
rewardsClaimed.length
|
||||
) {
|
||||
issues.push(
|
||||
`${document.userId}: maintenance.rewardsClaimed contains duplicate reward IDs.`
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
rewardsClaimed.some(
|
||||
rewardId =>
|
||||
typeof rewardId !==
|
||||
"string"
|
||||
||
|
||||
!rewardId.trim()
|
||||
)
|
||||
) {
|
||||
issues.push(
|
||||
`${document.userId}: maintenance.rewardsClaimed contains an invalid reward ID.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* ----------------------------------------------------
|
||||
* SCHEMA VERSION
|
||||
@@ -1407,5 +1761,10 @@ function isValidWeekKey(
|
||||
module.exports = {
|
||||
reconcileUserAchievements,
|
||||
reconcileAllAchievements,
|
||||
auditProfileData
|
||||
grantMaintenanceReward,
|
||||
getMaintenanceRewardStatus,
|
||||
auditProfileData,
|
||||
|
||||
DEFAULT_MAINTENANCE_REWARD_ID,
|
||||
DEFAULT_MAINTENANCE_REWARD_AMOUNT
|
||||
};
|
||||
+729
-26
@@ -409,6 +409,145 @@ async function ensureProfileSystem() {
|
||||
);
|
||||
}
|
||||
|
||||
async function evaluateSocialRelationshipFlags(
|
||||
userId,
|
||||
guildId,
|
||||
event
|
||||
) {
|
||||
const {
|
||||
stats
|
||||
} =
|
||||
getProfileCollections();
|
||||
|
||||
|
||||
const document =
|
||||
await stats.findOne({
|
||||
userId,
|
||||
guildId
|
||||
});
|
||||
|
||||
|
||||
if (!document) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
const social =
|
||||
document.social ?? {};
|
||||
|
||||
|
||||
const set = {};
|
||||
|
||||
|
||||
/*
|
||||
* ========================================================
|
||||
* ET TU, CRUMB?
|
||||
* ========================================================
|
||||
*
|
||||
* Successfully steal from somebody who has
|
||||
* previously gifted Cookies to you.
|
||||
*/
|
||||
|
||||
if (
|
||||
event.type ===
|
||||
"steal_success"
|
||||
&&
|
||||
event.targetUserId
|
||||
&&
|
||||
Array.isArray(
|
||||
social.receivedFrom
|
||||
)
|
||||
&&
|
||||
social.receivedFrom.includes(
|
||||
event.targetUserId
|
||||
)
|
||||
) {
|
||||
set[
|
||||
"special.etTuCrumb"
|
||||
] = 1;
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* ========================================================
|
||||
* NO HARD FEELINGS
|
||||
* ========================================================
|
||||
*
|
||||
* Give Cookies to somebody who has previously
|
||||
* successfully stolen from you.
|
||||
*/
|
||||
|
||||
if (
|
||||
event.type ===
|
||||
"cookies_given"
|
||||
&&
|
||||
event.targetUserId
|
||||
&&
|
||||
Array.isArray(
|
||||
social.successfullyStolenBy
|
||||
)
|
||||
&&
|
||||
social.successfullyStolenBy.includes(
|
||||
event.targetUserId
|
||||
)
|
||||
) {
|
||||
set[
|
||||
"special.noHardFeelings"
|
||||
] = 1;
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* ========================================================
|
||||
* COOKIE LAUNDERING
|
||||
* ========================================================
|
||||
*
|
||||
* Give Cookies to somebody you have previously
|
||||
* successfully stolen from.
|
||||
*/
|
||||
|
||||
if (
|
||||
event.type ===
|
||||
"cookies_given"
|
||||
&&
|
||||
event.targetUserId
|
||||
&&
|
||||
Array.isArray(
|
||||
social.successfullyStolenFrom
|
||||
)
|
||||
&&
|
||||
social.successfullyStolenFrom.includes(
|
||||
event.targetUserId
|
||||
)
|
||||
) {
|
||||
set[
|
||||
"special.cookieLaundering"
|
||||
] = 1;
|
||||
}
|
||||
|
||||
|
||||
if (
|
||||
Object.keys(
|
||||
set
|
||||
).length ===
|
||||
0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
await stats.updateOne(
|
||||
{
|
||||
userId,
|
||||
guildId
|
||||
},
|
||||
{
|
||||
$set:
|
||||
set
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function getDefaultStats(userId, guildId) {
|
||||
return {
|
||||
userId,
|
||||
@@ -486,8 +625,21 @@ function getDefaultStats(userId, guildId) {
|
||||
bestLoss: 0
|
||||
},
|
||||
|
||||
social: {
|
||||
giftedTo: [],
|
||||
receivedFrom: [],
|
||||
successfullyStolenFrom: [],
|
||||
successfullyStolenBy: []
|
||||
},
|
||||
|
||||
special: {
|
||||
allInRunItBack: 0
|
||||
allInRunItBack: 0,
|
||||
bankruptcyCount: 0,
|
||||
bankruptcyRecoveryBest: 0,
|
||||
maxUniqueStealAttackers: 0,
|
||||
etTuCrumb: 0,
|
||||
noHardFeelings: 0,
|
||||
cookieLaundering: 0
|
||||
},
|
||||
|
||||
activity: {
|
||||
@@ -517,6 +669,22 @@ function getDefaultStats(userId, guildId) {
|
||||
evening: 0
|
||||
}
|
||||
},
|
||||
daily: {
|
||||
dateKey: null,
|
||||
|
||||
gamesPlayed: [],
|
||||
gamesWon: [],
|
||||
gamesLost: [],
|
||||
|
||||
collectedCookies: false,
|
||||
gambledAfterCollect: false,
|
||||
|
||||
wonAnyGame: false,
|
||||
lostAnyGame: false,
|
||||
|
||||
gaveCookies: false,
|
||||
stoleCookies: false
|
||||
},
|
||||
|
||||
profileSchemaVersion: 2
|
||||
};
|
||||
@@ -590,16 +758,54 @@ async function trackProfileEvent(
|
||||
switch (event.type) {
|
||||
case "cookie_gain":
|
||||
await stats.updateOne(
|
||||
{ userId, guildId },
|
||||
{
|
||||
userId,
|
||||
guildId
|
||||
},
|
||||
{
|
||||
$inc: {
|
||||
"economy.cookiesObtainedTotal":
|
||||
event.amount
|
||||
},
|
||||
|
||||
...(
|
||||
event.source === "give" &&
|
||||
event.senderUserId
|
||||
? {
|
||||
$addToSet: {
|
||||
"social.receivedFrom":
|
||||
event.senderUserId
|
||||
},
|
||||
|
||||
$set: {
|
||||
"special.lastGiftSenderId":
|
||||
event.senderUserId
|
||||
}
|
||||
}
|
||||
: {}
|
||||
)
|
||||
}
|
||||
);
|
||||
break;
|
||||
case "bankruptcy":
|
||||
await stats.updateOne(
|
||||
{
|
||||
userId,
|
||||
guildId
|
||||
},
|
||||
{
|
||||
$inc: {
|
||||
"special.bankruptcyCount":
|
||||
1
|
||||
},
|
||||
|
||||
$set: {
|
||||
"special.bankruptcyRecoveryBest":
|
||||
0
|
||||
}
|
||||
}
|
||||
);
|
||||
break;
|
||||
|
||||
case "cookie_loss":
|
||||
await stats.updateOne(
|
||||
{ userId, guildId },
|
||||
@@ -614,48 +820,102 @@ async function trackProfileEvent(
|
||||
|
||||
case "cookies_given":
|
||||
await stats.updateOne(
|
||||
{ userId, guildId },
|
||||
{
|
||||
userId,
|
||||
guildId
|
||||
},
|
||||
{
|
||||
$inc: {
|
||||
"economy.cookiesGivenTotal":
|
||||
event.amount
|
||||
},
|
||||
|
||||
...(event.targetUserId
|
||||
? {
|
||||
$addToSet: {
|
||||
"social.giftedTo":
|
||||
event.targetUserId
|
||||
},
|
||||
|
||||
$set: {
|
||||
"special.lastGiftTargetId":
|
||||
event.targetUserId
|
||||
}
|
||||
}
|
||||
: {})
|
||||
}
|
||||
);
|
||||
break;
|
||||
case "steal_targeted":
|
||||
await stats.updateOne(
|
||||
{
|
||||
userId,
|
||||
guildId
|
||||
},
|
||||
{
|
||||
$max: {
|
||||
"special.maxUniqueStealAttackers":
|
||||
Number(
|
||||
event.uniqueAttackers ?? 0
|
||||
)
|
||||
}
|
||||
}
|
||||
);
|
||||
break;
|
||||
|
||||
case "steal_success":
|
||||
await stats.updateOne(
|
||||
{ userId, guildId },
|
||||
{
|
||||
userId,
|
||||
guildId
|
||||
},
|
||||
{
|
||||
$inc: {
|
||||
"economy.stolenTotal":
|
||||
event.amount
|
||||
},
|
||||
|
||||
$addToSet: {
|
||||
"unique.playersStolenFrom":
|
||||
event.targetUserId,
|
||||
|
||||
"social.successfullyStolenFrom":
|
||||
event.targetUserId
|
||||
},
|
||||
|
||||
$set: {
|
||||
"special.lastSuccessfulStealTargetId":
|
||||
event.targetUserId
|
||||
}
|
||||
}
|
||||
);
|
||||
break;
|
||||
|
||||
case "stolen_from_me":
|
||||
await stats.updateOne(
|
||||
{ userId, guildId },
|
||||
{
|
||||
userId,
|
||||
guildId
|
||||
},
|
||||
{
|
||||
$inc: {
|
||||
"economy.stolenFromMeTotal":
|
||||
event.amount
|
||||
},
|
||||
|
||||
$addToSet: {
|
||||
"unique.playersStolenBy":
|
||||
event.thiefUserId,
|
||||
|
||||
"social.successfullyStolenBy":
|
||||
event.thiefUserId
|
||||
},
|
||||
|
||||
$set: {
|
||||
"special.lastSuccessfulThiefId":
|
||||
event.thiefUserId
|
||||
}
|
||||
}
|
||||
);
|
||||
break;
|
||||
|
||||
case "vault_steal":
|
||||
await stats.updateOne(
|
||||
{ userId, guildId },
|
||||
@@ -690,10 +950,60 @@ async function trackProfileEvent(
|
||||
);
|
||||
break;
|
||||
|
||||
case "profile_refresh":
|
||||
break;
|
||||
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
if (
|
||||
[
|
||||
"steal_success",
|
||||
"cookies_given"
|
||||
].includes(
|
||||
event.type
|
||||
)
|
||||
) {
|
||||
await evaluateSocialRelationshipFlags(
|
||||
userId,
|
||||
guildId,
|
||||
event
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
if (
|
||||
[
|
||||
"cookie_gain",
|
||||
"cookie_loss",
|
||||
"game_result",
|
||||
"bankruptcy"
|
||||
].includes(
|
||||
event.type
|
||||
)
|
||||
) {
|
||||
await updateBankruptcyRecovery(
|
||||
userId,
|
||||
guildId
|
||||
);
|
||||
}
|
||||
if (
|
||||
event.type ===
|
||||
"game_result"
|
||||
) {
|
||||
await trackDailyGameState(
|
||||
userId,
|
||||
guildId,
|
||||
event
|
||||
);
|
||||
}
|
||||
await trackDailyNarrativeState(
|
||||
userId,
|
||||
guildId,
|
||||
event
|
||||
);
|
||||
const updatedStats =
|
||||
await getProfileStats(
|
||||
userId,
|
||||
@@ -804,18 +1114,22 @@ async function trackGameStart(
|
||||
|
||||
const set = {};
|
||||
|
||||
const previousBet = Number(
|
||||
event.previousBet ?? 0
|
||||
const previousWager = Number(
|
||||
event.previousWager ??
|
||||
event.previousBet ??
|
||||
0
|
||||
);
|
||||
|
||||
const currentBet = Number(
|
||||
event.bet ?? 0
|
||||
const currentWager = Number(
|
||||
event.wager ??
|
||||
event.bet ??
|
||||
0
|
||||
);
|
||||
|
||||
const chainedAllIn =
|
||||
event.previousAllIn === true &&
|
||||
previousBet > 0 &&
|
||||
currentBet === previousBet;
|
||||
previousWager > 0 &&
|
||||
currentWager === previousWager;
|
||||
|
||||
if (chainedAllIn) {
|
||||
set["special.allInRunItBack"] = 1;
|
||||
@@ -1401,8 +1715,21 @@ async function evaluateBadges(
|
||||
event.type === "game_start" &&
|
||||
event.source === "run_it_back" &&
|
||||
event.previousAllIn === true &&
|
||||
Number(event.previousBet) > 0 &&
|
||||
Number(event.bet) === Number(event.previousBet)
|
||||
Number(
|
||||
event.previousWager ??
|
||||
event.previousBet ??
|
||||
0
|
||||
) > 0 &&
|
||||
Number(
|
||||
event.wager ??
|
||||
event.bet ??
|
||||
0
|
||||
) ===
|
||||
Number(
|
||||
event.previousWager ??
|
||||
event.previousBet ??
|
||||
0
|
||||
)
|
||||
) {
|
||||
await tryClaimBadge(
|
||||
interaction,
|
||||
@@ -1434,15 +1761,14 @@ async function evaluateCollectorBadges(
|
||||
.find({
|
||||
active: {
|
||||
$ne: false
|
||||
},
|
||||
measure: {
|
||||
$ne:
|
||||
"achievementPercentage"
|
||||
}
|
||||
})
|
||||
.toArray()
|
||||
).filter(achievement =>
|
||||
Boolean(achievement.stat)
|
||||
).filter(
|
||||
achievement =>
|
||||
isStatBackfillableAchievement(
|
||||
achievement
|
||||
)
|
||||
);
|
||||
|
||||
if (
|
||||
@@ -1733,7 +2059,8 @@ async function buildStatsEmbed(
|
||||
["🎰 Slots", stats.games?.slots],
|
||||
["🃏 Blackjack", stats.games?.blackjack],
|
||||
["🎡 Roulette", stats.games?.roulette],
|
||||
["🏦 Heist", stats.games?.heist]
|
||||
["🏦 Heist", stats.games?.heist],
|
||||
["♠️ Poker", stats.games?.poker]
|
||||
].map(([name, game = {}]) => {
|
||||
const played =
|
||||
Number(game.played ?? 0);
|
||||
@@ -2070,8 +2397,9 @@ async function buildAchievementsEmbed(
|
||||
const collectibleDefinitions =
|
||||
definitions.filter(
|
||||
definition =>
|
||||
definition.measure !==
|
||||
"achievementPercentage"
|
||||
isStatBackfillableAchievement(
|
||||
definition
|
||||
)
|
||||
);
|
||||
|
||||
const unlockedCollectible =
|
||||
@@ -2614,12 +2942,18 @@ function formatCategoryName(
|
||||
"🎡 Roulette",
|
||||
heist:
|
||||
"🏦 Heist",
|
||||
poker:
|
||||
"♠️ Poker",
|
||||
collector:
|
||||
"🏆 Collector",
|
||||
other:
|
||||
"🏅 Other",
|
||||
activity:
|
||||
"📅 Activity",
|
||||
vault:
|
||||
"🏦 Vault",
|
||||
victim:
|
||||
"🎯 Victim",
|
||||
};
|
||||
|
||||
return labels[category] ??
|
||||
@@ -3279,6 +3613,371 @@ async function evaluateEventAchievements(
|
||||
);
|
||||
}
|
||||
}
|
||||
async function updateBankruptcyRecovery(
|
||||
userId,
|
||||
guildId
|
||||
) {
|
||||
const {
|
||||
stats
|
||||
} =
|
||||
getProfileCollections();
|
||||
|
||||
const document =
|
||||
await stats.findOne(
|
||||
{
|
||||
userId,
|
||||
guildId
|
||||
},
|
||||
{
|
||||
projection: {
|
||||
"special.bankruptcyCount":
|
||||
1
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
if (
|
||||
Number(
|
||||
document
|
||||
?.special
|
||||
?.bankruptcyCount ?? 0
|
||||
) <= 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
const balance =
|
||||
await getCookieBalance(
|
||||
userId
|
||||
);
|
||||
|
||||
|
||||
await stats.updateOne(
|
||||
{
|
||||
userId,
|
||||
guildId
|
||||
},
|
||||
{
|
||||
$max: {
|
||||
"special.bankruptcyRecoveryBest":
|
||||
balance
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
async function trackDailyGameState(
|
||||
userId,
|
||||
guildId,
|
||||
event
|
||||
) {
|
||||
if (
|
||||
event.type !==
|
||||
"game_result"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const game =
|
||||
String(
|
||||
event.game ?? ""
|
||||
).toLowerCase();
|
||||
|
||||
const result =
|
||||
String(
|
||||
event.result ?? ""
|
||||
).toLowerCase();
|
||||
|
||||
if (
|
||||
![
|
||||
"slots",
|
||||
"blackjack",
|
||||
"roulette",
|
||||
"heist"
|
||||
].includes(
|
||||
game
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const {
|
||||
stats
|
||||
} =
|
||||
getProfileCollections();
|
||||
|
||||
const {
|
||||
dateKey
|
||||
} =
|
||||
getActivityDateContext();
|
||||
|
||||
const document =
|
||||
await stats.findOne(
|
||||
{
|
||||
userId,
|
||||
guildId
|
||||
},
|
||||
{
|
||||
projection: {
|
||||
daily: 1
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const currentDateKey =
|
||||
document
|
||||
?.daily
|
||||
?.dateKey ?? null;
|
||||
|
||||
if (
|
||||
currentDateKey !==
|
||||
dateKey
|
||||
) {
|
||||
await stats.updateOne(
|
||||
{
|
||||
userId,
|
||||
guildId
|
||||
},
|
||||
{
|
||||
$set: {
|
||||
"daily.dateKey":
|
||||
dateKey,
|
||||
|
||||
"daily.gamesPlayed":
|
||||
[game],
|
||||
|
||||
"daily.gamesWon":
|
||||
result === "win"
|
||||
? [game]
|
||||
: [],
|
||||
|
||||
"daily.gamesLost":
|
||||
result === "loss"
|
||||
? [game]
|
||||
: [],
|
||||
|
||||
"daily.collectedCookies":
|
||||
false,
|
||||
|
||||
"daily.gambledAfterCollect":
|
||||
false,
|
||||
|
||||
"daily.wonAnyGame":
|
||||
result === "win",
|
||||
|
||||
"daily.lostAnyGame":
|
||||
result === "loss",
|
||||
|
||||
"daily.gaveCookies":
|
||||
false,
|
||||
|
||||
"daily.stoleCookies":
|
||||
false
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const addToSet = {
|
||||
"daily.gamesPlayed":
|
||||
game
|
||||
};
|
||||
|
||||
const set = {};
|
||||
|
||||
if (
|
||||
result ===
|
||||
"win"
|
||||
) {
|
||||
addToSet[
|
||||
"daily.gamesWon"
|
||||
] = game;
|
||||
|
||||
set[
|
||||
"daily.wonAnyGame"
|
||||
] = true;
|
||||
}
|
||||
|
||||
if (
|
||||
result ===
|
||||
"loss"
|
||||
) {
|
||||
addToSet[
|
||||
"daily.gamesLost"
|
||||
] = game;
|
||||
|
||||
set[
|
||||
"daily.lostAnyGame"
|
||||
] = true;
|
||||
}
|
||||
|
||||
await stats.updateOne(
|
||||
{
|
||||
userId,
|
||||
guildId
|
||||
},
|
||||
{
|
||||
$addToSet:
|
||||
addToSet,
|
||||
|
||||
...(
|
||||
Object.keys(
|
||||
set
|
||||
).length
|
||||
? {
|
||||
$set:
|
||||
set
|
||||
}
|
||||
: {}
|
||||
)
|
||||
}
|
||||
);
|
||||
}
|
||||
async function trackDailyNarrativeState(
|
||||
userId,
|
||||
guildId,
|
||||
event
|
||||
) {
|
||||
const {
|
||||
stats
|
||||
} =
|
||||
getProfileCollections();
|
||||
|
||||
const {
|
||||
dateKey
|
||||
} =
|
||||
getActivityDateContext();
|
||||
|
||||
const document =
|
||||
await stats.findOne(
|
||||
{
|
||||
userId,
|
||||
guildId
|
||||
},
|
||||
{
|
||||
projection: {
|
||||
daily: 1
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const currentDateKey =
|
||||
document
|
||||
?.daily
|
||||
?.dateKey ?? null;
|
||||
|
||||
if (
|
||||
currentDateKey !==
|
||||
dateKey
|
||||
) {
|
||||
await stats.updateOne(
|
||||
{
|
||||
userId,
|
||||
guildId
|
||||
},
|
||||
{
|
||||
$set: {
|
||||
"daily.dateKey":
|
||||
dateKey,
|
||||
|
||||
"daily.gamesPlayed":
|
||||
[],
|
||||
|
||||
"daily.gamesWon":
|
||||
[],
|
||||
|
||||
"daily.gamesLost":
|
||||
[],
|
||||
|
||||
"daily.collectedCookies":
|
||||
false,
|
||||
|
||||
"daily.gambledAfterCollect":
|
||||
false,
|
||||
|
||||
"daily.wonAnyGame":
|
||||
false,
|
||||
|
||||
"daily.lostAnyGame":
|
||||
false,
|
||||
|
||||
"daily.gaveCookies":
|
||||
false,
|
||||
|
||||
"daily.stoleCookies":
|
||||
false
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const set = {};
|
||||
|
||||
if (
|
||||
event.type ===
|
||||
"cookie_gain"
|
||||
&&
|
||||
event.source ===
|
||||
"collect"
|
||||
) {
|
||||
set[
|
||||
"daily.collectedCookies"
|
||||
] = true;
|
||||
}
|
||||
|
||||
if (
|
||||
event.type ===
|
||||
"cookies_given"
|
||||
) {
|
||||
set[
|
||||
"daily.gaveCookies"
|
||||
] = true;
|
||||
}
|
||||
|
||||
if (
|
||||
event.type ===
|
||||
"steal_success"
|
||||
) {
|
||||
set[
|
||||
"daily.stoleCookies"
|
||||
] = true;
|
||||
}
|
||||
|
||||
if (
|
||||
event.type ===
|
||||
"game_start"
|
||||
&&
|
||||
document
|
||||
?.daily
|
||||
?.collectedCookies === true
|
||||
) {
|
||||
set[
|
||||
"daily.gambledAfterCollect"
|
||||
] = true;
|
||||
}
|
||||
|
||||
if (
|
||||
Object.keys(
|
||||
set
|
||||
).length === 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
await stats.updateOne(
|
||||
{
|
||||
userId,
|
||||
guildId
|
||||
},
|
||||
{
|
||||
$set:
|
||||
set
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ensureProfileSystem,
|
||||
@@ -3288,6 +3987,7 @@ module.exports = {
|
||||
getProfileCollections,
|
||||
getDefaultStats,
|
||||
getCompositeProgress,
|
||||
updateBankruptcyRecovery,
|
||||
|
||||
readStatValue,
|
||||
readStatPath,
|
||||
@@ -3316,4 +4016,7 @@ module.exports = {
|
||||
evaluateEventCondition,
|
||||
evaluateEventAchievement,
|
||||
evaluateEventAchievements,
|
||||
evaluateSocialRelationshipFlags,
|
||||
trackDailyGameState,
|
||||
trackDailyNarrativeState,
|
||||
};
|
||||
Reference in New Issue
Block a user