Refresh repository documentation
Some checks failed
Deploy to Production / test (push) Failing after 33s
Some checks failed
Deploy to Production / test (push) Failing after 33s
- Rewrite AGENTS and README files to match the current app layout - Document API routes, trivia UI, and the active panel design language
This commit is contained in:
@@ -1,11 +1,36 @@
|
||||
# Economy Module
|
||||
# Economy module
|
||||
|
||||
- All currency values are `bigint`. Never use `Number()` for arithmetic on balances -- use BigInt literals (e.g., `0n`, `500n`) and `sql` template expressions for DB updates.
|
||||
- `modifyUserBalance` is the canonical way to change a user's balance. It checks for insufficient funds on negative amounts, logs a transaction record, and emits `BALANCE_CHANGED` for quest progression. Bypass it only if you have a very good reason.
|
||||
- Daily rewards reset at **UTC midnight**, not 24h from last claim. The cooldown `expiresAt` is set to the next UTC 00:00:00. Streak breaks if the user misses an entire 24h window after the cooldown expired.
|
||||
- Daily reward is capped at `MAX_DAILY_REWARD = 500n` regardless of streak/weekly bonus.
|
||||
- The streak has a grace period: if a user's timer record is missing (e.g., DB migration), the code allows one "free" increment to avoid unfair resets.
|
||||
- Weekly bonus triggers every 7th consecutive day (streak % 7 === 0).
|
||||
- **Exam system**: a weekly check-in that rewards users based on XP gained since their last exam. The reward uses scaled BigInt arithmetic (`* 10000 / 10000n`) to avoid floating-point precision loss. Exams are locked to a specific day of the week set at registration time. Missing your exam day means zero reward -- there is no retroactive claim.
|
||||
- Lootdrops use **in-memory state** (`Map`s for channel activity and cooldowns). This state is lost on restart. The DB stores only spawned/claimed drops. Claiming uses an atomic `UPDATE ... WHERE claimedBy IS NULL` to prevent race conditions.
|
||||
- Lootdrops expire after 10 minutes and are cleaned up by a 60-second interval.
|
||||
This area is split across three services:
|
||||
|
||||
- `economy.service.ts`
|
||||
- `exam.service.ts`
|
||||
- `lootdrop.service.ts`
|
||||
|
||||
## Core rules
|
||||
|
||||
- Currency values are `bigint`
|
||||
- `modifyUserBalance()` is the canonical balance mutator for most features because it logs a transaction and emits `BALANCE_CHANGED`
|
||||
- direct balance updates still exist in a few flows where the service owns the full transaction, such as trivia entry/win and exam payout
|
||||
|
||||
## Daily rewards
|
||||
|
||||
- `claimDaily()` uses a UTC-midnight cooldown, not a rolling 24-hour timer
|
||||
- streak bonus is linear from `config.economy.daily.streakBonus`
|
||||
- weekly bonus applies every seventh claim
|
||||
- total daily reward is capped at `500n`
|
||||
- missing more than 24 hours after the cooldown expired resets the streak
|
||||
|
||||
## Weekly exam
|
||||
|
||||
- stored in `user_timers` with type `EXAM_SYSTEM`
|
||||
- registration locks the user to the current weekday
|
||||
- payout is based on XP gained since the previous exam snapshot
|
||||
- missing the assigned weekday rolls the timer forward and pays nothing
|
||||
|
||||
## Lootdrops
|
||||
|
||||
- channel activity and cooldowns are kept in memory
|
||||
- spawned drops are persisted in the `lootdrops` table
|
||||
- claiming uses an atomic update where `claimedBy IS NULL`
|
||||
- expired drops and stale activity are cleaned every 60 seconds
|
||||
- spawned drops expire after 10 minutes
|
||||
|
||||
@@ -1,9 +1,24 @@
|
||||
# Feature Flags Module
|
||||
# Feature flags module
|
||||
|
||||
- **No caching.** Every `isFlagEnabled()` and `hasAccess()` call hits the database directly.
|
||||
- `isFlagEnabled(flagName)` checks global on/off state. `hasAccess(flagName, context)` checks both global state AND per-entity access records (guild, user, or role).
|
||||
- Access logic: flag must be globally enabled AND user must have an explicit access grant. Grants can target guildId, userId, or roleId independently.
|
||||
- Commands declare `beta: true` and optionally `featureFlag: string` in the Command interface. `CommandHandler` intercepts beta commands and calls `hasAccess()` before execution.
|
||||
- If a command has no explicit `featureFlag`, the command name (`interaction.commandName`) is used as the flag name fallback.
|
||||
- Flag names are case-sensitive. Convention is snake_case or camelCase — no enforcement.
|
||||
- Admin management via `/featureflags` command: CRUD on flags and access grants/revokes.
|
||||
## Behavior
|
||||
|
||||
- no in-memory caching; each check reads from the database
|
||||
- a flag must exist and be globally enabled before any access grant matters
|
||||
- grants can target a guild, a user, or a role
|
||||
|
||||
## Main entrypoints
|
||||
|
||||
- `isFlagEnabled(flagName)`
|
||||
- `hasAccess(flagName, { guildId, userId, memberRoles })`
|
||||
- `createFlag(name, description?)`
|
||||
- `setFlagEnabled(name, enabled)`
|
||||
- `grantAccess(flagName, { guildId?, userId?, roleId? })`
|
||||
- `revokeAccess(accessId)`
|
||||
- `listFlags()`
|
||||
- `listAccess(flagName)`
|
||||
- `deleteFlag(name)`
|
||||
|
||||
## Command integration
|
||||
|
||||
- `bot/lib/handlers/CommandHandler.ts` checks beta commands through this service
|
||||
- if a command does not set `featureFlag`, the slash command name is used as the fallback flag name
|
||||
|
||||
@@ -1,10 +1,32 @@
|
||||
# Guild Settings Module
|
||||
# Guild settings module
|
||||
|
||||
- `updateSetting()` uses a hardcoded `keyMap` to map friendly key names to DB columns. Use exact key names (e.g., `"studentRole"` not `"studentRoleId"`). Unknown keys throw `UserError`.
|
||||
- Type coercion per column: Discord IDs → BigInt automatically; `colorRoleIds` must be array; `featureOverrides` must be object; `moderationDmOnWarn` must be boolean; `moderationAutoTimeoutThreshold` must be number. Null values set columns to NULL.
|
||||
- **Caching:** `getGuildConfig()` (in `shared/lib/config.ts`) caches transformed settings for 60 seconds. Every mutation (`upsertSettings`, `updateSetting`, `addColorRole`, `removeColorRole`) calls `invalidateGuildConfigCache(guildId)` immediately.
|
||||
- If settings don't exist for a guild, the cache returns safe defaults — no errors thrown.
|
||||
- `featureOverrides` is a sparse `Record<string, boolean>` — no keys are predefined. Consumers must check key existence.
|
||||
- **No Discord validation:** The service does not verify that role/channel IDs actually exist in Discord. Invalid IDs are stored silently.
|
||||
- `addColorRole()` / `removeColorRole()` fetch the full settings, mutate the array in JS, then upsert — this is not atomic and can race under concurrent requests.
|
||||
- `terminalMessageId` and `terminalChannelId` are separate DB columns but grouped as `terminal: { channelId, messageId }` in the cached config. Setting one without the other can create orphaned data.
|
||||
## Responsibilities
|
||||
|
||||
- store raw per-guild settings in `guild_settings`
|
||||
- convert DB rows to string-friendly objects for the API
|
||||
- support the cached runtime view returned by `shared/lib/config.ts`
|
||||
|
||||
## Main methods
|
||||
|
||||
- `getSettings(guildId)`
|
||||
- `upsertSettings({ guildId, ...fields })`
|
||||
- `updateSetting(guildId, key, value)`
|
||||
- `deleteSettings(guildId)`
|
||||
- `addColorRole(guildId, roleId)`
|
||||
- `removeColorRole(guildId, roleId)`
|
||||
|
||||
## Runtime cache
|
||||
|
||||
- `shared/lib/config.ts` caches `getGuildConfig()` results for 60 seconds
|
||||
- API writes invalidate that cache immediately
|
||||
- the cached runtime shape is not identical to the DB shape:
|
||||
- `studentRoleId` -> `studentRole`
|
||||
- `visitorRoleId` -> `visitorRole`
|
||||
- `colorRoleIds` -> `colorRoles`
|
||||
- terminal fields are grouped under `terminal`
|
||||
|
||||
## Notes
|
||||
|
||||
- `updateSetting()` accepts friendly keys like `studentRole`, `welcomeChannel`, and `terminalMessage`
|
||||
- Discord IDs are stored as `bigint` in the DB and exposed as strings from the service
|
||||
- `addColorRole()` and `removeColorRole()` read-modify-write the whole array, so they are not atomic under concurrent updates
|
||||
|
||||
@@ -1,11 +1,37 @@
|
||||
# Inventory Module
|
||||
# Inventory module
|
||||
|
||||
- Inventory has two hard limits from config: **max slots** (distinct item types) and **max stack size** (quantity per item). Both are enforced in `addItem` and will throw `UserError` if exceeded.
|
||||
- When quantity reaches 0, the inventory row is **deleted** (not kept with quantity 0). This means slot count = row count.
|
||||
- `buyItem` delegates balance deduction to `economyService.modifyUserBalance` within the same transaction to ensure atomicity. Never deduct balance directly when purchasing.
|
||||
- Item usage is driven by a JSON `usageData` field on the item record. Items without `usageData.effects` cannot be used. The `consume` flag in `usageData` controls whether the item is removed after use.
|
||||
- **Effect system**: effects are validated at runtime via Zod (`EffectPayloadSchema`) before execution. The registry maps effect type strings to handler functions. Adding a new effect type requires: (1) add to `EffectType` enum in constants, (2) add Zod schema variant in `effect.types.ts`, (3) add handler in `effect.handlers.ts`, (4) register in `effect.registry.ts`.
|
||||
- `XP_BOOST` and `TEMP_ROLE` effects use `userTimers` with upsert -- activating while already active **replaces** the timer (does not stack or extend).
|
||||
- `TEMP_ROLE` only records the timer in DB; actual Discord role assignment must happen in the bot command layer.
|
||||
- `LOOTBOX` effect uses weighted random selection. Weights are relative, not percentages. A `NOTHING` loot type is valid and intentional.
|
||||
- The `getAutocompleteItems` method filters to only show items that have usable effects, so non-usable items won't appear in the `/use` autocomplete.
|
||||
## Main methods
|
||||
|
||||
- `addItem()`
|
||||
- `removeItem()`
|
||||
- `getInventory()`
|
||||
- `buyItem()`
|
||||
- `getItem()`
|
||||
- `useItem()`
|
||||
- `getAutocompleteItems()`
|
||||
|
||||
## Rules
|
||||
|
||||
- max slots and max stack size come from runtime config
|
||||
- removing the last quantity deletes the inventory row
|
||||
- `buyItem()` uses `economyService.modifyUserBalance()` and `addItem()` in one transaction
|
||||
|
||||
## Item usage
|
||||
|
||||
- item behavior is driven by `items.usageData`
|
||||
- items without `usageData.effects` are not usable
|
||||
- `usageData.consume` controls whether the item is removed after use
|
||||
- effect execution is routed through `effect.registry.ts`
|
||||
|
||||
To add a new effect type, update:
|
||||
|
||||
- `shared/lib/constants.ts`
|
||||
- `effect.types.ts`
|
||||
- `effect.handlers.ts`
|
||||
- `effect.registry.ts`
|
||||
|
||||
## Notes
|
||||
|
||||
- XP boost and temp-role effects are timer-based and overwrite existing timers rather than stacking
|
||||
- temp-role effects only write timer data; actual Discord role assignment is handled outside this service
|
||||
- autocomplete only returns usable items
|
||||
|
||||
@@ -1,9 +1,26 @@
|
||||
# Leveling Module
|
||||
# Leveling module
|
||||
|
||||
- **Level is derived, not stored.** Total XP is the source of truth. `getLevelFromXp()` recalculates level from cumulative XP on every `addXp()` call. Levels are monotonic — they never decrease.
|
||||
- XP curve is a power law: `xpForLevel(n) = floor(base * n^exponent)` where defaults are `base: 100`, `exponent: 1.5`. Config comes from `gameSettingsService` (30s cache TTL).
|
||||
- Chat XP (`processChatXp()`) awards random XP between `minXp` (5) and `maxXp` (15) per message, gated by a 60-second per-user cooldown (`TimerType.COOLDOWN`, key `TimerKey.CHAT_XP`). The cooldown is upserted atomically.
|
||||
- Quest/reward XP uses `addXp()` directly — it bypasses the chat cooldown.
|
||||
- XP boost multipliers come from active `TimerType.EFFECT` timers with key `'xp_boost'` (metadata field: `multiplier`).
|
||||
- All XP values are `bigint` in the DB but converted to `Number` for arithmetic. Watch for overflow at extremely high XP values.
|
||||
- `addXp()` and `processChatXp()` run inside transactions. They emit `XP_GAINED` (fire-and-forget) which the quest system listens to — the weight equals the XP amount.
|
||||
## Model
|
||||
|
||||
- total XP is the source of truth
|
||||
- level is derived from XP on each award
|
||||
- XP curve is driven by `config.leveling.base` and `config.leveling.exponent`
|
||||
|
||||
## Main methods
|
||||
|
||||
- `getXpToReachLevel(level)`
|
||||
- `getLevelFromXp(totalXp)`
|
||||
- `getXpForNextLevel(currentLevel)`
|
||||
- `addXp(userId, amount)`
|
||||
- `processChatXp(userId)`
|
||||
|
||||
## Chat XP
|
||||
|
||||
- gated by a `user_timers` cooldown on `TimerKey.CHAT_XP`
|
||||
- base award is random between `config.leveling.chat.minXp` and `maxXp`
|
||||
- active XP boost timers can multiply the award
|
||||
|
||||
## Notes
|
||||
|
||||
- `addXp()` emits `XP_GAINED` for quest progression
|
||||
- the level curve currently converts `bigint` XP to `number` for the math loop, so extremely large totals would be the stress point to watch
|
||||
|
||||
@@ -1,10 +1,28 @@
|
||||
# Moderation Module
|
||||
# Moderation module
|
||||
|
||||
- Case IDs are sequential strings formatted as `CASE-XXXX` (zero-padded to 4 digits). Generated by querying the latest case and incrementing. Not a DB sequence -- concurrent inserts could theoretically collide, but in practice moderation actions are low-frequency.
|
||||
- Only `WARN` type cases are created with `active: true`. All other case types (TIMEOUT, BAN, KICK, NOTE) default to `active: false`. The `active` flag is specifically for tracking unresolved warnings.
|
||||
- `issueWarning` has two side effects beyond creating the case:
|
||||
- **DM notification**: sends the user a warning embed via DM. Fails silently if the user has DMs disabled. Controlled by `config.dmOnWarn` (defaults to true if unset).
|
||||
- **Auto-timeout**: if active warning count >= `autoTimeoutThreshold`, the user is automatically timed out for 24 hours and a separate `TIMEOUT` case is created with `moderatorId: "0"` (system). The timeout target (Discord GuildMember) is passed in from the command layer.
|
||||
- `clearCase` sets `active: false` and records who cleared it and why. It works on any case type, not just warnings.
|
||||
- The service does **not** perform Discord actions (kick, ban, timeout) directly -- it only manages database records. The bot command layer is responsible for calling Discord APIs and then recording cases here. The one exception is auto-timeout in `issueWarning`, where the Discord member object is passed in.
|
||||
- `searchCases` supports pagination via `limit`/`offset` (default limit 50).
|
||||
## Responsibilities
|
||||
|
||||
- create and query moderation case records
|
||||
- manage active warning state
|
||||
- optionally DM warned users
|
||||
- optionally auto-timeout when warning thresholds are reached
|
||||
|
||||
## Main methods
|
||||
|
||||
- `createCase()`
|
||||
- `issueWarning()`
|
||||
- `getCaseById()`
|
||||
- `getUserCases()`
|
||||
- `getUserWarnings()`
|
||||
- `getUserNotes()`
|
||||
- `clearCase()`
|
||||
- `searchCases()`
|
||||
- `getActiveWarningCount()`
|
||||
|
||||
## Notes
|
||||
|
||||
- case IDs are generated in application code as `CASE-0001`, `CASE-0002`, and so on
|
||||
- warnings are the main case type that starts as `active: true`
|
||||
- `issueWarning()` can DM the user and can create a follow-up timeout case when the configured threshold is hit
|
||||
- the service stores moderation records; it does not generally execute Discord actions itself
|
||||
- the auto-timeout path is the exception because a timeout target can be passed into `issueWarning()`
|
||||
|
||||
@@ -1,10 +1,32 @@
|
||||
# Quest Module
|
||||
# Quest module
|
||||
|
||||
- Quests are **event-driven**. The `handleEvent` method is called by system event listeners (not by commands directly). It matches events by exact name or prefix (e.g., trigger `ITEM_COLLECT` matches event `ITEM_COLLECT:101`), enabling both generic and specific quest triggers.
|
||||
- Max active quests is controlled by `gameSettingsService`, not hardcoded. Default is 3.
|
||||
- `assignQuest` uses `onConflictDoNothing` -- re-assigning an already-assigned quest silently no-ops. This is intentional to avoid duplicate quest entries.
|
||||
- Quest progress is a simple integer counter. The `weight` parameter in `handleEvent` allows a single event to advance progress by more than 1 (useful for bulk actions).
|
||||
- Quest completion is **automatic**: when progress >= target during `handleEvent`, `completeQuest` is called within the same transaction. There is no manual "turn in" step.
|
||||
- Rewards (xp and balance) are distributed via `economyService` and `levelingService` inside the completion transaction. The `QUEST.COMPLETED` event is emitted with `systemEvents.emit` (fire-and-forget, not async) for bot-layer notifications.
|
||||
- `requirements` and `rewards` are stored as JSON columns. Always expect `{ target: number }` for requirements and `{ xp?: number, balance?: number }` for rewards.
|
||||
- Completed quests are never deleted -- they stay in `userQuests` with a `completedAt` timestamp. `getAvailableQuests` excludes any quest the user has ever been assigned (completed or not).
|
||||
## Model
|
||||
|
||||
- quests are event-driven
|
||||
- user progress is stored in `user_quests`
|
||||
- completion is automatic once progress reaches the quest target
|
||||
|
||||
## Main methods
|
||||
|
||||
- `assignQuest()`
|
||||
- `updateProgress()`
|
||||
- `handleEvent()`
|
||||
- `completeQuest()`
|
||||
- `getUserQuests()`
|
||||
- `getAvailableQuests()`
|
||||
- `createQuest()`
|
||||
- `getAllQuests()`
|
||||
- `deleteQuest()`
|
||||
- `updateQuest()`
|
||||
|
||||
## Rules
|
||||
|
||||
- max active quests comes from `gameSettingsService`
|
||||
- `assignQuest()` uses `onConflictDoNothing()`
|
||||
- `handleEvent()` matches either exact trigger names or `trigger:` prefixes, for example `ITEM_COLLECTED:42`
|
||||
- rewards can include balance and XP and are paid inside the completion transaction
|
||||
|
||||
## Notes
|
||||
|
||||
- completed assignments remain in `user_quests`
|
||||
- `EVENTS.QUEST.COMPLETED` is emitted for the bot/UI layer after reward distribution
|
||||
|
||||
@@ -1,9 +1,32 @@
|
||||
# Trade Module
|
||||
# Trade module
|
||||
|
||||
- Trade sessions are stored **in-memory only** (a `Map` keyed by thread ID). Sessions are lost on restart. There is no persistence or recovery mechanism.
|
||||
- The trade uses a **two-phase lock** pattern: both users must `toggleLock` (accept) before `executeTrade` can proceed. Any offer modification (add/remove item, change money) automatically **unlocks both users**, forcing re-confirmation. This prevents bait-and-switch.
|
||||
- `executeTrade` wraps both directions of transfer in a single DB transaction. If any part fails (e.g., insufficient funds, inventory full), the entire trade rolls back.
|
||||
- Money and item transfers go through `economyService.modifyUserBalance` and `inventoryService.addItem`/`removeItem`, which means all their validation (balance checks, stack limits, slot limits) and side effects (transaction logging, quest events) apply.
|
||||
- Item transactions are logged separately in the `itemTransactions` table (distinct from currency `transactions`), with `TRADE_IN`/`TRADE_OUT` types.
|
||||
- The trade types are defined in `bot/modules/trade/trade.types.ts` but the service lives in `shared/modules/trade/`. The import uses `@/modules/trade/trade.types` (bot alias). This cross-boundary import works because both run in the same process.
|
||||
- `_sessions` is exposed on the service object for testing purposes only.
|
||||
## Model
|
||||
|
||||
- trade sessions are in-memory only
|
||||
- sessions are keyed by Discord thread ID
|
||||
- there is no persistence or restart recovery
|
||||
|
||||
## Main methods
|
||||
|
||||
- `createSession()`
|
||||
- `getSession()`
|
||||
- `endSession()`
|
||||
- `updateMoney()`
|
||||
- `addItem()`
|
||||
- `removeItem()`
|
||||
- `toggleLock()`
|
||||
- `executeTrade()`
|
||||
- `clearSessions()`
|
||||
|
||||
## Rules
|
||||
|
||||
- any change to money or items unlocks both participants
|
||||
- `executeTrade()` requires both users to be locked
|
||||
- money transfer goes through `economyService.modifyUserBalance()`
|
||||
- item transfer goes through `inventoryService.removeItem()` and `addItem()`
|
||||
- item transfers are also logged in `item_transactions`
|
||||
|
||||
## Notes
|
||||
|
||||
- `_sessions` is intentionally exposed for tests
|
||||
- type definitions live under `bot/modules/trade/trade.types.ts`, while the service stays in `shared/modules/trade`
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
# Trivia Module
|
||||
# Trivia module
|
||||
|
||||
- Trivia is an **economic sink**: the entry fee is deducted immediately when starting, before the question is fetched. If the API call fails after payment, the user loses the fee. This is by design (prevents free retries).
|
||||
- Questions come from the **OpenTDB API** with base64 encoding to avoid HTML entity issues. The service decodes all fields from base64 before returning.
|
||||
- Sessions are in-memory (`Map` keyed by `userId_timestamp`). Lost on restart. Expired sessions are cleaned up every 30 seconds.
|
||||
- The cooldown is set **at session start**, not on answer submission. This means a user is on cooldown even if they never answer.
|
||||
- Answer correctness (`isCorrect`) is determined by the **caller** (interaction handler), not the service. The `submitAnswer` method trusts the `isCorrect` boolean. The session stores `correctIndex` for the UI layer to compare.
|
||||
- Reward calculation: `potentialReward = entryFee * rewardMultiplier`. The multiplier comes from config. Wrong answers get 0 (the entry fee is already gone).
|
||||
- Unlike most services, `TriviaService` is a **class instance** (not a plain object). This is because it needs constructor logic for the cleanup interval. The singleton is exported as `triviaService`.
|
||||
- The reward payment in `submitAnswer` reads the current balance and sets it directly (not using `sql` addition). This is a potential race condition under extreme concurrency but acceptable given the per-user cooldown.
|
||||
- Session is deleted before processing the reward to prevent double-submit, even if the reward transaction fails.
|
||||
## Model
|
||||
|
||||
- `triviaService` is a class-backed singleton
|
||||
- active sessions live in memory
|
||||
- expired sessions are cleaned every 30 seconds
|
||||
|
||||
## Flow
|
||||
|
||||
1. `canPlayTrivia()` checks the cooldown timer.
|
||||
2. `startTrivia()` deducts the entry fee, fetches a question from OpenTDB, creates the session, and sets the cooldown.
|
||||
3. The bot view layer renders answer buttons using the stored shuffled answers and `correctIndex`.
|
||||
4. `submitAnswer()` removes the session and pays the reward only if the caller says the answer was correct.
|
||||
|
||||
## Notes
|
||||
|
||||
- questions are fetched from OpenTDB with `encode=base64` and decoded server-side
|
||||
- entry fee is deducted before the question fetch completes
|
||||
- cooldown is applied when the session starts, not when the answer is submitted
|
||||
- `submitAnswer()` trusts the caller's `isCorrect` boolean
|
||||
- reward payment currently reads and writes the balance directly inside the transaction instead of using `modifyUserBalance()`
|
||||
|
||||
Reference in New Issue
Block a user