From 6abbd4652a9b373793d7ad48d9e8f7b7ddb3ecd7 Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Thu, 9 Apr 2026 21:10:10 +0200 Subject: [PATCH] Refresh repository documentation - Rewrite AGENTS and README files to match the current app layout - Document API routes, trivia UI, and the active panel design language --- AGENTS.md | 262 ++++++++--------------- README.md | 269 +++++++++++++----------- api/README.md | 132 ++++++++++-- api/src/AGENTS.md | 85 +++++--- bot/modules/trivia/README.md | 128 +++-------- docs/new-design/DESIGN.md | 106 ++++------ panel/AGENTS.md | 104 ++++++--- shared/db/AGENTS.md | 105 ++++++--- shared/modules/economy/AGENTS.md | 45 +++- shared/modules/feature-flags/AGENTS.md | 31 ++- shared/modules/guild-settings/AGENTS.md | 40 +++- shared/modules/inventory/AGENTS.md | 46 +++- shared/modules/leveling/AGENTS.md | 33 ++- shared/modules/moderation/AGENTS.md | 36 +++- shared/modules/quest/AGENTS.md | 40 +++- shared/modules/trade/AGENTS.md | 39 +++- shared/modules/trivia/AGENTS.md | 31 ++- 17 files changed, 893 insertions(+), 639 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 7f68b10..ac7eb77 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,211 +1,135 @@ -# CLAUDE.md +# AGENTS.md -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +This file documents the current implementation shape of the Aurora repository. ## Commands ```bash -# Development -bun --watch bot/index.ts # Run bot + API with hot reload -docker compose up # Start all services (bot, API, database) -docker compose up app # Start just the app (bot + API) -docker compose up db # Start just the database +# App +bun run dev # bot + API in one Bun process with watch mode +docker compose up # app + db +docker compose up app # app only +docker compose up db # database only # Testing -bun test # Run all tests -bun test path/to/file.test.ts # Run a single test file -bun test shared/modules/economy # Run tests in a directory -bun test --watch # Watch mode +bun test # Bun's native runner +bun run test # repo test wrapper script +bun run test:ci # include CI/integration path # Database -bun run db:push:local # Push schema changes (local) -bun run db:studio # Open Drizzle Studio (localhost:4983) -bun run generate # Generate Drizzle migrations (Docker) -bun run migrate # Apply migrations (Docker) +bun run db:push # drizzle-kit push via Docker +bun run db:push:local # drizzle-kit push locally +bun run db:generate # drizzle-kit generate via Docker +bun run db:migrate # drizzle-kit migrate via Docker +bun run db:studio # local Drizzle Studio on :4983 -# Admin Panel -bun run panel:dev # Start Vite dev server for dashboard -bun run panel:build # Build React dashboard for production +# Panel +bun run panel:dev # Vite dev server on :5173 +bun run panel:build # build panel/dist ``` ## Architecture -Aurora is a Discord RPG bot + REST API running as a **single Bun process**. The bot and API share the same database client and services. +Aurora is a single-process Bun application: -``` -bot/ # Discord bot -├── commands/ # Slash commands by category (admin, economy, inventory, etc.) -├── events/ # Discord event handlers -├── lib/ # BotClient, handlers, loaders, embed helpers, commandUtils -├── modules/ # Feature modules (views, interactions per domain) -└── graphics/ # Canvas-based image generation (@napi-rs/canvas) +- `bot/index.ts` boots shared config, registers domain listeners, starts the API server, then logs into Discord. +- `api/src/server.ts` hosts REST routes, WebSocket traffic, and built panel assets. +- `shared/modules/*` contains the business logic used by both the bot and the API. +- `shared/games/*` contains reusable game plugins; `api/src/games/*` runs rooms and WebSocket orchestration. -shared/ # Shared between bot and API -├── db/ # Drizzle ORM client + schema (users, economy, inventory, quests, etc.) -├── lib/ # env, config, errors, logger, types, utils -└── modules/ # Domain services (economy, user, inventory, quest, moderation, etc.) +Current high-level layout: -api/ # REST API (Bun HTTP server) -└── src/routes/ # Route handlers for each domain - -panel/ # React admin dashboard (Vite + Tailwind + Radix UI) +```text +bot/ Discord commands, events, views, interactions +api/ Bun HTTP + WebSocket server +panel/ React dashboard +shared/db/ Drizzle client and schema +shared/lib/ config, env, errors, logger, events, constants +shared/modules/ domain services +shared/games/ game plugins shared by API and panel ``` -**Key architectural details:** -- Bot and API both import from `shared/` — do not duplicate logic. -- Services in `shared/modules/` are singleton objects, not classes. -- The database uses PostgreSQL 16+ via Drizzle ORM with `bigint` mode for Discord IDs and currency. -- Feature modules follow a strict file suffix convention (see below). +## Import conventions -## Import Conventions +Use path aliases from the repo `tsconfig.json`: -Use path aliases (defined in `tsconfig.json`). Order: external packages → aliases → relative. +- `@/*` -> `bot/*` +- `@commands/*` -> `bot/commands/*` +- `@db/*` -> `shared/db/*` +- `@lib/*` -> `bot/lib/*` +- `@modules/*` -> `bot/modules/*` +- `@shared/*` -> `shared/*` -```typescript -import { SlashCommandBuilder } from "discord.js"; // external -import { economyService } from "@shared/modules/economy/economy.service"; // alias -import { users } from "@db/schema"; // alias -import { createErrorEmbed } from "@lib/embeds"; // alias -import { localHelper } from "./helper"; // relative -``` +Import order in the repo is generally: -**Aliases:** -- `@/*` → `bot/` -- `@shared/*` → `shared/` -- `@db/*` → `shared/db/` -- `@lib/*` → `bot/lib/` -- `@modules/*` → `bot/modules/` -- `@commands/*` → `bot/commands/` +1. external packages +2. aliases +3. relative imports -## Code Patterns +## File patterns -### Module File Suffixes +- `*.service.ts`: domain/business logic, usually in `shared/modules/*` +- `*.view.ts`: Discord message/view construction +- `*.interaction.ts`: component interaction handlers +- `*.types.ts`: local types and custom ID helpers +- `*.handler.ts`: bot-side orchestration around services/views +- `*.test.ts`: colocated tests -- `*.view.ts` — Creates Discord embeds/components -- `*.interaction.ts` — Handles button/select/modal interactions -- `*.service.ts` — Business logic (lives in `shared/modules/`) -- `*.types.ts` — Module-specific TypeScript types -- `*.test.ts` — Tests (co-located with source) +## Runtime config -### Interaction Routing +- Global game settings live in `game_settings` and are loaded into `shared/lib/config.ts`. +- Guild-specific settings live in `guild_settings`; `getGuildConfig()` adds a 60-second cache on top of DB reads. +- Most numeric DB values exposed through runtime config are converted to `bigint` in `shared/lib/config.ts`. -Component interactions (buttons, select menus, modals) flow through a centralized routing system: +## Interaction routing -``` -Discord event → interactionCreate → ComponentInteractionHandler → interaction.routes.ts → *.interaction.ts -``` +Global component routing is defined in `bot/lib/interaction.routes.ts` and consumed by `ComponentInteractionHandler`. -`ComponentInteractionHandler` (`bot/lib/handlers/ComponentInteractionHandler.ts`) iterates over the route table in `bot/lib/interaction.routes.ts`. Each route has a `predicate` that matches on `customId`, a lazy `handler` import, and a `method` name to call. The handler also provides centralized `UserError` / system error handling. +Current route table: -**Route table (custom ID prefix → handler):** +- `trade_` and `amount` -> `bot/modules/trade/trade.interaction.ts` +- `shop_buy_` -> `bot/modules/economy/shop.interaction.ts` +- `lootdrop_` -> `bot/modules/economy/lootdrop.interaction.ts` +- `trivia_` -> `bot/modules/trivia/trivia.interaction.ts` +- `createitem_` -> `bot/modules/admin/item_wizard.ts` +- `enrollment` -> `bot/modules/user/enrollment.interaction.ts` +- `feedback_` -> `bot/modules/feedback/feedback.interaction.ts` -| Custom ID prefix | Handler file | Method | -| ------------------ | ----------------------------------------------- | ------------------------------ | -| `trade_`, `amount` | `bot/modules/trade/trade.interaction.ts` | `handleTradeInteraction` | -| `shop_buy_` | `bot/modules/economy/shop.interaction.ts` | `handleShopInteraction` | -| `lootdrop_` | `bot/modules/economy/lootdrop.interaction.ts` | `handleLootdropInteraction` | -| `trivia_` | `bot/modules/trivia/trivia.interaction.ts` | `handleTriviaInteraction` | -| `createitem_` | `bot/modules/admin/item_wizard.ts` | `handleItemWizardInteraction` | -| `enrollment` | `bot/modules/user/enrollment.interaction.ts` | `handleEnrollmentInteraction` | -| `feedback_` | `bot/modules/feedback/feedback.interaction.ts` | `handleFeedbackInteraction` | +Some features still use local collectors instead of the global route table, notably inventory. -Routes are evaluated in order — the first matching predicate wins. Some modules (e.g., inventory with `inv_` prefix) handle interactions locally via message component collectors instead of the global route table. +## Commands and access control -### Command Definition +- Slash command execution is centralized in `bot/lib/handlers/CommandHandler.ts`. +- `withCommandErrorHandling()` is the normal command wrapper for defer/reply/error behavior. +- Beta commands rely on `featureFlagsService.hasAccess()`. +- `ADMIN_USER_IDS` controls admin panel access, not Discord permissions inside command code. -```typescript -export const commandName = createCommand({ - data: new SlashCommandBuilder().setName("name").setDescription("desc"), - execute: async (interaction) => { - await withCommandErrorHandling(interaction, async () => { - const result = await service.method(); - await interaction.editReply({ embeds: [createSuccessEmbed(result)] }); - }, { ephemeral: true }); - }, -}); -``` +## API and panel -`withCommandErrorHandling` (from `@lib/commandUtils`) handles `deferReply`, `UserError` display, and unexpected error logging automatically. +- API routes are prefix-matched in `api/src/routes/index.ts`. +- `/auth/*` and `/api/health` are public. +- Players may access `/api/stats`, `/api/health`, `/api/me`, and `/api/me/inventory`. +- Remaining `/api/*` routes are admin-only. +- The panel dev server proxies back to the Bun server; the integrated server serves `panel/dist` when built. -### Service Pattern +## Database notes -```typescript -export const serviceName = { - methodName: async (params: ParamType): Promise => { - return await withTransaction(async (tx) => { - // database operations - }); - }, -}; -``` +- Docker Compose uses PostgreSQL 17. +- Discord IDs and currency/xp values are stored as `bigint`. +- `withTransaction()` lives in `bot/lib/db.ts` and is the normal way shared services compose DB work. -### Error Handling +## Testing -```typescript -import { UserError, SystemError } from "@shared/lib/errors"; +- Tests use `bun:test`. +- Mock modules before importing the unit under test. +- Most service tests stub `DrizzleClient` or `withTransaction()` rather than hitting the real database. -throw new UserError("You don't have enough coins!"); // shown to user -throw new SystemError("DB connection failed"); // logged, generic message shown -``` +## Key entrypoints -### Database Transactions - -```typescript -import { withTransaction } from "@/lib/db"; - -return await withTransaction(async (tx) => { - const user = await tx.query.users.findFirst({ where: eq(users.id, id) }); - await tx.update(users).set({ coins: newBalance }).where(eq(users.id, id)); - return user; -}, existingTx); // pass existing tx for nested transactions -``` - -### Testing - -Mock modules **before** imports. Use `bun:test`. - -```typescript -import { describe, it, expect, mock, beforeEach } from "bun:test"; - -mock.module("@shared/db/DrizzleClient", () => ({ - DrizzleClient: { query: mockQuery }, -})); - -describe("serviceName", () => { - beforeEach(() => mockFn.mockClear()); - it("should handle expected case", async () => { - mockFn.mockResolvedValue(testData); - const result = await service.method(input); - expect(result).toEqual(expected); - }); -}); -``` - -## Naming Conventions - -| Element | Convention | Example | -| ---------------- | ---------------------- | -------------------------------- | -| Files | camelCase or kebab-case | `BotClient.ts`, `economy.service.ts` | -| Classes | PascalCase | `CommandHandler`, `UserError` | -| Functions | camelCase | `createCommand`, `handleShopInteraction` | -| Constants | UPPER_SNAKE_CASE | `EVENTS`, `BRANDING` | -| Enums | PascalCase | `TimerType`, `TransactionType` | -| Services | camelCase singleton | `economyService`, `userService` | -| Types/Interfaces | PascalCase | `Command`, `Event`, `GameConfigType` | -| DB tables | snake_case | `users`, `moderation_cases` | -| Custom IDs | snake_case with prefix | `shop_buy_`, `trade_accept_` | -| API routes | kebab-case | `/api/guild-settings` | - -## Key Files - -| Purpose | File | -| ----------------- | -------------------------- | -| Bot entry point | `bot/index.ts` | -| Discord client | `bot/lib/BotClient.ts` | -| DB schema index | `shared/db/schema.ts` | -| Error classes | `shared/lib/errors.ts` | -| Environment vars | `shared/lib/env.ts` | -| Config loader | `shared/lib/config.ts` | -| Embed helpers | `bot/lib/embeds.ts` | -| Command utils | `bot/lib/commandUtils.ts` | -| API server | `api/src/server.ts` | +- `bot/index.ts` +- `bot/lib/BotClient.ts` +- `api/src/server.ts` +- `api/src/routes/index.ts` +- `shared/lib/config.ts` +- `shared/db/DrizzleClient.ts` +- `shared/db/schema/index.ts` diff --git a/README.md b/README.md index 1cba5b4..5fb1b5f 100644 --- a/README.md +++ b/README.md @@ -1,159 +1,180 @@ # Aurora -> A comprehensive, feature-rich Discord RPG bot built with modern technologies. +Aurora is a Discord RPG bot, admin/player panel, and REST/WebSocket API that run as one Bun application. The Discord bot and HTTP server share the same database client, config, services, and domain events. -![Version](https://img.shields.io/badge/version-1.0.0-blue.svg) -![Bun](https://img.shields.io/badge/Bun-1.0+-black) -![Discord.js](https://img.shields.io/badge/Discord.js-14.x-5865F2) -![Drizzle ORM](https://img.shields.io/badge/Drizzle_ORM-0.30+-C5F74F) -![PostgreSQL](https://img.shields.io/badge/PostgreSQL-16-336791) +## What exists today -Aurora is a powerful Discord bot designed to facilitate RPG-like elements within a Discord server. It features a robust economy, class system, inventory management, quests, and more, all built on top of a high-performance stack using Bun and Drizzle ORM. +- Discord slash commands for economy, inventory, quests, moderation, feedback, user profiles, and admin tooling. +- A Bun HTTP API under `/api/*`, Discord OAuth under `/auth/*`, and a WebSocket endpoint at `/ws`. +- A React panel for both admins and enrolled players. +- Shared domain services in `shared/modules/*` and reusable game plugins in `shared/games/*`. +- Built-in real-time games: chess and blackjack. -**New in v1.0:** Aurora now includes a fully integrated **REST API** for accessing bot data, statistics, and configuration, running alongside the bot in a single process. +## Architecture -## ✨ Features +```text +bot/ Discord bot entrypoint, commands, events, Discord-facing views/interactions +api/ Bun HTTP server, route modules, WebSocket/game room server +panel/ React 19 + Vite + Tailwind v4 dashboard +shared/ Shared DB schema, services, config, events, utilities, game plugins +docs/ Product and design notes +``` -### Discord Bot -* **Class System**: Users can join different classes. -* **Economy**: Complete economy system with balance, transactions, and daily rewards. -* **Inventory & Items**: Sophisticated item system with rarities, types (Material, Consumable, etc.), and inventory management. -* **Leveling**: XP-based leveling system to track user activity and progress. -* **Quests**: Quest system with requirements and rewards. -* **Trading**: Secure trading system between users. -* **Lootdrops**: Random loot drops in channels to engage users. -* **Admin Tools**: Administrative commands for server management. +Important points: -### REST API -* **Live Analytics**: Real-time statistics endpoint (commands, transactions). -* **Configuration Management**: Update bot settings via API. -* **Database Inspection**: Integrated Drizzle Studio access. -* **WebSocket Support**: Real-time event streaming for live updates. +- `bot/index.ts` initializes DB-backed config, wires domain events, starts the API server, then logs into Discord. +- The API server also serves built panel assets from `panel/dist` when they exist. +- Bot commands, API routes, and the panel all rely on the same service layer in `shared/modules/*`. +- Runtime game config is loaded from the `game_settings` table into `shared/lib/config.ts`. -## 🏗️ Architecture - -Aurora uses a **Single Process Monolith** architecture to maximize performance and simplify resource sharing. - -* **Unified Runtime**: Both the Discord Client and the REST API run within the same Bun process. -* **Shared State**: This allows the API to access live bot memory (caches, gateways) directly without complex inter-process communication (IPC). -* **Simplified Deployment**: You only need to deploy a single Docker container. - -## 🛠️ Tech Stack - -* **Runtime**: [Bun](https://bun.sh/) -* **Bot Framework**: [Discord.js](https://discord.js.org/) -* **API Framework**: Bun HTTP Server (REST API) -* **UI**: Discord embeds and components -* **Database**: [PostgreSQL](https://www.postgresql.org/) -* **ORM**: [Drizzle ORM](https://orm.drizzle.team/) -* **Validation**: [Zod](https://zod.dev/) -* **Containerization**: [Docker](https://www.docker.com/) - -## 🚀 Getting Started +## Getting started ### Prerequisites -* [Bun](https://bun.sh/) (latest version) -* [Docker](https://www.docker.com/) & Docker Compose +- Bun +- Docker and Docker Compose +- A Discord application with bot token, client ID, and client secret -### Installation +### Setup -1. **Clone the repository** - ```bash - git clone - cd aurora - ``` +1. Install dependencies. -2. **Install dependencies** - ```bash - bun install - ``` +```bash +bun install +``` -3. **Environment Setup** - Copy the example environment file and configure it: - ```bash - cp .env.example .env - ``` - Edit `.env` with your Discord bot token, Client ID, and database credentials. +2. Create your environment file. - > **Note**: The `DATABASE_URL` in `.env.example` is pre-configured for Docker. +```bash +cp .env.example .env +``` -4. **Start the Database** - Run the database service using Docker Compose: - ```bash - docker compose up -d db - ``` +3. Start PostgreSQL. -5. **Run Migrations** - ```bash - bun run migrate - ``` - OR - ```bash - bun run db:push - ``` +```bash +docker compose up -d db +``` -### Running the Bot & API +4. Initialize the schema. + +```bash +bun run db:push:local +``` + +If you prefer running schema changes through Docker: + +```bash +bun run migrate +``` + +5. Start the bot and API. -**Development Mode** (with hot reload): ```bash bun run dev ``` -* Bot: Online in Discord -* API: http://localhost:3000 -**Production Mode**: -Build and run with Docker (recommended): +The Bun server listens on `http://localhost:3000`. + +### Panel development + +The Bun server can serve a built panel, but day-to-day panel work is done with Vite: + ```bash -docker compose up -d app +bun run panel:dev ``` -### 🔐 Accessing Production Services (SSH Tunnel) +The panel dev server runs on `http://localhost:5173` and proxies `/api`, `/auth`, `/assets`, and `/ws` to `http://localhost:3000`. -For security, the Production Database and API are **not exposed** to the public internet by default. They are only accessible via localhost on the server. +To build the panel for the integrated Bun server: -To access them from your local machine, use the included SSH tunnel script. - -1. Add your VPS details to your local `.env` file: - ```env - VPS_USER=root - VPS_HOST=123.45.67.89 - ``` - -2. Run the remote connection script: - ```bash - bun run remote - ``` - -This will establish secure tunnels for: -* **API**: http://localhost:3000 -* **Drizzle Studio**: http://localhost:4983 - -## 📜 Scripts - -* `bun run dev`: Start the bot and API server in watch mode. -* `bun run remote`: Open SSH tunnel to production services. -* `bun run generate`: Generate Drizzle migrations. -* `bun run migrate`: Apply migrations (via Docker). -* `bun run db:studio`: Open Drizzle Studio to inspect the database. -* `bun test`: Run tests. - -## 📂 Project Structure - -``` -├── bot # Discord Bot logic & entry point -├── web # REST API Server -├── shared # Shared code (Database, Config, Types) -├── drizzle # Drizzle migration files -├── scripts # Utility scripts -├── docker-compose.yml -└── package.json +```bash +bun run panel:build ``` -## 🤝 Contributing +## Useful scripts -Contributions are welcome! Please feel free to submit a Pull Request. +```bash +# App +bun run dev +docker compose up +docker compose up app +docker compose up db -## 📄 License +# Database +bun run db:push +bun run db:push:local +bun run db:generate +bun run db:migrate +bun run db:studio +bun run db:backup +bun run db:restore -This project is licensed under the MIT License. \ No newline at end of file +# Panel +bun run panel:dev +bun run panel:build + +# Tests +bun test +bun run test +bun run test:ci + +# Ops +bun run remote +bun run deploy +bun run deploy:remote +``` + +## Environment notes + +The main variables you need in `.env` are: + +- `DISCORD_BOT_TOKEN` +- `DISCORD_CLIENT_ID` +- `DISCORD_CLIENT_SECRET` +- `DISCORD_GUILD_ID` +- `ADMIN_USER_IDS` +- `DB_USER` +- `DB_PASSWORD` +- `DB_NAME` +- `DATABASE_URL` +- `PANEL_BASE_URL` + +Players can authenticate into the panel only after they exist in the `users` table. Admin access is determined by `ADMIN_USER_IDS`. + +## API and panel summary + +- Public routes: `/auth/*`, `/api/health` +- Player-accessible API routes: `/api/stats`, `/api/health`, `/api/me`, `/api/me/inventory` +- Admin-only API routes: the rest of `/api/*` +- WebSocket: `/ws` with cookie-based auth +- Static assets: `/assets/*` + +## Project structure + +```text +bot/ + commands/ + events/ + lib/ + modules/ + +api/ + src/ + routes/ + games/ + +panel/ + src/ + +shared/ + db/ + games/ + lib/ + modules/ +``` + +## Documentation + +- [AGENTS.md](AGENTS.md): repo-wide implementation guidance +- [api/README.md](api/README.md): API surface and auth model +- [docs/new-design/DESIGN.md](docs/new-design/DESIGN.md): current panel design language diff --git a/api/README.md b/api/README.md index 5d55815..c726a00 100644 --- a/api/README.md +++ b/api/README.md @@ -1,30 +1,130 @@ -# Aurora Web API +# Aurora API -The web API provides a REST interface and WebSocket support for accessing Aurora bot data and configuration. +Aurora's API is a Bun server that runs inside the same process as the Discord bot. It serves REST routes, the authenticated WebSocket endpoint, static assets, and built panel files. -## API Endpoints +## Runtime model -- `GET /api/stats` - Real-time bot statistics -- `GET /api/settings` - Bot configuration -- `GET /api/users` - User data -- `GET /api/items` - Item catalog -- `GET /api/quests` - Quest information -- `GET /api/transactions` - Economy data -- `GET /api/health` - Health check +- Entry point: `api/src/server.ts` +- Route dispatcher: `api/src/routes/index.ts` +- Auth: Discord OAuth with in-memory session cookies +- WebSocket: `/ws` +- Static assets: `/assets/*` +- Built panel fallback: `panel/dist` + +## Access model + +Public: + +- `GET /api/health` +- `/auth/discord` +- `/auth/callback` +- `POST /auth/logout` +- `GET /auth/me` + +Player-accessible API routes: + +- `GET /api/stats` +- `GET /api/health` +- `GET /api/me` +- `GET /api/me/inventory` + +Admin-only API routes: + +- everything else under `/api/*` + +Admin vs player is derived from `ADMIN_USER_IDS`. A user must already exist in the `users` table to complete panel login. + +## Route summary + +### Auth + +- `GET /auth/discord` +- `GET /auth/callback` +- `POST /auth/logout` +- `GET /auth/me` + +### Dashboard and system + +- `GET /api/health` +- `GET /api/stats` +- `GET /api/stats/activity` +- `POST /api/actions/reload-commands` +- `POST /api/actions/clear-cache` +- `POST /api/actions/maintenance-mode` + +### Settings + +- `GET /api/settings` +- `POST /api/settings` +- `GET /api/settings/meta` +- `GET /api/guilds/:guildId/settings` +- `PUT|PATCH /api/guilds/:guildId/settings` +- `DELETE /api/guilds/:guildId/settings` + +### Users, classes, and inventory + +- `GET /api/me` +- `GET /api/me/inventory` +- `GET /api/users` +- `GET /api/users/:id` +- `PUT /api/users/:id` +- `GET /api/users/:id/inventory` +- `POST /api/users/:id/inventory` +- `DELETE /api/users/:id/inventory/:itemId` +- `GET /api/classes` +- `POST /api/classes` +- `PUT /api/classes/:id` +- `DELETE /api/classes/:id` + +### Game content + +- `GET /api/items` +- `POST /api/items` +- `GET /api/items/:id` +- `PUT /api/items/:id` +- `DELETE /api/items/:id` +- `POST /api/items/:id/icon` +- `GET /api/quests` +- `POST /api/quests` +- `PUT /api/quests/:id` +- `DELETE /api/quests/:id` +- `GET /api/lootdrops` +- `POST /api/lootdrops` +- `DELETE /api/lootdrops/:messageId` + +### Moderation and economy history + +- `GET /api/moderation` +- `POST /api/moderation` +- `GET /api/transactions` ## WebSocket -Connect to `/ws` for real-time updates: -- Stats broadcasts every 5 seconds -- Event notifications via system bus -- PING/PONG heartbeat support +`/ws` requires a valid `aurora_session` cookie. + +Current behavior: + +- dashboard clients subscribe to `dashboard` +- game clients also use lobby and room-scoped traffic through `GameServer` +- `PING` from the client returns `PONG` +- dashboard stats are broadcast every 5 seconds while at least one client is connected +- hard limits in `api/src/server.ts`: + - 200 concurrent connections + - 16 KB max payload + - 60 second idle timeout ## Development -The API runs automatically when you start the bot: +Start the backend: ```bash bun run dev ``` -The API will be available at `http://localhost:3000` +Optional panel dev server: + +```bash +bun run panel:dev +``` + +Panel dev runs on `http://localhost:5173` and proxies API/auth/assets/WebSocket requests to `http://localhost:3000`. diff --git a/api/src/AGENTS.md b/api/src/AGENTS.md index e1ce373..b5a1e99 100644 --- a/api/src/AGENTS.md +++ b/api/src/AGENTS.md @@ -1,31 +1,68 @@ -# API Layer +# API layer -## Server -- Bun's native `serve()` API — no Express/Fastify. Custom `handleRequest()` dispatcher with pathname prefix matching. -- Route modules export `{ name: string, handler: RouteHandler }`. Handlers return `null` for non-matching paths. +## Server shape -## Authentication -- Discord OAuth2 with session cookies (`aurora_session`, HttpOnly, 7-day TTL). -- In-memory session store — sessions lost on restart. -- Role-based: `admin` vs `player` (admins set via `ADMIN_USER_IDS` env var). -- Non-enrolled users (not in DB) get 403 even with valid Discord auth. -- Call `getSession(req)` for all protected routes. +- Aurora uses Bun's native `serve()` API in `api/src/server.ts`. +- Route modules are aggregated in `api/src/routes/index.ts`. +- A route module returns `null` when it does not match so the dispatcher can continue. +- After route handling, the server tries `panel/dist` for SPA/static files. -## Response Conventions -- Success: `jsonResponse(data, status)` — uses custom BigInt-safe JSON replacer. -- Error: `errorResponse(message, status, details?)` → `{ error, details? }` -- Validation: `validationErrorResponse(zodError)` → `{ error: "Invalid payload", issues: [...] }` -- Zod schemas centralized in `schemas.ts`. +## Authentication and authorization + +- OAuth routes live in `api/src/routes/auth.routes.ts`. +- Sessions are stored in memory and keyed by the `aurora_session` cookie. +- Session TTL is 7 days. +- Login succeeds only for users already present in the `users` table. +- Role is `admin` if the Discord ID is in `ADMIN_USER_IDS`, otherwise `player`. +- Redirects after login are intentionally restricted to localhost or relative paths. + +Current access rules from `api/src/routes/index.ts`: + +- public: `/auth/*`, `/api/health` +- player allow-list: `/api/stats`, `/api/health`, `/api/me` +- everything else under `/api/*`: admin-only + +`/api/me/inventory` is handled by `users.routes.ts` and still depends on a valid session. + +## Response conventions + +- `jsonResponse()` serializes `bigint` values as strings. +- `errorResponse()` returns `{ error, details? }`. +- `parseBody()` and `parseQuery()` validate with Zod and return a `Response` on failure. +- The API does not use a framework-level middleware stack; each route handles its own parsing and branching. ## WebSocket -- Upgrade via `/ws` endpoint (requires auth). -- Pub/sub via Bun's `.publish()` / `.subscribe()` on channels: `dashboard`, `lobby`, `room:${roomId}`. -- Dashboard stats broadcast every 5 seconds. Game events are room-scoped. -- Hard limit: 200 concurrent WS connections (429 rejection), 16KB max payload, 60s idle timeout. -- Fire-and-forget broadcasts — no ack mechanism. + +- Endpoint: `/ws` +- Requires an authenticated session +- Dashboard channel: `dashboard` +- Lobby channel: `lobby` +- Room-specific messaging is handled inside `GameServer` +- Dashboard broadcasts `STATS_UPDATE` every 5 seconds while clients are connected +- `NEW_EVENT` broadcasts are wired from `shared/lib/events` + +Hard limits: + +- max connections: 200 +- max payload: 16 KB +- idle timeout: 60 seconds + +## Static files + +- Built panel assets are served from `panel/dist` +- `/assets/*` serves files from `bot/assets/graphics` +- `/api/*`, `/auth/*`, `/ws`, and `/assets/*` bypass the SPA fallback + +## Route notes + +- `items.routes.ts` supports both JSON and multipart form data for item creation. +- `settings.routes.ts` writes DB-backed game settings and emits the reload-commands event. +- `guild-settings.routes.ts` invalidates the guild config cache after writes. +- `lootdrops.routes.ts` delegates spawning/deletion to bot-side handlers because Discord message creation happens there. ## Gotchas -- All DB IDs are BigInt — JSON responses must use the custom `jsonReplacer`. -- No rate limiting on HTTP routes. -- Some routes accept multipart form data (e.g., item icon upload) — manual parsing, not abstracted. -- Asset directories resolve relative to `import.meta.dir`. + +- Sessions and some caches are in-memory only and are lost on restart. +- The server registers game plugins at startup; duplicate registration throws. +- BigInt-safe JSON matters for nearly every domain route. +- The panel's auth flow depends on `PANEL_BASE_URL` matching the OAuth callback origin. diff --git a/bot/modules/trivia/README.md b/bot/modules/trivia/README.md index 84a3239..307ce8a 100644 --- a/bot/modules/trivia/README.md +++ b/bot/modules/trivia/README.md @@ -1,116 +1,38 @@ -# Trivia - Components v2 Implementation +# Trivia UI -This trivia feature uses **Discord Components v2** for a premium visual experience. +The trivia command uses Discord Components v2 for the question, result, and timeout states. -## 🎨 Visual Features +## Files -### **Container with Accent Colors** -Each trivia question is displayed in a Container with a colored accent bar that changes based on difficulty: -- **🟢 Easy**: Green accent bar (`0x57F287`) -- **🟡 Medium**: Yellow accent bar (`0xFEE75C`) -- **🔴 Hard**: Red accent bar (`0xED4245`) - -### **Modern Layout Components** -- **TextDisplay** - Rich markdown formatting for question text -- **Separator** - Visual spacing between sections -- **Container** - Groups all content with difficulty-based styling - -### **Interactive Features** -✅ **Give Up Button** - Players can forfeit if they're unsure -✅ **Disabled Answer Buttons** - After answering, buttons show: - - ✅ Green for correct answer - - ❌ Red for user's incorrect answer - - Gray for other options - -✅ **Time Display** - Shows both relative time (`in 30s`) and seconds remaining -✅ **Stakes Preview** - Clear display: `50 AU ➜ 100 AU` - -## 📁 File Structure - -``` +```text bot/modules/trivia/ -├── trivia.view.ts # Components v2 view functions -├── trivia.interaction.ts # Button interaction handler -└── README.md # This file + trivia.types.ts + trivia.view.ts + trivia.interaction.ts -bot/commands/economy/ -└── trivia.ts # /trivia slash command +bot/commands/economy/trivia.ts +shared/modules/trivia/trivia.service.ts ``` -## 🔧 Technical Details +## What the view layer does -### Components v2 Requirements -- Uses `MessageFlags.IsComponentsV2` flag -- No `embeds` or `content` fields (uses TextDisplay instead) -- Numeric component types: - - `1` - Action Row - - `2` - Button - - `10` - Text Display - - `14` - Separator - - `17` - Container -- Max 40 components per message (vs 5 for legacy) +- renders the active question as a Components v2 container +- colors the container by difficulty +- renders answer buttons from the session's shuffled answers +- renders separate result and timeout views with disabled buttons -### Button Styles -- **Secondary (2)**: Gray - Used for answer buttons -- **Success (3)**: Green - Used for "True" and correct answers -- **Danger (4)**: Red - Used for "False", incorrect answers, and "Give Up" +## Current interaction flow -## 🎮 User Experience Flow +1. `/trivia` checks cooldown before deferring. +2. `startTrivia()` deducts the entry fee and creates the session. +3. `getTriviaQuestionView()` renders the prompt. +4. `trivia.interaction.ts` compares the clicked answer with the session's `correctIndex`. +5. `submitAnswer()` finalizes the session and the view swaps to success, failure, or timeout output. -1. User runs `/trivia` -2. Sees question in a Container with difficulty-based accent color -3. Can choose to: - - Select an answer (A/B/C/D or True/False) - - Give up using the 🏳️ button -4. After answering, sees result with: - - Disabled buttons showing correct/incorrect answers - - Container with result-based accent color (green/red/yellow) - - Reward or penalty information +The command also schedules a timeout cleanup with a 5-second grace period after `config.trivia.timeoutSeconds`. -## 🌟 Visual Examples +## Custom IDs -### Question Display -``` -┌─[GREEN]─────────────────────────┐ -│ # 🎯 Trivia Challenge │ -│ 🟢 Easy • 📚 Geography │ -│ ─────────────────────────── │ -│ ### What is the capital of │ -│ France? │ -│ │ -│ ⏱️ Time: in 30s (30s) │ -│ 💰 Stakes: 50 AU ➜ 100 AU │ -│ 👤 Player: Username │ -└─────────────────────────────────┘ -[🇦 A: Paris] [🇧 B: London] -[🇨 C: Berlin] [🇩 D: Madrid] -[🏳️ Give Up] -``` - -### Result Display (Correct) -``` -┌─[GREEN]─────────────────────────┐ -│ # 🎉 Correct Answer! │ -│ ### What is the capital of │ -│ France? │ -│ ─────────────────────────── │ -│ ✅ Your answer: Paris │ -│ │ -│ 💰 Reward: +100 AU │ -│ │ -│ 🏆 Great job! Keep it up! │ -└─────────────────────────────────┘ -[✅ A: Paris] [❌ B: London] -[❌ C: Berlin] [❌ D: Madrid] -(all buttons disabled) -``` - -## 🚀 Future Enhancements - -Potential improvements: -- [ ] Thumbnail images based on trivia category -- [ ] Progress bar for time remaining -- [ ] Streak counter display -- [ ] Category-specific accent colors -- [ ] Media Gallery for image-based questions -- [ ] Leaderboard integration in results +- answer buttons: `TRIVIA_CUSTOM_IDS.ANSWER(sessionId, index)` +- give up: `TRIVIA_CUSTOM_IDS.GIVE_UP(sessionId)` +- result/timeout buttons use non-interactive result IDs diff --git a/docs/new-design/DESIGN.md b/docs/new-design/DESIGN.md index a4daa4c..048b208 100644 --- a/docs/new-design/DESIGN.md +++ b/docs/new-design/DESIGN.md @@ -1,84 +1,60 @@ -# Design System Specification: Stellar Editorial +# Stellar Editorial -## 1. Overview & Creative North Star: "The Celestial Curator" -This design system is built to evoke the atmosphere of an elite, secretive astronomical academy. We are moving away from the "utility-first" aesthetic of standard Discord bots toward a "Digital Editorial" experience. +This document now tracks the design language that is actually implemented in the panel, with a small amount of forward-looking guidance where the code is still catching up. -**Creative North Star: The Celestial Curator** -The UI should feel like a high-end, leather-bound astronomical ledger reimagined for the 22nd century. We achieve this through "The Void & The Light"—using deep, expansive dark spaces contrasted against precise, shimmering accents. We reject the rigid, boxed-in layouts of traditional dashboards in favor of intentional asymmetry, overlapping layers, and a profound sense of depth. Every element should feel curated, prestigious, and slightly mysterious. +## Current implementation status ---- +Implemented today in `panel/src/index.css` and the active panel pages: -## 2. Colors: The Palette of the Night Sky -The color philosophy is rooted in "Atmospheric Depth." We use a hierarchy of midnight tones to create a sense of infinite space, punctuated by gold and starlight. +- dark "void and light" surface stack +- Celestial Gold primary color family +- Noto Serif, Manrope, Space Grotesk, and JetBrains Mono typography +- low-contrast "ghost border" treatment +- rounded surface hierarchy for cards, sidebars, and controls -### The "No-Line" Rule -**Explicit Instruction:** Designers are prohibited from using 1px solid borders to define sections or containers. Layout boundaries must be defined exclusively through background color shifts. Use `surface-container-low` against a `surface` background to create a subtle "carved" effect, or `surface-container-high` to create "lift." +Partially implemented or still aspirational: -### Surface Hierarchy & Nesting -Instead of a flat grid, treat the UI as a series of nested celestial bodies. -* **Base Layer:** `surface` (#0d1323) – The infinite void. -* **Secondary Sections:** `surface-container-low` (#151b2c) – Softly recessed areas. -* **Interactive Cards:** `surface-container-high` (#24293b) – Elevated content. -* **Floating Modals:** `surface-container-highest` (#2f3446) – The closest layer to the viewer. +- stronger asymmetry and editorial layouts across more pages +- decorative constellation/nebula treatments +- more consistent premium component states across every admin screen -### The "Glass & Gradient" Rule -To capture the "Stellar Academy" prestige, use Glassmorphism for floating elements. -* **Formula:** Apply `surface-variant` at 40% opacity with a `24px` backdrop blur. -* **Signature Textures:** For primary CTAs and hero headers, utilize a linear gradient: `primary` (#e9c349) to `primary-fixed-dim` (#e9c349) at a 135-degree angle. This provides a "metallic brass" sheen that flat colors cannot replicate. +## Design intent ---- +Aurora's panel should feel like an elite astronomical academy rather than a default admin dashboard. The codebase already follows that direction through the theme tokens and typography system; new UI work should continue that tone instead of falling back to generic SaaS styling. -## 3. Typography: Academic Authority -The type system pairs the intellectual weight of a classic Serif with the technical precision of a modern Sans-Serif. +## Core tokens -* **Display & Headlines (Noto Serif):** These are the "Ancient Manuscripts." Use `display-lg` to `headline-sm` for titles and major headers. This font conveys the "Elite Academy" prestige. -* **UI & Body (Manrope):** This is the "Modern Faculty." Use `title-lg` down to `body-sm` for all functional UI elements, descriptions, and system feedback. -* **Technical Labels (Space Grotesk):** Use `label-md` and `label-sm` for status tags, metadata, and numerical RPG stats. This adds a subtle "astronomical instrument" feel. +Surface hierarchy: -**Editorial Tip:** Use exaggerated tracking (0.1em) on `label-sm` in all-caps to enhance the high-end, premium feel. +- `--color-background`: `#0d1323` +- `--color-surface-container-low`: `#151b2c` +- `--color-surface-container-high`: `#24293b` +- `--color-surface-container-highest`: `#2f3446` ---- +Primary accents: -## 4. Elevation & Depth: Tonal Layering -We do not use structural lines. We use light and shadow to imply existence. +- `--color-primary`: `#e9c349` +- `--color-primary-fixed-dim`: `#d4af37` +- `--color-primary-container`: `#3d2e00` -* **The Layering Principle:** Depth is achieved by stacking. Place a `surface-container-lowest` card on a `surface-container-low` section. This creates a "soft-edge" transition that feels organic rather than digital. -* **Ambient Shadows:** For floating elements, use a shadow with a 40px blur, 0px offset, and 6% opacity using the `on-surface` color. This creates a "glow" rather than a "drop shadow," mimicking how light behaves in a dark vacuum. -* **The "Ghost Border" Fallback:** If a boundary is strictly required for accessibility, use a "Ghost Border": `outline-variant` (#45464c) at 15% opacity. Never use 100% opaque borders. -* **The Aurora Glow:** Use `primary-container` as a very large, soft radial gradient (600px+) behind key UI elements to create a "nebula" effect, grounding the component in space. +Typography: ---- +- display: Noto Serif +- body: Manrope +- labels: Space Grotesk +- mono: JetBrains Mono -## 5. Components: Precision & Prestige +## Component guidance -### Buttons -* **Primary:** A gradient-filled container (`primary` to `primary-fixed-dim`). No border. `label-md` (Space Grotesk) text in `on-primary`. -* **Secondary:** A "Ghost Border" container with `secondary` text. Upon hover, the background fills with 5% `secondary` white. -* **Tertiary:** Text-only in `primary`, but with a small `2px` gold dot (star) preceding the label. +- Prefer tonal separation over heavy borders. +- Use gold as a focused accent, not a flood color. +- Keep text contrast high and metadata quieter. +- Sidebar, cards, tables, and game views should feel like the same product family. +- Avoid plain white cards, Discord blurple defaults, and generic component-library styling. -### Cards & Lists -* **Rule:** Forbid the use of divider lines. -* **Separation:** Use `spacing-6` (2rem) of vertical white space to separate list items. If items must be grouped, use a subtle background shift to `surface-container-low`. -* **Visual Interest:** In the top-right corner of a "Highest" elevation card, place a subtle, 10% opacity constellation pattern SVG. +## Practical rules for future work -### Input Fields -* **Styling:** Inputs should not be boxes. Use a `surface-container-lowest` background with a `2px` bottom-only border in `outline-variant`. -* **Focus State:** The bottom border transitions to `primary` (gold) with a subtle `primary-container` outer glow. - -### New Component: "The Astral Badge" -A specialized chip for RPG ranks. -* **Style:** `label-sm` text inside a `surface-container-highest` pill. It features a 1px "Ghost Border" and a tiny radial glow in the center-left using the `primary` color to represent a "guiding star." - ---- - -## 6. Do's and Don'ts - -### Do: -* **Embrace Asymmetry:** Align text to the left but place decorative "constellation" elements floating on the right to break the grid. -* **Use Generous Spacing:** Premium design requires "breathing room." Use `spacing-10` and `spacing-12` for section margins. -* **Tint Your Greys:** Ensure all "neutrals" are pulled from the `secondary` and `tertiary` tokens, which are infused with midnight blue and silver. - -### Don't: -* **Don't use pure black (#000):** It kills the "depth" of the midnight blue theme. Use `surface-container-lowest`. -* **Don't use standard Discord "Blurple":** This system must feel like a custom application that exists outside the standard Discord ecosystem. -* **Don't use hard corners:** Stick strictly to the `Roundedness Scale`. Most containers should use `xl` (0.75rem) or `lg` (0.5rem) to maintain a sophisticated, approachable feel. \ No newline at end of file +- Start from the CSS tokens in `panel/src/index.css` instead of inventing new one-off colors. +- Preserve the current font roles unless there is a strong reason to change them. +- Use gradients, glow, or tonal depth sparingly and intentionally. +- Keep mobile behavior first-class; the existing layout already has mobile drawer behavior that new pages should respect. diff --git a/panel/AGENTS.md b/panel/AGENTS.md index 4892431..653d2ee 100644 --- a/panel/AGENTS.md +++ b/panel/AGENTS.md @@ -1,31 +1,87 @@ -# Panel (Admin Dashboard) +# Panel ## Stack -- React 19 + React Router v7 + Tailwind CSS v4 (Vite plugin) + Lucide icons. -- No component library (no Radix, no shadcn). All styling is inline Tailwind. -- No external state management. All state via custom hooks (`useAuth`, `useUsers`, `useItems`, `useDashboard`, `useGameRoom`). -## API Client -- Thin `fetch` wrapper in `src/lib/api.ts` with typed generics: `get`, `post`, `put`, `del`. -- Credentials: `same-origin` only — CORS requests will fail. -- 401 responses redirect to Discord auth. No retry logic. -- 204 / empty responses return `undefined`. +- React 19 +- React Router 7 +- Vite 6 +- Tailwind CSS v4 via `@tailwindcss/vite` +- Local utilities: `clsx`, `tailwind-merge`, `class-variance-authority` +- Icons: `lucide-react` -## WebSocket -- Singleton pattern in `useWebSocket()`. Global handler Set — multiple hooks share one connection. -- Auto-reconnects with exponential backoff (max 30s). -- `send()` and `subscribe()` API for components. +The panel lives in `panel/src` and is built to `panel/dist`. -## Patterns -- **Draft editing:** Pages use `selectedUser → userDraft` flow. Changes aren't auto-saved. `saveDraft()` commits, `discardDraft()` reverts, `isDirty()` detects changes. -- **Debounced search:** 300ms debounce on search inputs. -- **No cache invalidation:** After mutations, manually call `refetch()` to update lists. +## Dev and runtime -## Routing -- Role-based: player routes (`/dashboard`, `/games`, `/leaderboards`) and admin routes (`/admin/*`). -- Sidebar auto-hides admin routes for non-admins. +- `bun run panel:dev` starts Vite on `http://localhost:5173` +- Vite proxies `/api`, `/auth`, `/assets`, and `/ws` to `http://localhost:3000` +- The Bun server serves the built panel from `panel/dist` in integrated mode -## Theme -- Dark theme with "Celestial Gold" primary (`#e9c349`). -- Semantic colors: destructive, success, warning, info. -- 4-tier surface hierarchy. Utility: `cn()` from `clsx + tailwind-merge`. +## Auth model + +- `useAuth()` calls `GET /auth/me` +- unauthenticated users are sent through `/auth/discord` +- logout uses `POST /auth/logout` +- non-enrolled users see the `NotEnrolled` page + +Roles: + +- `admin`: admin routes plus player/game routes +- `player`: player/game routes only + +## Active routes + +- `/dashboard` +- `/leaderboards` +- `/games` +- `/:gameSlug/:roomId` +- `/admin` +- `/admin/users` +- `/admin/items` +- `/admin/classes` +- `/admin/quests` +- `/admin/lootdrops` +- `/admin/moderation` +- `/admin/transactions` +- `/admin/settings` + +## Data layer + +Shared hooks in `panel/src/lib`: + +- `useAuth` +- `useDashboard` +- `useUsers` +- `useItems` +- `useSettings` +- `useWebSocket` +- `useGameRoom` + +`panel/src/lib/api.ts` is a thin fetch wrapper: + +- base path is empty because the panel is usually same-origin with the Bun server +- 401 triggers a redirect back into `/auth/discord` +- 204 and empty responses return `undefined` + +## WebSocket and games + +- `useWebSocket()` keeps a singleton browser WebSocket connection +- reconnects with exponential backoff up to 30 seconds +- `useGameRoom()` multiplexes room traffic over that shared socket +- current built-in game UIs are chess and blackjack + +## Styling + +- Theme tokens live in `panel/src/index.css` +- Fonts currently loaded: + - Noto Serif + - Manrope + - Space Grotesk + - JetBrains Mono +- The implemented visual system is the "Stellar Editorial" direction documented in `docs/new-design/DESIGN.md` + +## Current patterns + +- Admin pages tend to use explicit `refetch()` after mutations instead of a shared cache layer +- Search inputs use a 300 ms debounce in hooks such as `useUsers()` and `useItems()` +- Layout and sidebar ownership lives in `panel/src/components/Layout.tsx` diff --git a/shared/db/AGENTS.md b/shared/db/AGENTS.md index b62533c..cb0ea6b 100644 --- a/shared/db/AGENTS.md +++ b/shared/db/AGENTS.md @@ -1,40 +1,79 @@ -# Database Layer +# Database layer -## Column Types -- **Bigint** (`mode: 'bigint'`): All Discord snowflake IDs (`userId`, `guildId`, `roleId`, `channelId`), currency amounts (`balance`, `xp`, transaction `amount`, item `price`, inventory `quantity`). Use `0n` BigInt literals, never `Number()`. -- **Integer**: Small counts and internal IDs — `level`, `daily_streak`, `progress`, `reward_amount` in lootdrops, auto-increment `serial('id')` for items. +Aurora uses Drizzle ORM with PostgreSQL. Docker Compose currently runs PostgreSQL 17. -## Composite Primary Keys -- `inventory(userId, itemId)` — one stack per user per item -- `userQuests(userId, questId)` — one assignment per user per quest -- `userTimers(userId, type, key)` — one timer per user per type/key combo +## Schema modules -## Constraints -- `inventory.quantity > 0` check constraint — never store zero-quantity rows -- `classes.name` and `items.name` are unique -- Cascade deletes on user FK; set null on `relatedUserId` (preserves transaction history) +- `users.ts` +- `inventory.ts` +- `economy.ts` +- `quests.ts` +- `moderation.ts` +- `feature-flags.ts` +- `guild-settings.ts` +- `game-settings.ts` -## JSON Columns (JSONB) -- `quests.requirements`: `{ target: number }` -- `quests.rewards`: `{ xp?: number, balance?: number }` -- `gameSettings` fields: Typed via `.$type()` — `LevelingConfig`, `EconomyConfig`, etc. -- `guildSettings.featureOverrides`: `Record` (sparse) -- `guildSettings.colorRoleIds`: `string[]` -- `users.settings`, `userTimers.metadata`, `items.usageData`: Untyped JSONB, default `{}` +`shared/db/schema/index.ts` re-exports the full schema surface. -## Enums -Defined as TypeScript enums in `shared/lib/constants.ts`, **not** as database enums. Schema columns use `varchar` with length constraints. Key enum types: -- `TimerType`: COOLDOWN, EFFECT, ACCESS, EXAM_SYSTEM, TRIVIA_COOLDOWN -- `TransactionType`: TRANSFER_IN, DAILY_REWARD, etc. (8 values) -- `ModerationCaseType`: warn, timeout, kick, ban, note, prune -- `ItemType`: MATERIAL, CONSUMABLE, EQUIPMENT, QUEST +## Numeric conventions -## Notable Indexes -- `user_timers_lookup_idx`: Composite on (userId, type, key) — fast timer checks -- `user_timers_expires_at_idx`: Expiry-based cleanup queries -- `users_balance_idx` / `users_level_xp_idx`: Leaderboard queries +- Discord IDs, balances, XP, quantities, and transaction amounts are stored as `bigint` +- many API responses serialize those `bigint` values to strings +- JSON config blobs inside `game_settings` use strings for values that become `bigint` at runtime, for example: + - `economy.daily.amount` + - `economy.daily.streakBonus` + - `economy.daily.weeklyBonus` + - `economy.transfers.minAmount` + - `inventory.maxStackSize` + - `trivia.entryFee` -## Client Setup -- `DrizzleClient` is a singleton in `shared/db/DrizzleClient.ts` (postgres-js driver, no prefetch). -- `withTransaction` utility in `bot/lib/db.ts` wraps Drizzle transactions and tracks count for graceful shutdown. Accepts optional existing `tx` for nested calls. -- No soft deletes anywhere. `moderationCases` uses `active: boolean` + `resolvedAt`/`resolvedBy` for lifecycle, but rows are never deleted. +## Important tables + +- `users` +- `classes` +- `transactions` +- `item_transactions` +- `items` +- `inventory` +- `quests` +- `user_quests` +- `user_timers` +- `moderation_cases` +- `lootdrops` +- `feature_flags` +- `feature_flag_access` +- `guild_settings` +- `game_settings` + +## Composite keys and constraints + +- `inventory(userId, itemId)` +- `userQuests(userId, questId)` +- `userTimers(userId, type, key)` +- `inventory.quantity > 0` +- `items.name` is unique +- `feature_flags.name` is unique + +## JSON columns + +- `items.usageData` +- `users.settings` +- `userTimers.metadata` +- `quests.requirements` +- `quests.rewards` +- `guildSettings.colorRoleIds` +- `guildSettings.featureOverrides` +- every main section in `gameSettings` + +## Relations and deletion behavior + +- inventory rows cascade on user/item deletion +- `transactions.relatedUserId` and `item_transactions.relatedUserId` use `set null` +- feature flag access rows cascade when a feature flag is deleted +- moderation cases are not soft-deleted; lifecycle is represented by `active`, `resolvedAt`, and `resolvedBy` + +## Client setup + +- `shared/db/DrizzleClient.ts` exports the singleton DB client +- `bot/lib/db.ts` exports `withTransaction()` +- shared services normally accept an optional existing transaction so nested operations stay atomic diff --git a/shared/modules/economy/AGENTS.md b/shared/modules/economy/AGENTS.md index 8401d09..7223047 100644 --- a/shared/modules/economy/AGENTS.md +++ b/shared/modules/economy/AGENTS.md @@ -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 diff --git a/shared/modules/feature-flags/AGENTS.md b/shared/modules/feature-flags/AGENTS.md index 66dbb52..a3bb7e9 100644 --- a/shared/modules/feature-flags/AGENTS.md +++ b/shared/modules/feature-flags/AGENTS.md @@ -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 diff --git a/shared/modules/guild-settings/AGENTS.md b/shared/modules/guild-settings/AGENTS.md index 867247a..7c895f5 100644 --- a/shared/modules/guild-settings/AGENTS.md +++ b/shared/modules/guild-settings/AGENTS.md @@ -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` — 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 diff --git a/shared/modules/inventory/AGENTS.md b/shared/modules/inventory/AGENTS.md index 35348e0..f3f224c 100644 --- a/shared/modules/inventory/AGENTS.md +++ b/shared/modules/inventory/AGENTS.md @@ -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 diff --git a/shared/modules/leveling/AGENTS.md b/shared/modules/leveling/AGENTS.md index 697f108..06d9787 100644 --- a/shared/modules/leveling/AGENTS.md +++ b/shared/modules/leveling/AGENTS.md @@ -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 diff --git a/shared/modules/moderation/AGENTS.md b/shared/modules/moderation/AGENTS.md index 3aac9b4..d8de066 100644 --- a/shared/modules/moderation/AGENTS.md +++ b/shared/modules/moderation/AGENTS.md @@ -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()` diff --git a/shared/modules/quest/AGENTS.md b/shared/modules/quest/AGENTS.md index 904bd70..5ab22b7 100644 --- a/shared/modules/quest/AGENTS.md +++ b/shared/modules/quest/AGENTS.md @@ -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 diff --git a/shared/modules/trade/AGENTS.md b/shared/modules/trade/AGENTS.md index 8f2485d..78c888c 100644 --- a/shared/modules/trade/AGENTS.md +++ b/shared/modules/trade/AGENTS.md @@ -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` diff --git a/shared/modules/trivia/AGENTS.md b/shared/modules/trivia/AGENTS.md index 6a8fa25..44583a9 100644 --- a/shared/modules/trivia/AGENTS.md +++ b/shared/modules/trivia/AGENTS.md @@ -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()`