forked from syntaxbullet/AuroraBot-discord
Compare commits
40 Commits
3a96b67e89
...
216189b0a4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
216189b0a4 | ||
|
|
ca1339728a | ||
|
|
5833224ba9 | ||
|
|
65f5dc3721 | ||
|
|
637f0826db | ||
|
|
578987caea | ||
|
|
064efb0ed2 | ||
|
|
4229e5338f | ||
|
|
1f7679e5a1 | ||
|
|
4e228bb7a3 | ||
|
|
95d5202d7f | ||
|
|
6c150f753e | ||
|
|
c881b305f0 | ||
|
|
ae5ef4c802 | ||
|
|
2b365cb96d | ||
|
|
bcbbcaa6a4 | ||
|
|
bdb8456f34 | ||
|
|
acaca46298 | ||
|
|
7b831fa17c | ||
|
|
c128c96aa8 | ||
|
|
d0f53dc37b | ||
|
|
28936a7f7a | ||
|
|
4642cf7f6a | ||
|
|
528a66a7ef | ||
|
|
a97a24f72a | ||
|
|
7bd4d811cd | ||
|
|
2ce768013d | ||
|
|
3c20b23cc1 | ||
|
|
71fefb3a14 | ||
|
|
1d650bb2c7 | ||
|
|
7cf8d68d39 | ||
|
|
83cd33e439 | ||
|
|
34cbea2753 | ||
|
|
ce7d4525b2 | ||
|
|
4ac8b4759e | ||
|
|
56ad5b49cd | ||
|
|
e8f6a56057 | ||
|
|
a7f66a98b9 | ||
|
|
6d54695325 | ||
|
|
8c1f80981b |
11
.env.example
11
.env.example
@@ -1,9 +1,12 @@
|
||||
DB_USER=kyoko
|
||||
DB_PASSWORD=kyoko
|
||||
DB_NAME=kyoko
|
||||
DB_USER=aurora
|
||||
DB_PASSWORD=aurora
|
||||
DB_NAME=aurora
|
||||
DB_PORT=5432
|
||||
DB_HOST=db
|
||||
DISCORD_BOT_TOKEN=your-discord-bot-token
|
||||
DISCORD_CLIENT_ID=your-discord-client-id
|
||||
DISCORD_GUILD_ID=your-discord-guild-id
|
||||
DATABASE_URL=postgres://kyoko:kyoko@db:5432/kyoko
|
||||
DATABASE_URL=postgres://aurora:aurora@db:5432/aurora
|
||||
|
||||
VPS_USER=your-vps-user
|
||||
VPS_HOST=your-vps-ip
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -4,8 +4,11 @@ db-logs
|
||||
db-data
|
||||
.cursor
|
||||
# dependencies (bun install)
|
||||
|
||||
node_modules
|
||||
|
||||
config/
|
||||
|
||||
# output
|
||||
out
|
||||
dist
|
||||
|
||||
133
README.md
133
README.md
@@ -1,42 +1,119 @@
|
||||
# Kyoko - Discord Rpg
|
||||
# Aurora
|
||||
|
||||
A Discord bot built with [Bun](https://bun.sh), [Discord.js](https://discord.js.org/), and [Drizzle ORM](https://orm.drizzle.team/).
|
||||
> A comprehensive, feature-rich Discord RPG bot built with modern technologies.
|
||||
|
||||
## Architecture
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
This project uses a modular architecture:
|
||||
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.
|
||||
|
||||
- **`src/index.ts`**: Entry point. initializes the client.
|
||||
- **`src/lib/KyokoClient.ts`**: Custom Discord Client wrapper handling command loading and events.
|
||||
- **`src/lib/env.ts`**: **Centralized Environment Configuration**. Validates environment variables using `zod` at startup.
|
||||
- **`src/lib/DrizzleClient.ts`**: Database client instance.
|
||||
- **`src/commands/`**: Command files.
|
||||
- **`src/db/`**: Database schema and migrations.
|
||||
## ✨ Features
|
||||
|
||||
## Setup
|
||||
* **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.
|
||||
|
||||
1. **Install Dependencies**:
|
||||
## 🛠️ Tech Stack
|
||||
|
||||
* **Runtime**: [Bun](https://bun.sh/)
|
||||
* **Framework**: [Discord.js](https://discord.js.org/)
|
||||
* **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
|
||||
|
||||
### Prerequisites
|
||||
|
||||
* [Bun](https://bun.sh/) (latest version)
|
||||
* [Docker](https://www.docker.com/) & Docker Compose
|
||||
|
||||
### Installation
|
||||
|
||||
1. **Clone the repository**
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd aurora
|
||||
```
|
||||
|
||||
2. **Install dependencies**
|
||||
```bash
|
||||
bun install
|
||||
```
|
||||
|
||||
2. **Environment Variables**:
|
||||
Copy `.env.example` to `.env` (create one if it doesn't exist) and fill in the required values:
|
||||
```env
|
||||
DISCORD_BOT_TOKEN=your_token_here
|
||||
DISCORD_CLIENT_ID=your_client_id
|
||||
DISCORD_GUILD_ID=your_guild_id_optional
|
||||
DATABASE_URL=postgres://user:pass@localhost:5432/db_name
|
||||
```
|
||||
*Note: The app will fail to start if `DISCORD_BOT_TOKEN` or `DATABASE_URL` are missing or invalid.*
|
||||
|
||||
3. **Run Development**:
|
||||
3. **Environment Setup**
|
||||
Copy the example environment file and configure it:
|
||||
```bash
|
||||
bun run dev
|
||||
cp .env.example .env
|
||||
```
|
||||
Edit `.env` with your Discord bot token, Client ID, and database credentials.
|
||||
|
||||
> **Note**: The `DATABASE_URL` in `.env.example` is pre-configured for Docker.
|
||||
|
||||
4. **Start the Database**
|
||||
Run the database service using Docker Compose:
|
||||
```bash
|
||||
docker compose up -d db
|
||||
```
|
||||
|
||||
4. **Database Migrations**:
|
||||
5. **Run Migrations**
|
||||
```bash
|
||||
bun run db:push # Apply schema changes
|
||||
bun run generate # Generate migrations
|
||||
```
|
||||
bun run migrate
|
||||
```
|
||||
OR
|
||||
```bash
|
||||
bun run db:push
|
||||
```
|
||||
|
||||
### Running the Bot
|
||||
|
||||
**Development Mode** (with hot reload):
|
||||
```bash
|
||||
bun run dev
|
||||
```
|
||||
|
||||
**Production Mode**:
|
||||
Build and run with Docker (recommended):
|
||||
```bash
|
||||
docker compose up -d app
|
||||
```
|
||||
|
||||
## 📜 Scripts
|
||||
|
||||
* `bun run dev`: Start the bot in watch mode.
|
||||
* `bun run generate`: Generate Drizzle migrations.
|
||||
* `bun run migrate`: Apply migrations (via Docker).
|
||||
* `bun run db:push`: Push, schema to DB (via Docker).
|
||||
* `bun run db:studio`: Open Drizzle Studio to inspect the database.
|
||||
* `bun test`: Run tests.
|
||||
|
||||
## 📂 Project Structure
|
||||
|
||||
```
|
||||
├── src
|
||||
│ ├── commands # Slash commands
|
||||
│ ├── events # Discord event handlers
|
||||
│ ├── modules # Feature modules (Economy, Inventory, etc.)
|
||||
│ ├── db # Database schema and connection
|
||||
│ └── lib # Shared utilities
|
||||
├── drizzle # Drizzle migration files
|
||||
├── config # Configuration files
|
||||
└── scripts # Utility scripts
|
||||
```
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
Contributions are welcome! Please feel free to submit a Pull Request.
|
||||
|
||||
## 📄 License
|
||||
|
||||
This project is licensed under the MIT License.
|
||||
@@ -13,7 +13,7 @@ services:
|
||||
- ./src/db/log:/var/log/postgresql
|
||||
app:
|
||||
container_name: aurora_app
|
||||
image: kyoko-app
|
||||
image: aurora-app
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
@@ -39,7 +39,7 @@ services:
|
||||
|
||||
studio:
|
||||
container_name: aurora_studio
|
||||
image: kyoko-app
|
||||
image: aurora-app
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
|
||||
113
drizzle/0000_fixed_tomas.sql
Normal file
113
drizzle/0000_fixed_tomas.sql
Normal file
@@ -0,0 +1,113 @@
|
||||
CREATE TABLE "classes" (
|
||||
"id" bigint PRIMARY KEY NOT NULL,
|
||||
"name" varchar(255) NOT NULL,
|
||||
"balance" bigint DEFAULT 0,
|
||||
"role_id" varchar(255),
|
||||
CONSTRAINT "classes_name_unique" UNIQUE("name")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "inventory" (
|
||||
"user_id" bigint NOT NULL,
|
||||
"item_id" integer NOT NULL,
|
||||
"quantity" bigint DEFAULT 1,
|
||||
CONSTRAINT "inventory_user_id_item_id_pk" PRIMARY KEY("user_id","item_id"),
|
||||
CONSTRAINT "quantity_check" CHECK ("inventory"."quantity" > 0)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "item_transactions" (
|
||||
"id" bigserial PRIMARY KEY NOT NULL,
|
||||
"user_id" bigint NOT NULL,
|
||||
"related_user_id" bigint,
|
||||
"item_id" integer NOT NULL,
|
||||
"quantity" bigint NOT NULL,
|
||||
"type" varchar(50) NOT NULL,
|
||||
"description" text,
|
||||
"created_at" timestamp with time zone DEFAULT now()
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "items" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"name" varchar(255) NOT NULL,
|
||||
"description" text,
|
||||
"rarity" varchar(20) DEFAULT 'Common',
|
||||
"type" varchar(50) DEFAULT 'MATERIAL' NOT NULL,
|
||||
"usage_data" jsonb DEFAULT '{}'::jsonb,
|
||||
"price" bigint,
|
||||
"icon_url" text NOT NULL,
|
||||
"image_url" text NOT NULL,
|
||||
CONSTRAINT "items_name_unique" UNIQUE("name")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "lootdrops" (
|
||||
"message_id" varchar(255) PRIMARY KEY NOT NULL,
|
||||
"channel_id" varchar(255) NOT NULL,
|
||||
"reward_amount" integer NOT NULL,
|
||||
"currency" varchar(50) NOT NULL,
|
||||
"claimed_by" bigint,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"expires_at" timestamp with time zone
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "quests" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"name" varchar(255) NOT NULL,
|
||||
"description" text,
|
||||
"trigger_event" varchar(50) NOT NULL,
|
||||
"requirements" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
"rewards" jsonb DEFAULT '{}'::jsonb NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "transactions" (
|
||||
"id" bigserial PRIMARY KEY NOT NULL,
|
||||
"user_id" bigint,
|
||||
"related_user_id" bigint,
|
||||
"amount" bigint NOT NULL,
|
||||
"type" varchar(50) NOT NULL,
|
||||
"description" text,
|
||||
"created_at" timestamp with time zone DEFAULT now()
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "user_quests" (
|
||||
"user_id" bigint NOT NULL,
|
||||
"quest_id" integer NOT NULL,
|
||||
"progress" integer DEFAULT 0,
|
||||
"completed_at" timestamp with time zone,
|
||||
CONSTRAINT "user_quests_user_id_quest_id_pk" PRIMARY KEY("user_id","quest_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "user_timers" (
|
||||
"user_id" bigint NOT NULL,
|
||||
"type" varchar(50) NOT NULL,
|
||||
"key" varchar(100) NOT NULL,
|
||||
"expires_at" timestamp with time zone NOT NULL,
|
||||
"metadata" jsonb DEFAULT '{}'::jsonb,
|
||||
CONSTRAINT "user_timers_user_id_type_key_pk" PRIMARY KEY("user_id","type","key")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "users" (
|
||||
"id" bigint PRIMARY KEY NOT NULL,
|
||||
"class_id" bigint,
|
||||
"username" varchar(255) NOT NULL,
|
||||
"is_active" boolean DEFAULT true,
|
||||
"balance" bigint DEFAULT 0,
|
||||
"xp" bigint DEFAULT 0,
|
||||
"level" integer DEFAULT 1,
|
||||
"daily_streak" integer DEFAULT 0,
|
||||
"settings" jsonb DEFAULT '{}'::jsonb,
|
||||
"created_at" timestamp with time zone DEFAULT now(),
|
||||
"updated_at" timestamp with time zone DEFAULT now(),
|
||||
CONSTRAINT "users_username_unique" UNIQUE("username")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "inventory" ADD CONSTRAINT "inventory_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "inventory" ADD CONSTRAINT "inventory_item_id_items_id_fk" FOREIGN KEY ("item_id") REFERENCES "public"."items"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "item_transactions" ADD CONSTRAINT "item_transactions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "item_transactions" ADD CONSTRAINT "item_transactions_related_user_id_users_id_fk" FOREIGN KEY ("related_user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "item_transactions" ADD CONSTRAINT "item_transactions_item_id_items_id_fk" FOREIGN KEY ("item_id") REFERENCES "public"."items"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "lootdrops" ADD CONSTRAINT "lootdrops_claimed_by_users_id_fk" FOREIGN KEY ("claimed_by") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "transactions" ADD CONSTRAINT "transactions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "transactions" ADD CONSTRAINT "transactions_related_user_id_users_id_fk" FOREIGN KEY ("related_user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "user_quests" ADD CONSTRAINT "user_quests_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "user_quests" ADD CONSTRAINT "user_quests_quest_id_quests_id_fk" FOREIGN KEY ("quest_id") REFERENCES "public"."quests"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "user_timers" ADD CONSTRAINT "user_timers_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "users" ADD CONSTRAINT "users_class_id_classes_id_fk" FOREIGN KEY ("class_id") REFERENCES "public"."classes"("id") ON DELETE no action ON UPDATE no action;
|
||||
770
drizzle/meta/0000_snapshot.json
Normal file
770
drizzle/meta/0000_snapshot.json
Normal file
@@ -0,0 +1,770 @@
|
||||
{
|
||||
"id": "d43c3f7b-afe5-4974-ab67-fcd69256f3d8",
|
||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.classes": {
|
||||
"name": "classes",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "bigint",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"balance": {
|
||||
"name": "balance",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "0"
|
||||
},
|
||||
"role_id": {
|
||||
"name": "role_id",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"classes_name_unique": {
|
||||
"name": "classes_name_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"name"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.inventory": {
|
||||
"name": "inventory",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"item_id": {
|
||||
"name": "item_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"quantity": {
|
||||
"name": "quantity",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "1"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"inventory_user_id_users_id_fk": {
|
||||
"name": "inventory_user_id_users_id_fk",
|
||||
"tableFrom": "inventory",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"inventory_item_id_items_id_fk": {
|
||||
"name": "inventory_item_id_items_id_fk",
|
||||
"tableFrom": "inventory",
|
||||
"tableTo": "items",
|
||||
"columnsFrom": [
|
||||
"item_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"inventory_user_id_item_id_pk": {
|
||||
"name": "inventory_user_id_item_id_pk",
|
||||
"columns": [
|
||||
"user_id",
|
||||
"item_id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {
|
||||
"quantity_check": {
|
||||
"name": "quantity_check",
|
||||
"value": "\"inventory\".\"quantity\" > 0"
|
||||
}
|
||||
},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.item_transactions": {
|
||||
"name": "item_transactions",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "bigserial",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"related_user_id": {
|
||||
"name": "related_user_id",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"item_id": {
|
||||
"name": "item_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"quantity": {
|
||||
"name": "quantity",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "varchar(50)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"item_transactions_user_id_users_id_fk": {
|
||||
"name": "item_transactions_user_id_users_id_fk",
|
||||
"tableFrom": "item_transactions",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"item_transactions_related_user_id_users_id_fk": {
|
||||
"name": "item_transactions_related_user_id_users_id_fk",
|
||||
"tableFrom": "item_transactions",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"related_user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "set null",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"item_transactions_item_id_items_id_fk": {
|
||||
"name": "item_transactions_item_id_items_id_fk",
|
||||
"tableFrom": "item_transactions",
|
||||
"tableTo": "items",
|
||||
"columnsFrom": [
|
||||
"item_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.items": {
|
||||
"name": "items",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "serial",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"rarity": {
|
||||
"name": "rarity",
|
||||
"type": "varchar(20)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "'Common'"
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "varchar(50)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'MATERIAL'"
|
||||
},
|
||||
"usage_data": {
|
||||
"name": "usage_data",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "'{}'::jsonb"
|
||||
},
|
||||
"price": {
|
||||
"name": "price",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"icon_url": {
|
||||
"name": "icon_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"image_url": {
|
||||
"name": "image_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"items_name_unique": {
|
||||
"name": "items_name_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"name"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.lootdrops": {
|
||||
"name": "lootdrops",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"message_id": {
|
||||
"name": "message_id",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"channel_id": {
|
||||
"name": "channel_id",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"reward_amount": {
|
||||
"name": "reward_amount",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"currency": {
|
||||
"name": "currency",
|
||||
"type": "varchar(50)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"claimed_by": {
|
||||
"name": "claimed_by",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"lootdrops_claimed_by_users_id_fk": {
|
||||
"name": "lootdrops_claimed_by_users_id_fk",
|
||||
"tableFrom": "lootdrops",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"claimed_by"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "set null",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.quests": {
|
||||
"name": "quests",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "serial",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"trigger_event": {
|
||||
"name": "trigger_event",
|
||||
"type": "varchar(50)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"requirements": {
|
||||
"name": "requirements",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'{}'::jsonb"
|
||||
},
|
||||
"rewards": {
|
||||
"name": "rewards",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'{}'::jsonb"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.transactions": {
|
||||
"name": "transactions",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "bigserial",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"related_user_id": {
|
||||
"name": "related_user_id",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"amount": {
|
||||
"name": "amount",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "varchar(50)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"transactions_user_id_users_id_fk": {
|
||||
"name": "transactions_user_id_users_id_fk",
|
||||
"tableFrom": "transactions",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"transactions_related_user_id_users_id_fk": {
|
||||
"name": "transactions_related_user_id_users_id_fk",
|
||||
"tableFrom": "transactions",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"related_user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "set null",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.user_quests": {
|
||||
"name": "user_quests",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"quest_id": {
|
||||
"name": "quest_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"progress": {
|
||||
"name": "progress",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": 0
|
||||
},
|
||||
"completed_at": {
|
||||
"name": "completed_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"user_quests_user_id_users_id_fk": {
|
||||
"name": "user_quests_user_id_users_id_fk",
|
||||
"tableFrom": "user_quests",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"user_quests_quest_id_quests_id_fk": {
|
||||
"name": "user_quests_quest_id_quests_id_fk",
|
||||
"tableFrom": "user_quests",
|
||||
"tableTo": "quests",
|
||||
"columnsFrom": [
|
||||
"quest_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"user_quests_user_id_quest_id_pk": {
|
||||
"name": "user_quests_user_id_quest_id_pk",
|
||||
"columns": [
|
||||
"user_id",
|
||||
"quest_id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.user_timers": {
|
||||
"name": "user_timers",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "varchar(50)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"key": {
|
||||
"name": "key",
|
||||
"type": "varchar(100)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"metadata": {
|
||||
"name": "metadata",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "'{}'::jsonb"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"user_timers_user_id_users_id_fk": {
|
||||
"name": "user_timers_user_id_users_id_fk",
|
||||
"tableFrom": "user_timers",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"user_timers_user_id_type_key_pk": {
|
||||
"name": "user_timers_user_id_type_key_pk",
|
||||
"columns": [
|
||||
"user_id",
|
||||
"type",
|
||||
"key"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.users": {
|
||||
"name": "users",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "bigint",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"class_id": {
|
||||
"name": "class_id",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"username": {
|
||||
"name": "username",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"is_active": {
|
||||
"name": "is_active",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": true
|
||||
},
|
||||
"balance": {
|
||||
"name": "balance",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "0"
|
||||
},
|
||||
"xp": {
|
||||
"name": "xp",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "0"
|
||||
},
|
||||
"level": {
|
||||
"name": "level",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": 1
|
||||
},
|
||||
"daily_streak": {
|
||||
"name": "daily_streak",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": 0
|
||||
},
|
||||
"settings": {
|
||||
"name": "settings",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "'{}'::jsonb"
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"users_class_id_classes_id_fk": {
|
||||
"name": "users_class_id_classes_id_fk",
|
||||
"tableFrom": "users",
|
||||
"tableTo": "classes",
|
||||
"columnsFrom": [
|
||||
"class_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"users_username_unique": {
|
||||
"name": "users_username_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"username"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
}
|
||||
},
|
||||
"enums": {},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
13
drizzle/meta/_journal.json
Normal file
13
drizzle/meta/_journal.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1766137924760,
|
||||
"tag": "0000_fixed_tomas",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -15,8 +15,11 @@
|
||||
"generate": "docker compose run --rm app drizzle-kit generate",
|
||||
"migrate": "docker compose run --rm app drizzle-kit migrate",
|
||||
"db:push": "docker compose run --rm app drizzle-kit push",
|
||||
"db:push:local": "drizzle-kit push",
|
||||
"dev": "bun --watch src/index.ts",
|
||||
"db:studio": "drizzle-kit studio --host 0.0.0.0"
|
||||
"db:studio": "drizzle-kit studio --host 0.0.0.0",
|
||||
"studio:remote": "bash scripts/remote-studio.sh",
|
||||
"test": "bun test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@napi-rs/canvas": "^0.1.84",
|
||||
|
||||
25
scripts/remote-studio.sh
Executable file
25
scripts/remote-studio.sh
Executable file
@@ -0,0 +1,25 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Load environment variables
|
||||
if [ -f .env ]; then
|
||||
# export $(grep -v '^#' .env | xargs) # Use a safer way if possible, but for simple .env this often works.
|
||||
# Better way to source .env without exporting everything to shell if we just want to use them in script:
|
||||
set -a
|
||||
source .env
|
||||
set +a
|
||||
fi
|
||||
|
||||
if [ -z "$VPS_HOST" ] || [ -z "$VPS_USER" ]; then
|
||||
echo "Error: VPS_HOST and VPS_USER must be set in .env"
|
||||
echo "Please add them to your .env file:"
|
||||
echo "VPS_USER=your-username"
|
||||
echo "VPS_HOST=your-ip-address"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "🔮 Establishing secure tunnel to Drizzle Studio..."
|
||||
echo "📚 Studio will be accessible at: https://local.drizzle.studio"
|
||||
echo "Press Ctrl+C to stop the connection."
|
||||
|
||||
# -N means "Do not execute a remote command". -L is for local port forwarding.
|
||||
ssh -N -L 4983:127.0.0.1:4983 $VPS_USER@$VPS_HOST
|
||||
68
src/commands/admin/config.ts
Normal file
68
src/commands/admin/config.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { createCommand } from "@lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, ModalBuilder, TextInputBuilder, TextInputStyle, ActionRowBuilder, ModalSubmitInteraction } from "discord.js";
|
||||
import { config, saveConfig } from "@lib/config";
|
||||
import type { GameConfigType } from "@lib/config";
|
||||
import { createSuccessEmbed, createErrorEmbed } from "@lib/embeds";
|
||||
|
||||
export const configCommand = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("config")
|
||||
.setDescription("Edit the bot configuration")
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
execute: async (interaction) => {
|
||||
console.log(`Config command executed by ${interaction.user.tag}`);
|
||||
const replacer = (key: string, value: any) => {
|
||||
if (typeof value === 'bigint') {
|
||||
return value.toString();
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const currentConfigJson = JSON.stringify(config, replacer, 4);
|
||||
|
||||
const modal = new ModalBuilder()
|
||||
.setCustomId("config-modal")
|
||||
.setTitle("Edit Configuration");
|
||||
|
||||
const jsonInput = new TextInputBuilder()
|
||||
.setCustomId("json-input")
|
||||
.setLabel("Configuration JSON")
|
||||
.setStyle(TextInputStyle.Paragraph)
|
||||
.setValue(currentConfigJson)
|
||||
.setRequired(true);
|
||||
|
||||
const actionRow = new ActionRowBuilder<TextInputBuilder>().addComponents(jsonInput);
|
||||
modal.addComponents(actionRow);
|
||||
|
||||
await interaction.showModal(modal);
|
||||
|
||||
try {
|
||||
const submitted = await interaction.awaitModalSubmit({
|
||||
time: 300000, // 5 minutes
|
||||
filter: (i) => i.customId === "config-modal" && i.user.id === interaction.user.id
|
||||
});
|
||||
|
||||
const jsonString = submitted.fields.getTextInputValue("json-input");
|
||||
|
||||
try {
|
||||
const newConfig = JSON.parse(jsonString);
|
||||
saveConfig(newConfig as GameConfigType);
|
||||
|
||||
await submitted.reply({
|
||||
embeds: [createSuccessEmbed("Configuration updated successfully.", "Config Saved")]
|
||||
});
|
||||
} catch (parseError) {
|
||||
await submitted.reply({
|
||||
embeds: [createErrorEmbed(`Invalid JSON: ${parseError instanceof Error ? parseError.message : String(parseError)}`, "Config Update Failed")],
|
||||
ephemeral: true
|
||||
});
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
// Timeout or other error handling if needed, usually just ignore timeouts for modals
|
||||
if (error instanceof Error && error.message.includes('time')) {
|
||||
// specific timeout handling if desired
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
14
src/commands/admin/create_item.ts
Normal file
14
src/commands/admin/create_item.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { createCommand } from "@/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||
import { renderWizard } from "@/modules/admin/item_wizard";
|
||||
|
||||
export const createItem = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("createitem")
|
||||
.setDescription("Create a new item using the interactive wizard")
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
execute: async (interaction) => {
|
||||
const payload = renderWizard(interaction.user.id);
|
||||
await interaction.reply({ ...payload, flags: MessageFlags.Ephemeral });
|
||||
}
|
||||
});
|
||||
@@ -2,7 +2,7 @@ import { createCommand } from "@/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, EmbedBuilder, MessageFlags } from "discord.js";
|
||||
import { configManager } from "@/lib/configManager";
|
||||
import { config, reloadConfig } from "@/lib/config";
|
||||
import { KyokoClient } from "@/lib/BotClient"; // Import directly from lib, avoiding circular dep with index
|
||||
import { AuroraClient } from "@/lib/BotClient";
|
||||
|
||||
export const features = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
@@ -31,7 +31,7 @@ export const features = createCommand({
|
||||
const subcommand = interaction.options.getSubcommand();
|
||||
|
||||
if (subcommand === "list") {
|
||||
const activeCommands = KyokoClient.commands;
|
||||
const activeCommands = AuroraClient.commands;
|
||||
const categories = new Map<string, string[]>();
|
||||
|
||||
// Group active commands
|
||||
@@ -87,8 +87,8 @@ export const features = createCommand({
|
||||
// Reload config from disk (which was updated by configManager)
|
||||
reloadConfig();
|
||||
|
||||
await KyokoClient.loadCommands(true);
|
||||
await KyokoClient.deployCommands();
|
||||
await AuroraClient.loadCommands(true);
|
||||
await AuroraClient.deployCommands();
|
||||
|
||||
await interaction.editReply({ content: `✅ Command **${commandName}** has been ${enabled ? "enabled" : "disabled"}. Commands reloaded!` });
|
||||
}
|
||||
|
||||
@@ -11,15 +11,19 @@ import {
|
||||
} from "discord.js";
|
||||
import { inventoryService } from "@/modules/inventory/inventory.service";
|
||||
import { createErrorEmbed, createWarningEmbed } from "@lib/embeds";
|
||||
import { items } from "@/db/schema";
|
||||
import { ilike, isNotNull, and } from "drizzle-orm";
|
||||
import { DrizzleClient } from "@/lib/DrizzleClient";
|
||||
|
||||
export const listing = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("listing")
|
||||
.setDescription("Post an item listing in the channel for users to buy")
|
||||
.addNumberOption(option =>
|
||||
option.setName("itemid")
|
||||
.setDescription("The ID of the item to list")
|
||||
option.setName("item")
|
||||
.setDescription("The item to list")
|
||||
.setRequired(true)
|
||||
.setAutocomplete(true)
|
||||
)
|
||||
.addChannelOption(option =>
|
||||
option.setName("channel")
|
||||
@@ -30,7 +34,7 @@ export const listing = createCommand({
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
|
||||
const itemId = interaction.options.getNumber("itemid", true);
|
||||
const itemId = interaction.options.getNumber("item", true);
|
||||
const targetChannel = (interaction.options.getChannel("channel") as BaseGuildTextChannel) || interaction.channel as BaseGuildTextChannel;
|
||||
|
||||
if (!targetChannel || !targetChannel.isSendable()) {
|
||||
@@ -73,5 +77,29 @@ export const listing = createCommand({
|
||||
console.error("Failed to send listing message:", error);
|
||||
await interaction.editReply({ content: "", embeds: [createErrorEmbed("Failed to post the listing.")] });
|
||||
}
|
||||
},
|
||||
autocomplete: async (interaction) => {
|
||||
const focusedValue = interaction.options.getFocused();
|
||||
|
||||
const results = await DrizzleClient.select({
|
||||
id: items.id,
|
||||
name: items.name,
|
||||
price: items.price
|
||||
})
|
||||
.from(items)
|
||||
.where(
|
||||
and(
|
||||
ilike(items.name, `%${focusedValue}%`),
|
||||
isNotNull(items.price)
|
||||
)
|
||||
)
|
||||
.limit(20);
|
||||
|
||||
await interaction.respond(
|
||||
results.map(item => ({
|
||||
name: `${item.name} (Price: ${item.price})`,
|
||||
value: item.id
|
||||
}))
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
33
src/commands/admin/refresh.ts
Normal file
33
src/commands/admin/refresh.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { createCommand } from "@lib/utils";
|
||||
import { AuroraClient } from "@/lib/BotClient";
|
||||
import { SlashCommandBuilder, EmbedBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||
import { createErrorEmbed, createSuccessEmbed, createWarningEmbed } from "@lib/embeds";
|
||||
|
||||
export const refresh = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("refresh")
|
||||
.setDescription("Reloads all commands and config without restarting")
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
|
||||
try {
|
||||
const start = Date.now();
|
||||
await AuroraClient.loadCommands(true);
|
||||
const duration = Date.now() - start;
|
||||
|
||||
// Deploy commands
|
||||
await AuroraClient.deployCommands();
|
||||
|
||||
const embed = createSuccessEmbed(
|
||||
`Successfully reloaded ${AuroraClient.commands.size} commands in ${duration}ms.`,
|
||||
"System Refreshed"
|
||||
);
|
||||
|
||||
await interaction.editReply({ embeds: [embed] });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
await interaction.editReply({ embeds: [createErrorEmbed("An error occurred while refreshing commands. Check console for details.", "Refresh Failed")] });
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1,82 +0,0 @@
|
||||
import { createCommand } from "@lib/utils";
|
||||
import { KyokoClient } from "@/lib/BotClient";
|
||||
import { SlashCommandBuilder, EmbedBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||
import { createErrorEmbed, createSuccessEmbed, createWarningEmbed } from "@lib/embeds";
|
||||
|
||||
export const reload = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("reload")
|
||||
.setDescription("Reloads all commands")
|
||||
.addBooleanOption(option =>
|
||||
option
|
||||
.setName("redeploy")
|
||||
.setDescription("Pull latest changes from git and restart the bot")
|
||||
.setRequired(false)
|
||||
)
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
|
||||
const redeploy = interaction.options.getBoolean("redeploy") ?? false;
|
||||
|
||||
if (redeploy) {
|
||||
const { exec } = await import("child_process");
|
||||
const { promisify } = await import("util");
|
||||
const { writeFile, utimes } = await import("fs/promises");
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
try {
|
||||
await interaction.editReply({
|
||||
embeds: [createWarningEmbed("Pulling latest changes and restarting...", "Redeploy Initiated")]
|
||||
});
|
||||
|
||||
const { stdout, stderr } = await execAsync("git pull");
|
||||
|
||||
if (stderr && !stdout) {
|
||||
throw new Error(stderr);
|
||||
}
|
||||
|
||||
await interaction.editReply({
|
||||
embeds: [createSuccessEmbed(`Git Pull Output:\n\`\`\`\n${stdout}\n\`\`\`\nRestarting process...`, "Update Successful")]
|
||||
});
|
||||
|
||||
// Write context for post-restart notification
|
||||
await writeFile(".restart_context.json", JSON.stringify({
|
||||
channelId: interaction.channelId,
|
||||
userId: interaction.user.id,
|
||||
timestamp: Date.now()
|
||||
}));
|
||||
|
||||
// Trigger restart by touching entry point
|
||||
const { appendFile } = await import("fs/promises");
|
||||
await appendFile("src/index.ts", " ");
|
||||
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
await interaction.editReply({
|
||||
embeds: [createErrorEmbed(`Failed to redeploy:\n\`\`\`\n${error instanceof Error ? error.message : String(error)}\n\`\`\``, "Redeploy Failed")]
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const start = Date.now();
|
||||
await KyokoClient.loadCommands(true);
|
||||
const duration = Date.now() - start;
|
||||
|
||||
// Deploy commands
|
||||
await KyokoClient.deployCommands();
|
||||
|
||||
const embed = createSuccessEmbed(
|
||||
`Successfully reloaded ${KyokoClient.commands.size} commands in ${duration}ms.`,
|
||||
"System Reloaded"
|
||||
);
|
||||
|
||||
await interaction.editReply({ embeds: [embed] });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
await interaction.editReply({ embeds: [createErrorEmbed("An error occurred while reloading commands. Check console for details.", "Reload Failed")] });
|
||||
}
|
||||
}
|
||||
});
|
||||
124
src/commands/admin/update.ts
Normal file
124
src/commands/admin/update.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { createCommand } from "@lib/utils";
|
||||
import { SlashCommandBuilder, EmbedBuilder, PermissionFlagsBits, MessageFlags, ButtonBuilder, ButtonStyle, ActionRowBuilder, ComponentType } from "discord.js";
|
||||
import { createErrorEmbed, createSuccessEmbed, createWarningEmbed, createInfoEmbed } from "@lib/embeds";
|
||||
|
||||
export const update = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("update")
|
||||
.setDescription("Check for updates and restart the bot")
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
|
||||
const { exec } = await import("child_process");
|
||||
const { promisify } = await import("util");
|
||||
const { writeFile, appendFile } = await import("fs/promises");
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
try {
|
||||
// Get current branch
|
||||
const { stdout: branchName } = await execAsync("git rev-parse --abbrev-ref HEAD");
|
||||
const branch = branchName.trim();
|
||||
|
||||
await interaction.editReply({
|
||||
embeds: [createInfoEmbed("Fetching latest changes...", "Checking for Updates")]
|
||||
});
|
||||
|
||||
// Fetch remote
|
||||
await execAsync("git fetch --all");
|
||||
|
||||
// Check for potential changes
|
||||
const { stdout: logOutput } = await execAsync(`git log HEAD..origin/${branch} --oneline`);
|
||||
|
||||
if (!logOutput.trim()) {
|
||||
await interaction.editReply({
|
||||
embeds: [createSuccessEmbed("The bot is already up to date.", "No Updates Found")]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Prepare confirmation UI
|
||||
const confirmButton = new ButtonBuilder()
|
||||
.setCustomId("confirm_update")
|
||||
.setLabel("Update & Restart")
|
||||
.setStyle(ButtonStyle.Success);
|
||||
|
||||
const cancelButton = new ButtonBuilder()
|
||||
.setCustomId("cancel_update")
|
||||
.setLabel("Cancel")
|
||||
.setStyle(ButtonStyle.Secondary);
|
||||
|
||||
const row = new ActionRowBuilder<ButtonBuilder>()
|
||||
.addComponents(confirmButton, cancelButton);
|
||||
|
||||
const updateEmbed = createInfoEmbed(
|
||||
`The following changes are available:\n\`\`\`\n${logOutput.substring(0, 1000)}${logOutput.length > 1000 ? "\n...and more" : ""}\n\`\`\`\n**Do you want to update and restart?**`,
|
||||
"Updates Available"
|
||||
);
|
||||
|
||||
const response = await interaction.editReply({
|
||||
embeds: [updateEmbed],
|
||||
components: [row]
|
||||
});
|
||||
|
||||
try {
|
||||
const confirmation = await response.awaitMessageComponent({
|
||||
filter: (i) => i.user.id === interaction.user.id,
|
||||
componentType: ComponentType.Button,
|
||||
time: 30000 // 30 seconds timeout
|
||||
});
|
||||
|
||||
if (confirmation.customId === "confirm_update") {
|
||||
await confirmation.update({
|
||||
embeds: [createWarningEmbed("Applying updates and restarting...\nThe bot will run database migrations on next startup.", "Update In Progress")],
|
||||
components: []
|
||||
});
|
||||
|
||||
// Write context BEFORE reset, because reset -> watcher restart
|
||||
await writeFile(".restart_context.json", JSON.stringify({
|
||||
channelId: interaction.channelId,
|
||||
userId: interaction.user.id,
|
||||
timestamp: Date.now(),
|
||||
runMigrations: true
|
||||
}));
|
||||
|
||||
const { stdout } = await execAsync(`git reset --hard origin/${branch}`);
|
||||
|
||||
// In case we are not running with a watcher, or if no files were changed (unlikely given log check),
|
||||
// we might need to manually trigger restart.
|
||||
// But if files changed, watcher kicks in here or slightly after.
|
||||
// If we are here, we can try to force a touch or just exit.
|
||||
|
||||
// Trigger restart just in case watcher didn't catch it or we are in a mode without watcher (though update implies source change)
|
||||
try {
|
||||
await appendFile("src/index.ts", " ");
|
||||
} catch (err) {
|
||||
console.error("Failed to touch triggers:", err);
|
||||
}
|
||||
|
||||
// The process should die now or soon.
|
||||
// We do NOT run migrations here anymore.
|
||||
|
||||
} else {
|
||||
await confirmation.update({
|
||||
embeds: [createInfoEmbed("Update cancelled.", "Cancelled")],
|
||||
components: []
|
||||
});
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
// Timeout
|
||||
await interaction.editReply({
|
||||
embeds: [createWarningEmbed("Update confirmation timed out.", "Timed Out")],
|
||||
components: []
|
||||
});
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
await interaction.editReply({
|
||||
embeds: [createErrorEmbed(`Failed to check for updates:\n\`\`\`\n${error instanceof Error ? error.message : String(error)}\n\`\`\``, "Update Check Failed")]
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createCommand } from "@/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, TextChannel, NewsChannel, VoiceChannel, MessageFlags } from "discord.js";
|
||||
import { createErrorEmbed } from "@/lib/embeds";
|
||||
import { sendWebhookMessage } from "@/lib/webhookUtils";
|
||||
|
||||
export const webhook = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
@@ -36,37 +37,17 @@ export const webhook = createCommand({
|
||||
return;
|
||||
}
|
||||
|
||||
let webhook;
|
||||
try {
|
||||
webhook = await channel.createWebhook({
|
||||
name: `${interaction.client.user.username} - Proxy`,
|
||||
avatar: interaction.client.user.displayAvatarURL(),
|
||||
reason: `Proxy message requested by ${interaction.user.tag}`
|
||||
});
|
||||
|
||||
|
||||
// Support snake_case keys for raw API compatibility
|
||||
if (payload.avatar_url && !payload.avatarURL) {
|
||||
payload.avatarURL = payload.avatar_url;
|
||||
delete payload.avatar_url;
|
||||
}
|
||||
|
||||
await webhook.send(payload);
|
||||
|
||||
await webhook.delete("Proxy message sent");
|
||||
await sendWebhookMessage(
|
||||
channel,
|
||||
payload,
|
||||
interaction.client.user,
|
||||
`Proxy message requested by ${interaction.user.tag}`
|
||||
);
|
||||
|
||||
await interaction.editReply({ content: "Message sent successfully!" });
|
||||
} catch (error) {
|
||||
console.error("Webhook error:", error);
|
||||
// Attempt cleanup if webhook was created but sending failed
|
||||
if (webhook) {
|
||||
try {
|
||||
await webhook.delete("Cleanup after failure");
|
||||
} catch (cleanupError) {
|
||||
console.error("Failed to delete webhook during cleanup:", cleanupError);
|
||||
}
|
||||
}
|
||||
|
||||
await interaction.editReply({
|
||||
embeds: [createErrorEmbed("Failed to send message via webhook. Ensure the bot has 'Manage Webhooks' permission and the payload is valid.", "Delivery Failed")]
|
||||
});
|
||||
|
||||
186
src/commands/economy/exam.ts
Normal file
186
src/commands/economy/exam.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
import { createCommand } from "@/lib/utils";
|
||||
import { SlashCommandBuilder } from "discord.js";
|
||||
import { userService } from "@/modules/user/user.service";
|
||||
import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
|
||||
import { userTimers, users } from "@/db/schema";
|
||||
import { eq, and, sql } from "drizzle-orm";
|
||||
import { DrizzleClient } from "@/lib/DrizzleClient";
|
||||
import { config } from "@lib/config";
|
||||
|
||||
const EXAM_TIMER_TYPE = 'EXAM_SYSTEM';
|
||||
const EXAM_TIMER_KEY = 'default';
|
||||
|
||||
interface ExamMetadata {
|
||||
examDay: number;
|
||||
lastXp: string;
|
||||
}
|
||||
|
||||
const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
||||
|
||||
export const exam = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("exam")
|
||||
.setDescription("Take your weekly exam to earn rewards based on your XP progress."),
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply();
|
||||
const user = await userService.getOrCreateUser(interaction.user.id, interaction.user.username);
|
||||
const now = new Date();
|
||||
const currentDay = now.getDay();
|
||||
|
||||
try {
|
||||
// 1. Fetch existing timer/exam data
|
||||
const timer = await DrizzleClient.query.userTimers.findFirst({
|
||||
where: and(
|
||||
eq(userTimers.userId, user.id),
|
||||
eq(userTimers.type, EXAM_TIMER_TYPE),
|
||||
eq(userTimers.key, EXAM_TIMER_KEY)
|
||||
)
|
||||
});
|
||||
|
||||
// 2. First Run Logic
|
||||
if (!timer) {
|
||||
// Set exam day to today
|
||||
const nextWeek = new Date(now);
|
||||
nextWeek.setDate(now.getDate() + 7);
|
||||
|
||||
const metadata: ExamMetadata = {
|
||||
examDay: currentDay,
|
||||
lastXp: user.xp.toString()
|
||||
};
|
||||
|
||||
await DrizzleClient.insert(userTimers).values({
|
||||
userId: user.id,
|
||||
type: EXAM_TIMER_TYPE,
|
||||
key: EXAM_TIMER_KEY,
|
||||
expiresAt: nextWeek,
|
||||
metadata: metadata
|
||||
});
|
||||
|
||||
await interaction.editReply({
|
||||
embeds: [createSuccessEmbed(
|
||||
`You have registered for the exam! Your exam day is **${DAYS[currentDay]}**.\n` +
|
||||
`Come back next week on **${DAYS[currentDay]}** to take your first exam and earn rewards based on your XP gain!`,
|
||||
"Exam Registration Successful"
|
||||
)]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const metadata = timer.metadata as unknown as ExamMetadata;
|
||||
const examDay = metadata.examDay;
|
||||
|
||||
// 3. Cooldown Check
|
||||
if (now < new Date(timer.expiresAt)) {
|
||||
// Calculate time remaining
|
||||
const expiresAt = new Date(timer.expiresAt);
|
||||
// Simple formatting
|
||||
const timestamp = Math.floor(expiresAt.getTime() / 1000);
|
||||
|
||||
await interaction.editReply({
|
||||
embeds: [createErrorEmbed(
|
||||
`You have already taken your exam for this week (or are waiting for your first week to pass).\n` +
|
||||
`Next exam available: <t:${timestamp}:R> (${DAYS[examDay]})`
|
||||
)]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. Day Check
|
||||
if (currentDay !== examDay) {
|
||||
// "If not executed on same weekday... we do not reward"
|
||||
// Consume the attempt (reset timer) but give 0 reward.
|
||||
|
||||
const nextWeek = new Date(now);
|
||||
nextWeek.setDate(now.getDate() + 7);
|
||||
|
||||
const newMetadata: ExamMetadata = {
|
||||
examDay: examDay,
|
||||
lastXp: user.xp.toString() // Reset tracking
|
||||
};
|
||||
|
||||
await DrizzleClient.update(userTimers)
|
||||
.set({
|
||||
expiresAt: nextWeek,
|
||||
metadata: newMetadata
|
||||
})
|
||||
.where(and(
|
||||
eq(userTimers.userId, user.id),
|
||||
eq(userTimers.type, EXAM_TIMER_TYPE),
|
||||
eq(userTimers.key, EXAM_TIMER_KEY)
|
||||
));
|
||||
|
||||
await interaction.editReply({
|
||||
embeds: [createErrorEmbed(
|
||||
`You missed your exam day! Your exam is on **${DAYS[examDay]}**, but today is ${DAYS[currentDay]}.\n` +
|
||||
`You verify your attendance but score a **0**. Come back next **${DAYS[examDay]}**!`,
|
||||
"Exam Failed"
|
||||
)]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 5. Reward Calculation
|
||||
const lastXp = BigInt(metadata.lastXp || "0"); // Fallback just in case
|
||||
const currentXp = user.xp;
|
||||
const diff = currentXp - lastXp;
|
||||
|
||||
// Calculate Reward
|
||||
const multMin = config.economy.exam.multMin;
|
||||
const multMax = config.economy.exam.multMax;
|
||||
const multiplier = Math.random() * (multMax - multMin) + multMin;
|
||||
|
||||
// Allow negative reward? existing description implies "difference", usually gain.
|
||||
// If diff is negative (lost XP?), reward might be 0.
|
||||
let reward = 0n;
|
||||
if (diff > 0n) {
|
||||
reward = BigInt(Math.floor(Number(diff) * multiplier));
|
||||
}
|
||||
|
||||
// 6. Update State
|
||||
const nextWeek = new Date(now);
|
||||
nextWeek.setDate(now.getDate() + 7);
|
||||
|
||||
const newMetadata: ExamMetadata = {
|
||||
examDay: examDay,
|
||||
lastXp: currentXp.toString()
|
||||
};
|
||||
|
||||
await DrizzleClient.transaction(async (tx) => {
|
||||
// Update Timer
|
||||
await tx.update(userTimers)
|
||||
.set({
|
||||
expiresAt: nextWeek,
|
||||
metadata: newMetadata
|
||||
})
|
||||
.where(and(
|
||||
eq(userTimers.userId, user.id),
|
||||
eq(userTimers.type, EXAM_TIMER_TYPE),
|
||||
eq(userTimers.key, EXAM_TIMER_KEY)
|
||||
));
|
||||
|
||||
// Add Currency
|
||||
if (reward > 0n) {
|
||||
await tx.update(users)
|
||||
.set({
|
||||
balance: sql`${users.balance} + ${reward}`
|
||||
})
|
||||
.where(eq(users.id, user.id));
|
||||
}
|
||||
});
|
||||
|
||||
await interaction.editReply({
|
||||
embeds: [createSuccessEmbed(
|
||||
`**XP Gained:** ${diff.toString()}\n` +
|
||||
`**Multiplier:** x${multiplier.toFixed(2)}\n` +
|
||||
`**Reward:** ${reward.toString()} Currency\n\n` +
|
||||
`See you next week on **${DAYS[examDay]}**!`,
|
||||
"Exam Passed!"
|
||||
)]
|
||||
});
|
||||
|
||||
} catch (error: any) {
|
||||
console.error("Exam command error:", error);
|
||||
await interaction.editReply({ embeds: [createErrorEmbed("An error occurred while processing your exam.")] });
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -19,34 +19,6 @@ export const use = createCommand({
|
||||
.setAutocomplete(true)
|
||||
),
|
||||
execute: async (interaction) => {
|
||||
if (!interaction.isChatInputCommand()) {
|
||||
if (interaction.isAutocomplete()) {
|
||||
const focusedValue = interaction.options.getFocused();
|
||||
const userId = interaction.user.id;
|
||||
|
||||
// Fetch owned items that are usable
|
||||
const userInventory = await DrizzleClient.query.inventory.findMany({
|
||||
where: eq(inventory.userId, BigInt(userId)),
|
||||
with: {
|
||||
item: true
|
||||
},
|
||||
limit: 10
|
||||
});
|
||||
|
||||
const filtered = userInventory.filter(entry => {
|
||||
const matchName = entry.item.name.toLowerCase().includes(focusedValue.toLowerCase());
|
||||
const usageData = entry.item.usageData as ItemUsageData | null;
|
||||
const isUsable = usageData && usageData.effects && usageData.effects.length > 0;
|
||||
return matchName && isUsable;
|
||||
});
|
||||
|
||||
await interaction.respond(
|
||||
filtered.map(entry => ({ name: `${entry.item.name} (${entry.quantity})`, value: entry.item.id }))
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await interaction.deferReply();
|
||||
|
||||
const itemId = interaction.options.getNumber("item", true);
|
||||
@@ -55,14 +27,6 @@ export const use = createCommand({
|
||||
try {
|
||||
const result = await inventoryService.useItem(user.id, itemId);
|
||||
|
||||
// Check for side effects like Role assignment that need Discord API access
|
||||
// The service returns the usageData, so we can re-check simple effects or just check the results log?
|
||||
// Actually, we put "TEMP_ROLE" inside results log, AND we can check usageData here for strict role assignment if we want to separate concerns.
|
||||
// But for now, let's rely on the service to have handled database state, and we handle Discord state here if needed?
|
||||
// WAIT - I put the role assignment placeholder in the service but it returned a result string.
|
||||
// The service cannot assign the role directly because it doesn't have the member object easily (requires fetching).
|
||||
// So we should iterate results or usageData here.
|
||||
|
||||
const usageData = result.usageData;
|
||||
if (usageData) {
|
||||
for (const effect of usageData.effects) {
|
||||
@@ -91,5 +55,33 @@ export const use = createCommand({
|
||||
} catch (error: any) {
|
||||
await interaction.editReply({ embeds: [createErrorEmbed(error.message)] });
|
||||
}
|
||||
},
|
||||
autocomplete: async (interaction) => {
|
||||
const focusedValue = interaction.options.getFocused();
|
||||
const userId = interaction.user.id;
|
||||
|
||||
// Fetch owned items that match the search query
|
||||
// We join with items table to filter by name directly in the database
|
||||
const entries = await DrizzleClient.select({
|
||||
quantity: inventory.quantity,
|
||||
item: items
|
||||
})
|
||||
.from(inventory)
|
||||
.innerJoin(items, eq(inventory.itemId, items.id))
|
||||
.where(and(
|
||||
eq(inventory.userId, BigInt(userId)),
|
||||
like(items.name, `%${focusedValue}%`)
|
||||
))
|
||||
.limit(20); // Fetch up to 20 matching items
|
||||
|
||||
const filtered = entries.filter(entry => {
|
||||
const usageData = entry.item.usageData as ItemUsageData | null;
|
||||
const isUsable = usageData && usageData.effects && usageData.effects.length > 0;
|
||||
return isUsable;
|
||||
});
|
||||
|
||||
await interaction.respond(
|
||||
filtered.map(entry => ({ name: `${entry.item.name} (${entry.quantity})`, value: entry.item.id }))
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
{
|
||||
"leveling": {
|
||||
"base": 100,
|
||||
"exponent": 2.5,
|
||||
"chat": {
|
||||
"cooldownMs": 60000,
|
||||
"minXp": 15,
|
||||
"maxXp": 25
|
||||
}
|
||||
},
|
||||
"economy": {
|
||||
"daily": {
|
||||
"amount": "100",
|
||||
"streakBonus": "10",
|
||||
"cooldownMs": 86400000
|
||||
},
|
||||
"transfers": {
|
||||
"allowSelfTransfer": false,
|
||||
"minAmount": "1"
|
||||
}
|
||||
},
|
||||
"inventory": {
|
||||
"maxStackSize": "999",
|
||||
"maxSlots": 50
|
||||
},
|
||||
"commands": {
|
||||
"daily": true,
|
||||
"quests": false,
|
||||
"inventory": false,
|
||||
"trade": false,
|
||||
"balance": false
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ export const classes = pgTable('classes', {
|
||||
id: bigint('id', { mode: 'bigint' }).primaryKey(),
|
||||
name: varchar('name', { length: 255 }).unique().notNull(),
|
||||
balance: bigint('balance', { mode: 'bigint' }).default(0n),
|
||||
roleId: varchar('role_id', { length: 255 }),
|
||||
});
|
||||
|
||||
// 2. Users
|
||||
@@ -130,8 +131,17 @@ export const userTimers = pgTable('user_timers', {
|
||||
}, (table) => [
|
||||
primaryKey({ columns: [table.userId, table.type, table.key] })
|
||||
]);
|
||||
// 9. Lootdrops
|
||||
export const lootdrops = pgTable('lootdrops', {
|
||||
messageId: varchar('message_id', { length: 255 }).primaryKey(),
|
||||
channelId: varchar('channel_id', { length: 255 }).notNull(),
|
||||
rewardAmount: integer('reward_amount').notNull(),
|
||||
currency: varchar('currency', { length: 50 }).notNull(),
|
||||
claimedBy: bigint('claimed_by', { mode: 'bigint' }).references(() => users.id, { onDelete: 'set null' }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
expiresAt: timestamp('expires_at', { withTimezone: true }),
|
||||
});
|
||||
|
||||
// --- RELATIONS ---
|
||||
|
||||
export const classesRelations = relations(classes, ({ many }) => ({
|
||||
users: many(users),
|
||||
|
||||
@@ -1,13 +1,34 @@
|
||||
import { Events } from "discord.js";
|
||||
import type { Event } from "@lib/types";
|
||||
import { config } from "@lib/config";
|
||||
import { userService } from "@modules/user/user.service";
|
||||
|
||||
// Visitor role
|
||||
const event: Event<Events.GuildMemberAdd> = {
|
||||
name: Events.GuildMemberAdd,
|
||||
execute: async (member) => {
|
||||
const role = member.guild.roles.cache.find(role => role.id === "1449859380269940947");
|
||||
if (!role) return;
|
||||
await member.roles.add(role);
|
||||
console.log(`👤 New member joined: ${member.user.tag} (${member.id})`);
|
||||
try {
|
||||
const user = await userService.getUserById(member.id);
|
||||
|
||||
if (user && user.class) {
|
||||
console.log(`🔄 Returning student detected: ${member.user.tag}`);
|
||||
await member.roles.remove(config.visitorRole);
|
||||
await member.roles.add(config.studentRole);
|
||||
|
||||
if (user.class.roleId) {
|
||||
await member.roles.add(user.class.roleId);
|
||||
console.log(`Restored class role ${user.class.name} to ${member.user.tag}`);
|
||||
}
|
||||
console.log(`Restored student role to ${member.user.tag}`);
|
||||
} else {
|
||||
await member.roles.add(config.visitorRole);
|
||||
console.log(`Assigned visitor role to ${member.user.tag}`);
|
||||
}
|
||||
console.log(`User Roles: ${member.roles.cache.map(role => role.name).join(", ")}`);
|
||||
} catch (error) {
|
||||
console.error(`Failed to handle role assignment for ${member.user.tag}:`, error);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Events, MessageFlags } from "discord.js";
|
||||
import { KyokoClient } from "@/lib/BotClient";
|
||||
import { AuroraClient } from "@/lib/BotClient";
|
||||
import { userService } from "@/modules/user/user.service";
|
||||
import { createErrorEmbed } from "@lib/embeds";
|
||||
import type { Event } from "@lib/types";
|
||||
@@ -17,11 +17,34 @@ const event: Event<Events.InteractionCreate> = {
|
||||
await import("@/modules/economy/shop.interaction").then(m => m.handleShopInteraction(interaction));
|
||||
return;
|
||||
}
|
||||
if (interaction.customId.startsWith("lootdrop_") && interaction.isButton()) {
|
||||
await import("@/modules/economy/lootdrop.interaction").then(m => m.handleLootdropInteraction(interaction));
|
||||
return;
|
||||
}
|
||||
if (interaction.customId.startsWith("createitem_")) {
|
||||
await import("@/modules/admin/item_wizard").then(m => m.handleItemWizardInteraction(interaction));
|
||||
return;
|
||||
}
|
||||
if (interaction.customId === "enrollment" && interaction.isButton()) {
|
||||
await import("@/modules/user/enrollment.interaction").then(m => m.handleEnrollmentInteraction(interaction));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (interaction.isAutocomplete()) {
|
||||
const command = AuroraClient.commands.get(interaction.commandName);
|
||||
if (!command || !command.autocomplete) return;
|
||||
try {
|
||||
await command.autocomplete(interaction);
|
||||
} catch (error) {
|
||||
console.error(`Error handling autocomplete for ${interaction.commandName}:`, error);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!interaction.isChatInputCommand()) return;
|
||||
|
||||
const command = KyokoClient.commands.get(interaction.commandName);
|
||||
const command = AuroraClient.commands.get(interaction.commandName);
|
||||
|
||||
if (!command) {
|
||||
console.error(`No command matching ${interaction.commandName} was found.`);
|
||||
|
||||
@@ -13,6 +13,11 @@ const event: Event<Events.MessageCreate> = {
|
||||
if (!user) return;
|
||||
|
||||
levelingService.processChatXp(message.author.id);
|
||||
|
||||
// Activity Tracking for Lootdrops
|
||||
// We do dynamic import to avoid circular dependency issues if any, though likely not needed here.
|
||||
// But better safe for modules. Actually direct import is fine if structure is clean.
|
||||
import("@/modules/economy/lootdrop.service").then(m => m.lootdropService.processMessage(message));
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -20,10 +20,37 @@ const event: Event<Events.ClientReady> = {
|
||||
// Validate context freshness (e.g., ignore if older than 5 minutes)
|
||||
if (Date.now() - context.timestamp < 5 * 60 * 1000) {
|
||||
const channel = await c.channels.fetch(context.channelId);
|
||||
|
||||
if (channel && channel.isSendable()) {
|
||||
await channel.send({
|
||||
embeds: [createSuccessEmbed("Bot is back online! Redeploy successful.", "System Online")]
|
||||
});
|
||||
let migrationOutput = "";
|
||||
let success = true;
|
||||
|
||||
if (context.runMigrations) {
|
||||
try {
|
||||
const { exec } = await import("child_process");
|
||||
const { promisify } = await import("util");
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
// Send intermediate update if possible, though ready event should be fast.
|
||||
// Maybe just run it and report result.
|
||||
const { stdout: dbOut } = await execAsync("bun run db:push:local");
|
||||
migrationOutput = dbOut;
|
||||
} catch (dbErr: any) {
|
||||
success = false;
|
||||
migrationOutput = `Migration Failed: ${dbErr.message}`;
|
||||
console.error("Migration Error:", dbErr);
|
||||
}
|
||||
}
|
||||
|
||||
if (context.runMigrations) {
|
||||
await channel.send({
|
||||
embeds: [createSuccessEmbed(`Bot is back online!\n\n**DB Migration Output:**\n\`\`\`\n${migrationOutput}\n\`\`\``, success ? "Update Successful" : "Update Completed with/Errors")]
|
||||
});
|
||||
} else {
|
||||
await channel.send({
|
||||
embeds: [createSuccessEmbed("Bot is back online! Redeploy successful.", "System Online")]
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
10
src/index.ts
10
src/index.ts
@@ -1,14 +1,14 @@
|
||||
import { KyokoClient } from "@/lib/BotClient";
|
||||
import { AuroraClient } from "@/lib/BotClient";
|
||||
import { env } from "@lib/env";
|
||||
|
||||
// Load commands & events
|
||||
await KyokoClient.loadCommands();
|
||||
await KyokoClient.loadEvents();
|
||||
await KyokoClient.deployCommands();
|
||||
await AuroraClient.loadCommands();
|
||||
await AuroraClient.loadEvents();
|
||||
await AuroraClient.deployCommands();
|
||||
|
||||
|
||||
// login with the token from .env
|
||||
if (!env.DISCORD_BOT_TOKEN) {
|
||||
throw new Error("❌ DISCORD_BOT_TOKEN is not set in environment variables.");
|
||||
}
|
||||
KyokoClient.login(env.DISCORD_BOT_TOKEN);
|
||||
AuroraClient.login(env.DISCORD_BOT_TOKEN);
|
||||
@@ -185,4 +185,4 @@ class Client extends DiscordClient {
|
||||
}
|
||||
}
|
||||
|
||||
export const KyokoClient = new Client({ intents: [GatewayIntentBits.Guilds, GatewayIntentBits.MessageContent, GatewayIntentBits.GuildMessages] });
|
||||
export const AuroraClient = new Client({ intents: [GatewayIntentBits.Guilds, GatewayIntentBits.MessageContent, GatewayIntentBits.GuildMessages, GatewayIntentBits.GuildMembers] });
|
||||
@@ -1,7 +1,8 @@
|
||||
import { readFileSync, existsSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { readFileSync, existsSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { z } from 'zod';
|
||||
|
||||
const configPath = join(process.cwd(), 'src', 'config', 'config.json');
|
||||
const configPath = join(import.meta.dir, '..', '..', 'config', 'config.json');
|
||||
|
||||
export interface GameConfigType {
|
||||
leveling: {
|
||||
@@ -22,6 +23,10 @@ export interface GameConfigType {
|
||||
transfers: {
|
||||
allowSelfTransfer: boolean;
|
||||
minAmount: bigint;
|
||||
},
|
||||
exam: {
|
||||
multMin: number;
|
||||
multMax: number;
|
||||
}
|
||||
},
|
||||
inventory: {
|
||||
@@ -29,11 +34,85 @@ export interface GameConfigType {
|
||||
maxSlots: number;
|
||||
},
|
||||
commands: Record<string, boolean>;
|
||||
lootdrop: {
|
||||
activityWindowMs: number;
|
||||
minMessages: number;
|
||||
spawnChance: number;
|
||||
cooldownMs: number;
|
||||
reward: {
|
||||
min: number;
|
||||
max: number;
|
||||
currency: string;
|
||||
}
|
||||
};
|
||||
studentRole: string;
|
||||
visitorRole: string;
|
||||
welcomeChannelId?: string;
|
||||
welcomeMessage?: string;
|
||||
}
|
||||
|
||||
// Initial default config state
|
||||
export const config: GameConfigType = {} as GameConfigType;
|
||||
|
||||
const bigIntSchema = z.union([z.string(), z.number(), z.bigint()])
|
||||
.refine((val) => {
|
||||
try {
|
||||
BigInt(val);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, { message: "Must be a valid integer" })
|
||||
.transform((val) => BigInt(val));
|
||||
|
||||
const configSchema = z.object({
|
||||
leveling: z.object({
|
||||
base: z.number(),
|
||||
exponent: z.number(),
|
||||
chat: z.object({
|
||||
cooldownMs: z.number(),
|
||||
minXp: z.number(),
|
||||
maxXp: z.number(),
|
||||
})
|
||||
}),
|
||||
economy: z.object({
|
||||
daily: z.object({
|
||||
amount: bigIntSchema,
|
||||
streakBonus: bigIntSchema,
|
||||
cooldownMs: z.number(),
|
||||
}),
|
||||
transfers: z.object({
|
||||
allowSelfTransfer: z.boolean(),
|
||||
minAmount: bigIntSchema,
|
||||
}),
|
||||
exam: z.object({
|
||||
multMin: z.number(),
|
||||
multMax: z.number(),
|
||||
})
|
||||
}),
|
||||
inventory: z.object({
|
||||
maxStackSize: bigIntSchema,
|
||||
maxSlots: z.number(),
|
||||
}),
|
||||
commands: z.record(z.string(), z.boolean()),
|
||||
lootdrop: z.object({
|
||||
activityWindowMs: z.number(),
|
||||
minMessages: z.number(),
|
||||
spawnChance: z.number(),
|
||||
cooldownMs: z.number(),
|
||||
reward: z.object({
|
||||
min: z.number(),
|
||||
max: z.number(),
|
||||
currency: z.string(),
|
||||
})
|
||||
|
||||
}),
|
||||
studentRole: z.string(),
|
||||
visitorRole: z.string(),
|
||||
welcomeChannelId: z.string().optional(),
|
||||
welcomeMessage: z.string().optional()
|
||||
});
|
||||
|
||||
export function reloadConfig() {
|
||||
if (!existsSync(configPath)) {
|
||||
throw new Error(`Config file not found at ${configPath}`);
|
||||
@@ -53,13 +132,19 @@ export function reloadConfig() {
|
||||
transfers: {
|
||||
...rawConfig.economy.transfers,
|
||||
minAmount: BigInt(rawConfig.economy.transfers.minAmount),
|
||||
}
|
||||
},
|
||||
exam: rawConfig.economy.exam,
|
||||
};
|
||||
config.inventory = {
|
||||
...rawConfig.inventory,
|
||||
maxStackSize: BigInt(rawConfig.inventory.maxStackSize),
|
||||
};
|
||||
config.commands = rawConfig.commands || {};
|
||||
config.lootdrop = rawConfig.lootdrop;
|
||||
config.studentRole = rawConfig.studentRole;
|
||||
config.visitorRole = rawConfig.visitorRole;
|
||||
config.welcomeChannelId = rawConfig.welcomeChannelId;
|
||||
config.welcomeMessage = rawConfig.welcomeMessage;
|
||||
|
||||
console.log("🔄 Config reloaded from disk.");
|
||||
}
|
||||
@@ -69,3 +154,19 @@ reloadConfig();
|
||||
|
||||
// Backwards compatibility alias
|
||||
export const GameConfig = config;
|
||||
|
||||
export function saveConfig(newConfig: unknown) {
|
||||
// Validate and transform input
|
||||
const validatedConfig = configSchema.parse(newConfig);
|
||||
|
||||
const replacer = (key: string, value: any) => {
|
||||
if (typeof value === 'bigint') {
|
||||
return value.toString();
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const jsonString = JSON.stringify(validatedConfig, replacer, 4);
|
||||
writeFileSync(configPath, jsonString, 'utf-8');
|
||||
reloadConfig();
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { readFileSync, writeFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { KyokoClient } from '@/lib/BotClient';
|
||||
import { AuroraClient } from '@/lib/BotClient';
|
||||
|
||||
const configPath = join(process.cwd(), 'src', 'config', 'config.json');
|
||||
const configPath = join(process.cwd(), 'config', 'config.json');
|
||||
|
||||
export const configManager = {
|
||||
toggleCommand: (commandName: string, enabled: boolean) => {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { ChatInputCommandInteraction, ClientEvents, SlashCommandBuilder, SlashCommandOptionsOnlyBuilder, SlashCommandSubcommandsOnlyBuilder } from "discord.js";
|
||||
import type { AutocompleteInteraction, ChatInputCommandInteraction, ClientEvents, SlashCommandBuilder, SlashCommandOptionsOnlyBuilder, SlashCommandSubcommandsOnlyBuilder } from "discord.js";
|
||||
|
||||
export interface Command {
|
||||
data: SlashCommandBuilder | SlashCommandOptionsOnlyBuilder | SlashCommandSubcommandsOnlyBuilder;
|
||||
execute: (interaction: ChatInputCommandInteraction) => Promise<void> | void;
|
||||
autocomplete?: (interaction: AutocompleteInteraction) => Promise<void> | void;
|
||||
category?: string;
|
||||
}
|
||||
|
||||
|
||||
56
src/lib/webhookUtils.ts
Normal file
56
src/lib/webhookUtils.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { type TextBasedChannel, User, Client } from 'discord.js';
|
||||
|
||||
/**
|
||||
* Sends a message to a channel using a temporary webhook (imitating the bot or custom persona).
|
||||
*
|
||||
* @param channel The channel to send the message to (must support webhooks).
|
||||
* @param payload The message payload (string content or JSON object for embeds/options).
|
||||
* @param clientUser The client user (bot) to fallback for avatar/name if not specified in payload.
|
||||
* @param reason The reason for creating the webhook (for audit logs).
|
||||
*/
|
||||
export async function sendWebhookMessage(
|
||||
channel: TextBasedChannel,
|
||||
payload: any,
|
||||
clientUser: User,
|
||||
reason: string
|
||||
): Promise<void> {
|
||||
|
||||
if (!('createWebhook' in channel)) {
|
||||
throw new Error("Channel does not support webhooks.");
|
||||
}
|
||||
|
||||
// Normalize payload if it's just a string, wrap it in content
|
||||
if (typeof payload === 'string') {
|
||||
payload = { content: payload };
|
||||
}
|
||||
|
||||
let webhook;
|
||||
try {
|
||||
webhook = await channel.createWebhook({
|
||||
name: payload.username || `${clientUser.username}`, // Use payload name or bot name
|
||||
avatar: payload.avatar_url || payload.avatarURL || clientUser.displayAvatarURL(),
|
||||
reason: reason
|
||||
});
|
||||
|
||||
// Support snake_case keys for raw API compatibility if passed from config
|
||||
if (payload.avatar_url && !payload.avatarURL) {
|
||||
payload.avatarURL = payload.avatar_url;
|
||||
delete payload.avatar_url;
|
||||
}
|
||||
|
||||
await webhook.send(payload);
|
||||
|
||||
await webhook.delete(reason);
|
||||
|
||||
} catch (error) {
|
||||
// Attempt cleanup if webhook was created but sending failed
|
||||
if (webhook) {
|
||||
try {
|
||||
await webhook.delete("Cleanup after failure");
|
||||
} catch (cleanupError) {
|
||||
console.error("Failed to delete webhook during cleanup:", cleanupError);
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
261
src/modules/admin/item_wizard.test.ts
Normal file
261
src/modules/admin/item_wizard.test.ts
Normal file
@@ -0,0 +1,261 @@
|
||||
import { describe, test, expect, spyOn, beforeEach, mock } from "bun:test";
|
||||
import { handleItemWizardInteraction, renderWizard } from "./item_wizard";
|
||||
import { DrizzleClient } from "@/lib/DrizzleClient";
|
||||
import { ButtonInteraction, ModalSubmitInteraction, StringSelectMenuInteraction } from "discord.js";
|
||||
|
||||
// Mock Setup
|
||||
const valuesMock = mock((_args: any) => Promise.resolve());
|
||||
const insertMock = mock(() => ({ values: valuesMock }));
|
||||
|
||||
mock.module("@/lib/DrizzleClient", () => ({
|
||||
DrizzleClient: {
|
||||
insert: insertMock
|
||||
}
|
||||
}));
|
||||
|
||||
mock.module("@/db/schema", () => ({
|
||||
items: "items_schema"
|
||||
}));
|
||||
|
||||
describe("ItemWizard", () => {
|
||||
const userId = "test-user-123";
|
||||
|
||||
beforeEach(() => {
|
||||
valuesMock.mockClear();
|
||||
insertMock.mockClear();
|
||||
// Since draftSession is internal, we can't easily clear it.
|
||||
// We will use unique user IDs or rely on overwrite behavior.
|
||||
});
|
||||
|
||||
// Helper to create base interaction
|
||||
const createBaseInteraction = (id: string, customId: string) => ({
|
||||
user: { id },
|
||||
customId,
|
||||
deferUpdate: mock(() => Promise.resolve()),
|
||||
editReply: mock(() => Promise.resolve()),
|
||||
update: mock(() => Promise.resolve()),
|
||||
showModal: mock(() => Promise.resolve()),
|
||||
followUp: mock(() => Promise.resolve()),
|
||||
reply: mock(() => Promise.resolve()),
|
||||
});
|
||||
|
||||
test("renderWizard should return initial state for new user", () => {
|
||||
const result = renderWizard(`new-${Date.now()}`);
|
||||
expect(result.embeds).toHaveLength(1);
|
||||
expect(result.embeds[0]?.data.title).toContain("New Item");
|
||||
expect(result.components).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("handleItemWizardInteraction should handle details modal submit", async () => {
|
||||
const uid = `user-details-${Date.now()}`;
|
||||
renderWizard(uid); // Init session
|
||||
|
||||
const interaction = {
|
||||
...createBaseInteraction(uid, "createitem_modal_details"),
|
||||
isButton: () => false,
|
||||
isStringSelectMenu: () => false,
|
||||
isModalSubmit: () => true,
|
||||
isMessageComponent: () => false,
|
||||
fields: {
|
||||
getTextInputValue: (key: string) => {
|
||||
if (key === "name") return "Updated Name";
|
||||
if (key === "desc") return "Updated Desc";
|
||||
if (key === "rarity") return "Legendary";
|
||||
return "";
|
||||
}
|
||||
},
|
||||
} as unknown as ModalSubmitInteraction;
|
||||
|
||||
await handleItemWizardInteraction(interaction);
|
||||
|
||||
expect(interaction.deferUpdate).toHaveBeenCalled();
|
||||
const result = renderWizard(uid);
|
||||
expect(result.embeds[0]?.data.title).toContain("Updated Name");
|
||||
});
|
||||
|
||||
test("handleItemWizardInteraction should handle economy modal submit", async () => {
|
||||
const uid = `user-economy-${Date.now()}`;
|
||||
renderWizard(uid);
|
||||
|
||||
const interaction = {
|
||||
...createBaseInteraction(uid, "createitem_modal_economy"),
|
||||
isButton: () => false,
|
||||
isStringSelectMenu: () => false,
|
||||
isModalSubmit: () => true,
|
||||
isMessageComponent: () => false,
|
||||
fields: {
|
||||
getTextInputValue: (key: string) => (key === "price" ? "500" : "")
|
||||
},
|
||||
} as unknown as ModalSubmitInteraction;
|
||||
|
||||
await handleItemWizardInteraction(interaction);
|
||||
|
||||
const result = renderWizard(uid);
|
||||
const economyField = result.embeds[0]?.data.fields?.find(f => f.name === "Economy");
|
||||
expect(economyField?.value).toContain("500 🪙");
|
||||
});
|
||||
|
||||
test("handleItemWizardInteraction should handle visuals modal submit", async () => {
|
||||
const uid = `user-visuals-${Date.now()}`;
|
||||
renderWizard(uid);
|
||||
|
||||
const interaction = {
|
||||
...createBaseInteraction(uid, "createitem_modal_visuals"),
|
||||
isButton: () => false,
|
||||
isStringSelectMenu: () => false,
|
||||
isModalSubmit: () => true,
|
||||
isMessageComponent: () => false,
|
||||
fields: {
|
||||
getTextInputValue: (key: string) => {
|
||||
if (key === "icon") return "http://icon.com";
|
||||
if (key === "image") return "http://image.com";
|
||||
return "";
|
||||
}
|
||||
},
|
||||
} as unknown as ModalSubmitInteraction;
|
||||
|
||||
await handleItemWizardInteraction(interaction);
|
||||
|
||||
const result = renderWizard(uid);
|
||||
expect(result.embeds[0]?.data.thumbnail?.url).toBe("http://icon.com");
|
||||
expect(result.embeds[0]?.data.image?.url).toBe("http://image.com");
|
||||
});
|
||||
|
||||
test("handleItemWizardInteraction should flow through adding an effect", async () => {
|
||||
const uid = `user-effect-${Date.now()}`;
|
||||
renderWizard(uid);
|
||||
|
||||
// 1. Start Add Effect
|
||||
const startInteraction = {
|
||||
...createBaseInteraction(uid, "createitem_addeffect_start"),
|
||||
isButton: () => true,
|
||||
isStringSelectMenu: () => false,
|
||||
isModalSubmit: () => false,
|
||||
isMessageComponent: () => true,
|
||||
} as unknown as ButtonInteraction;
|
||||
await handleItemWizardInteraction(startInteraction);
|
||||
expect(startInteraction.update).toHaveBeenCalled(); // Should show select menu
|
||||
|
||||
// 2. Select Effect Type
|
||||
const selectInteraction = {
|
||||
...createBaseInteraction(uid, "createitem_select_effect_type"),
|
||||
isButton: () => false,
|
||||
isStringSelectMenu: () => true,
|
||||
isModalSubmit: () => false,
|
||||
isMessageComponent: () => true,
|
||||
values: ["ADD_XP"]
|
||||
} as unknown as StringSelectMenuInteraction;
|
||||
await handleItemWizardInteraction(selectInteraction);
|
||||
expect(selectInteraction.showModal).toHaveBeenCalled(); // Should show config modal
|
||||
|
||||
// 3. Submit Effect Config Modal
|
||||
const modalInteraction = {
|
||||
...createBaseInteraction(uid, "createitem_modal_effect"),
|
||||
isButton: () => false,
|
||||
isStringSelectMenu: () => false,
|
||||
isModalSubmit: () => true,
|
||||
isMessageComponent: () => false,
|
||||
fields: {
|
||||
getTextInputValue: (key: string) => (key === "amount" ? "1000" : "")
|
||||
},
|
||||
} as unknown as ModalSubmitInteraction;
|
||||
await handleItemWizardInteraction(modalInteraction);
|
||||
|
||||
// Verify Effect Added
|
||||
const result = renderWizard(uid);
|
||||
const effectsField = result.embeds[0]?.data.fields?.find(f => f.name === "Usage Effects");
|
||||
expect(effectsField?.value).toContain("ADD_XP");
|
||||
expect(effectsField?.value).toContain("1000");
|
||||
});
|
||||
|
||||
test("handleItemWizardInteraction should save item to database", async () => {
|
||||
const uid = `user-save-${Date.now()}`;
|
||||
renderWizard(uid);
|
||||
|
||||
// Set name first so we can check it
|
||||
const nameInteraction = {
|
||||
...createBaseInteraction(uid, "createitem_modal_details"),
|
||||
isButton: () => false,
|
||||
isStringSelectMenu: () => false,
|
||||
isModalSubmit: () => true,
|
||||
isMessageComponent: () => false,
|
||||
fields: {
|
||||
getTextInputValue: (key: string) => {
|
||||
if (key === "name") return "Saved Item";
|
||||
if (key === "desc") return "Desc";
|
||||
if (key === "rarity") return "Common";
|
||||
return "";
|
||||
}
|
||||
},
|
||||
} as unknown as ModalSubmitInteraction;
|
||||
await handleItemWizardInteraction(nameInteraction);
|
||||
|
||||
// Save
|
||||
const saveInteraction = {
|
||||
...createBaseInteraction(uid, "createitem_save"),
|
||||
isButton: () => true,
|
||||
isStringSelectMenu: () => false,
|
||||
isModalSubmit: () => false,
|
||||
isMessageComponent: () => true,
|
||||
} as unknown as ButtonInteraction;
|
||||
|
||||
await handleItemWizardInteraction(saveInteraction);
|
||||
|
||||
expect(valuesMock).toHaveBeenCalled();
|
||||
const calls = valuesMock.mock.calls as any[];
|
||||
if (calls.length > 0) {
|
||||
const callArgs = calls[0][0];
|
||||
expect(callArgs).toMatchObject({
|
||||
name: "Saved Item",
|
||||
description: "Desc",
|
||||
rarity: "Common",
|
||||
// Add other fields as needed
|
||||
});
|
||||
}
|
||||
|
||||
expect(saveInteraction.editReply).toHaveBeenCalledWith(expect.objectContaining({
|
||||
content: expect.stringContaining("successfully")
|
||||
}));
|
||||
});
|
||||
|
||||
test("handleItemWizardInteraction should cancel and clear session", async () => {
|
||||
const uid = `user-cancel-${Date.now()}`;
|
||||
renderWizard(uid);
|
||||
|
||||
const interaction = {
|
||||
...createBaseInteraction(uid, "createitem_cancel"),
|
||||
isButton: () => true, // Technically any component
|
||||
isStringSelectMenu: () => false,
|
||||
isModalSubmit: () => false,
|
||||
isMessageComponent: () => true,
|
||||
} as unknown as ButtonInteraction;
|
||||
|
||||
await handleItemWizardInteraction(interaction);
|
||||
|
||||
expect(interaction.update).toHaveBeenCalledWith(expect.objectContaining({
|
||||
content: expect.stringContaining("cancelled")
|
||||
}));
|
||||
|
||||
// Verify session is gone by checking if renderWizard returns default New Item
|
||||
// Let's modify it first
|
||||
const modInteraction = {
|
||||
...createBaseInteraction(uid, "createitem_modal_details"),
|
||||
isButton: () => false,
|
||||
isStringSelectMenu: () => false,
|
||||
isModalSubmit: () => true,
|
||||
isMessageComponent: () => false,
|
||||
fields: {
|
||||
getTextInputValue: (key: string) => (key === "name" ? "Modified" : "x")
|
||||
},
|
||||
} as unknown as ModalSubmitInteraction;
|
||||
await handleItemWizardInteraction(modInteraction);
|
||||
|
||||
// Now Cancel
|
||||
await handleItemWizardInteraction(interaction);
|
||||
|
||||
// New render should be "New Item" not "Modified"
|
||||
const result = renderWizard(uid);
|
||||
expect(result.embeds[0]?.data.title).toContain("New Item");
|
||||
expect(result.embeds[0]?.data.title).not.toContain("Modified");
|
||||
});
|
||||
});
|
||||
329
src/modules/admin/item_wizard.ts
Normal file
329
src/modules/admin/item_wizard.ts
Normal file
@@ -0,0 +1,329 @@
|
||||
import {
|
||||
ActionRowBuilder,
|
||||
ButtonBuilder,
|
||||
ButtonStyle,
|
||||
EmbedBuilder,
|
||||
ModalBuilder,
|
||||
StringSelectMenuBuilder,
|
||||
TextInputBuilder,
|
||||
TextInputStyle,
|
||||
type Interaction,
|
||||
type MessageActionRowComponentBuilder
|
||||
} from "discord.js";
|
||||
import { items } from "@/db/schema";
|
||||
import { DrizzleClient } from "@/lib/DrizzleClient";
|
||||
import type { ItemUsageData, ItemEffect } from "@/lib/types";
|
||||
|
||||
// --- Types ---
|
||||
export interface DraftItem {
|
||||
name: string;
|
||||
description: string;
|
||||
rarity: string;
|
||||
type: string;
|
||||
price: number | null;
|
||||
iconUrl: string;
|
||||
imageUrl: string;
|
||||
usageData: ItemUsageData;
|
||||
// Temporary state for effect adding flow
|
||||
pendingEffectType?: string;
|
||||
}
|
||||
|
||||
// --- State ---
|
||||
const draftSession = new Map<string, DraftItem>();
|
||||
|
||||
const getItemTypeOptions = () => [
|
||||
{ label: "Material", value: "MATERIAL", description: "Used for crafting or trading" },
|
||||
{ label: "Consumable", value: "CONSUMABLE", description: "Can be used to gain effects" },
|
||||
{ label: "Equipment", value: "EQUIPMENT", description: "Can be equipped (Not yet implemented)" },
|
||||
{ label: "Quest Item", value: "QUEST", description: "Required for quests" },
|
||||
];
|
||||
|
||||
const getEffectTypeOptions = () => [
|
||||
{ label: "Add XP", value: "ADD_XP", description: "Gives XP to the user" },
|
||||
{ label: "Add Balance", value: "ADD_BALANCE", description: "Gives currency to the user" },
|
||||
{ label: "Reply Message", value: "REPLY_MESSAGE", description: "Bot replies with a message" },
|
||||
{ label: "XP Boost", value: "XP_BOOST", description: "Temporarily boosts XP gain" },
|
||||
{ label: "Temp Role", value: "TEMP_ROLE", description: "Gives a temporary role" },
|
||||
];
|
||||
|
||||
// --- Render ---
|
||||
export const renderWizard = (userId: string, isDraft = true) => {
|
||||
let draft = draftSession.get(userId);
|
||||
|
||||
// Initialize if new
|
||||
if (!draft) {
|
||||
draft = {
|
||||
name: "New Item",
|
||||
description: "No description",
|
||||
rarity: "Common",
|
||||
type: "MATERIAL",
|
||||
price: null,
|
||||
iconUrl: "",
|
||||
imageUrl: "",
|
||||
usageData: { consume: true, effects: [] } // Default Consume to true for now
|
||||
};
|
||||
draftSession.set(userId, draft);
|
||||
}
|
||||
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle(`🛠️ Item Creator: ${draft.name}`)
|
||||
.setColor("Blue")
|
||||
.addFields(
|
||||
{ name: "General", value: `**Type:** ${draft.type}\n**Rarity:** ${draft.rarity}\n**Desc:** ${draft.description}`, inline: true },
|
||||
{ name: "Economy", value: `**Price:** ${draft.price ? `${draft.price} 🪙` : "Not for sale"}`, inline: true },
|
||||
{ name: "Visuals", value: `**Icon:** ${draft.iconUrl ? "✅ Set" : "❌"}\n**Image:** ${draft.imageUrl ? "✅ Set" : "❌"}`, inline: true },
|
||||
);
|
||||
|
||||
// Effects Display
|
||||
if (draft.usageData.effects.length > 0) {
|
||||
const effecto = draft.usageData.effects.map((e, i) => `${i + 1}. **${e.type}**: ${JSON.stringify(e)}`).join("\n");
|
||||
embed.addFields({ name: "Usage Effects", value: effecto.substring(0, 1024) });
|
||||
} else {
|
||||
embed.addFields({ name: "Usage Effects", value: "None" });
|
||||
}
|
||||
|
||||
if (draft.imageUrl) embed.setImage(draft.imageUrl);
|
||||
if (draft.iconUrl) embed.setThumbnail(draft.iconUrl);
|
||||
|
||||
// Components
|
||||
const row1 = new ActionRowBuilder<MessageActionRowComponentBuilder>()
|
||||
.addComponents(
|
||||
new ButtonBuilder().setCustomId("createitem_details").setLabel("Edit Details").setStyle(ButtonStyle.Secondary).setEmoji("📝"),
|
||||
new ButtonBuilder().setCustomId("createitem_economy").setLabel("Edit Economy").setStyle(ButtonStyle.Secondary).setEmoji("💰"),
|
||||
new ButtonBuilder().setCustomId("createitem_visuals").setLabel("Edit Visuals").setStyle(ButtonStyle.Secondary).setEmoji("🖼️"),
|
||||
new ButtonBuilder().setCustomId("createitem_type_toggle").setLabel("Change Type").setStyle(ButtonStyle.Secondary).setEmoji("🔄"),
|
||||
);
|
||||
|
||||
const row2 = new ActionRowBuilder<MessageActionRowComponentBuilder>()
|
||||
.addComponents(
|
||||
new ButtonBuilder().setCustomId("createitem_addeffect_start").setLabel("Add Effect").setStyle(ButtonStyle.Primary).setEmoji("✨"),
|
||||
new ButtonBuilder().setCustomId("createitem_save").setLabel("Save Item").setStyle(ButtonStyle.Success).setEmoji("💾"),
|
||||
new ButtonBuilder().setCustomId("createitem_cancel").setLabel("Cancel").setStyle(ButtonStyle.Danger).setEmoji("✖️")
|
||||
);
|
||||
|
||||
return { embeds: [embed], components: [row1, row2] };
|
||||
};
|
||||
|
||||
// --- Handler ---
|
||||
export const handleItemWizardInteraction = async (interaction: Interaction) => {
|
||||
// Only handle createitem interactions
|
||||
if (!interaction.isButton() && !interaction.isStringSelectMenu() && !interaction.isModalSubmit()) return;
|
||||
if (!interaction.customId.startsWith("createitem_")) return;
|
||||
|
||||
const userId = interaction.user.id;
|
||||
let draft = draftSession.get(userId);
|
||||
|
||||
// Special case for Cancel - doesn't need draft checks usually, but we want to clear it
|
||||
if (interaction.customId === "createitem_cancel") {
|
||||
draftSession.delete(userId);
|
||||
if (interaction.isMessageComponent()) {
|
||||
await interaction.update({ content: "❌ Item creation cancelled.", embeds: [], components: [] });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialize draft if missing for other actions (edge case: bot restart)
|
||||
if (!draft) {
|
||||
if (interaction.isMessageComponent()) {
|
||||
// Create one implicitly to prevent crashes, or warn user
|
||||
if (interaction.customId === "createitem_start") {
|
||||
// Allow start
|
||||
} else {
|
||||
await interaction.reply({ content: "⚠️ Session expired. Please run `/createitem` again.", ephemeral: true });
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Re-get draft (guaranteed now if we handled the start/restart)
|
||||
// Actually renderWizard initializes it, so if we call that we are safe.
|
||||
// But for Modals we need it.
|
||||
|
||||
if (!draft) {
|
||||
// Just init it
|
||||
renderWizard(userId);
|
||||
draft = draftSession.get(userId)!;
|
||||
}
|
||||
|
||||
|
||||
// --- Routing ---
|
||||
|
||||
// 1. Details Modal
|
||||
if (interaction.customId === "createitem_details") {
|
||||
if (!interaction.isButton()) return;
|
||||
const modal = new ModalBuilder().setCustomId("createitem_modal_details").setTitle("Edit Details");
|
||||
modal.addComponents(
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("name").setLabel("Name").setValue(draft.name).setStyle(TextInputStyle.Short).setRequired(true)),
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("desc").setLabel("Description").setValue(draft.description).setStyle(TextInputStyle.Paragraph).setRequired(false)),
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("rarity").setLabel("Rarity").setValue(draft.rarity).setStyle(TextInputStyle.Short).setPlaceholder("Common, Rare, Legendary...").setRequired(true))
|
||||
);
|
||||
await interaction.showModal(modal);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Economy Modal
|
||||
if (interaction.customId === "createitem_economy") {
|
||||
if (!interaction.isButton()) return;
|
||||
const modal = new ModalBuilder().setCustomId("createitem_modal_economy").setTitle("Edit Economy");
|
||||
modal.addComponents(
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("price").setLabel("Price (0 for not for sale)").setValue(draft.price?.toString() || "0").setStyle(TextInputStyle.Short).setRequired(true))
|
||||
);
|
||||
await interaction.showModal(modal);
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Visuals Modal
|
||||
if (interaction.customId === "createitem_visuals") {
|
||||
if (!interaction.isButton()) return;
|
||||
const modal = new ModalBuilder().setCustomId("createitem_modal_visuals").setTitle("Edit Visuals");
|
||||
modal.addComponents(
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("icon").setLabel("Icon URL (Emoji or Link)").setValue(draft.iconUrl).setStyle(TextInputStyle.Short).setRequired(false)),
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("image").setLabel("Image URL").setValue(draft.imageUrl).setStyle(TextInputStyle.Short).setRequired(false))
|
||||
);
|
||||
await interaction.showModal(modal);
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. Type Toggle (Start Select Menu)
|
||||
if (interaction.customId === "createitem_type_toggle") {
|
||||
if (!interaction.isButton()) return;
|
||||
const row = new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(
|
||||
new StringSelectMenuBuilder().setCustomId("createitem_select_type").setPlaceholder("Select Item Type").addOptions(getItemTypeOptions())
|
||||
);
|
||||
await interaction.update({ components: [row as any] }); // Temporary view
|
||||
return;
|
||||
}
|
||||
|
||||
if (interaction.customId === "createitem_select_type") {
|
||||
if (!interaction.isStringSelectMenu()) return;
|
||||
const selected = interaction.values[0];
|
||||
if (selected) {
|
||||
draft.type = selected;
|
||||
}
|
||||
// Re-render
|
||||
const payload = renderWizard(userId);
|
||||
await interaction.update(payload);
|
||||
return;
|
||||
}
|
||||
|
||||
// 5. Add Effect Flow
|
||||
if (interaction.customId === "createitem_addeffect_start") {
|
||||
if (!interaction.isButton()) return;
|
||||
const row = new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(
|
||||
new StringSelectMenuBuilder().setCustomId("createitem_select_effect_type").setPlaceholder("Select Effect Type").addOptions(getEffectTypeOptions())
|
||||
);
|
||||
await interaction.update({ components: [row as any] });
|
||||
return;
|
||||
}
|
||||
|
||||
if (interaction.customId === "createitem_select_effect_type") {
|
||||
if (!interaction.isStringSelectMenu()) return;
|
||||
const effectType = interaction.values[0];
|
||||
draft.pendingEffectType = effectType;
|
||||
|
||||
// Immediately show modal for data collection
|
||||
// Note: You can't showModal from an update? You CAN showModal from a component interaction (SelectMenu).
|
||||
// But we shouldn't update the message AND show modal. We must pick one.
|
||||
// We will show modal. The message remains in "Select Effect" state until modal submit re-renders it.
|
||||
|
||||
let modal = new ModalBuilder().setCustomId("createitem_modal_effect").setTitle(`Config ${effectType}`);
|
||||
|
||||
if (effectType === "ADD_XP" || effectType === "ADD_BALANCE") {
|
||||
modal.addComponents(new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("amount").setLabel("Amount").setStyle(TextInputStyle.Short).setRequired(true).setPlaceholder("100")));
|
||||
} else if (effectType === "REPLY_MESSAGE") {
|
||||
modal.addComponents(new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("message").setLabel("Message").setStyle(TextInputStyle.Paragraph).setRequired(true)));
|
||||
} else if (effectType === "XP_BOOST") {
|
||||
modal.addComponents(
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("multiplier").setLabel("Multiplier (e.g. 1.5)").setStyle(TextInputStyle.Short).setRequired(true)),
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("duration").setLabel("Duration (Seconds)").setStyle(TextInputStyle.Short).setRequired(true).setValue("3600"))
|
||||
);
|
||||
} else if (effectType === "TEMP_ROLE") {
|
||||
modal.addComponents(
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("role_id").setLabel("Role ID").setStyle(TextInputStyle.Short).setRequired(true)),
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("duration").setLabel("Duration (Seconds)").setStyle(TextInputStyle.Short).setRequired(true).setValue("3600"))
|
||||
);
|
||||
}
|
||||
|
||||
await interaction.showModal(modal);
|
||||
return;
|
||||
}
|
||||
|
||||
// 6. Handle Modal Submits
|
||||
if (interaction.isModalSubmit()) {
|
||||
if (interaction.customId === "createitem_modal_details") {
|
||||
draft.name = interaction.fields.getTextInputValue("name");
|
||||
draft.description = interaction.fields.getTextInputValue("desc");
|
||||
draft.rarity = interaction.fields.getTextInputValue("rarity");
|
||||
}
|
||||
else if (interaction.customId === "createitem_modal_economy") {
|
||||
const price = parseInt(interaction.fields.getTextInputValue("price"));
|
||||
draft.price = isNaN(price) || price === 0 ? null : price;
|
||||
}
|
||||
else if (interaction.customId === "createitem_modal_visuals") {
|
||||
draft.iconUrl = interaction.fields.getTextInputValue("icon");
|
||||
draft.imageUrl = interaction.fields.getTextInputValue("image");
|
||||
}
|
||||
else if (interaction.customId === "createitem_modal_effect") {
|
||||
const type = draft.pendingEffectType;
|
||||
if (type) {
|
||||
let effect: ItemEffect | null = null;
|
||||
|
||||
if (type === "ADD_XP" || type === "ADD_BALANCE") {
|
||||
const amount = parseInt(interaction.fields.getTextInputValue("amount"));
|
||||
if (!isNaN(amount)) effect = { type: type as any, amount };
|
||||
}
|
||||
else if (type === "REPLY_MESSAGE") {
|
||||
effect = { type: "REPLY_MESSAGE", message: interaction.fields.getTextInputValue("message") };
|
||||
}
|
||||
else if (type === "XP_BOOST") {
|
||||
const multiplier = parseFloat(interaction.fields.getTextInputValue("multiplier"));
|
||||
const duration = parseInt(interaction.fields.getTextInputValue("duration"));
|
||||
if (!isNaN(multiplier) && !isNaN(duration)) effect = { type: "XP_BOOST", multiplier, durationSeconds: duration };
|
||||
}
|
||||
else if (type === "TEMP_ROLE") {
|
||||
const roleId = interaction.fields.getTextInputValue("role_id");
|
||||
const duration = parseInt(interaction.fields.getTextInputValue("duration"));
|
||||
if (roleId && !isNaN(duration)) effect = { type: "TEMP_ROLE", roleId: roleId, durationSeconds: duration };
|
||||
}
|
||||
|
||||
if (effect) {
|
||||
draft.usageData.effects.push(effect);
|
||||
}
|
||||
draft.pendingEffectType = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// Re-render
|
||||
const payload = renderWizard(userId);
|
||||
await interaction.deferUpdate();
|
||||
await interaction.editReply(payload);
|
||||
return;
|
||||
}
|
||||
|
||||
// 7. Save
|
||||
if (interaction.customId === "createitem_save") {
|
||||
if (!interaction.isButton()) return;
|
||||
|
||||
await interaction.deferUpdate(); // Prepare to save
|
||||
|
||||
try {
|
||||
await DrizzleClient.insert(items).values({
|
||||
name: draft.name,
|
||||
description: draft.description,
|
||||
type: draft.type,
|
||||
rarity: draft.rarity,
|
||||
price: draft.price ? BigInt(draft.price) : null,
|
||||
iconUrl: draft.iconUrl,
|
||||
imageUrl: draft.imageUrl,
|
||||
usageData: draft.usageData
|
||||
});
|
||||
|
||||
draftSession.delete(userId);
|
||||
await interaction.editReply({ content: `✅ **${draft.name}** has been created successfully!`, embeds: [], components: [] });
|
||||
} catch (error: any) {
|
||||
console.error("Failed to create item:", error);
|
||||
// Restore state
|
||||
await interaction.followUp({ content: `❌ Failed to save item: ${error.message}`, ephemeral: true });
|
||||
}
|
||||
}
|
||||
|
||||
};
|
||||
209
src/modules/class/class.service.test.ts
Normal file
209
src/modules/class/class.service.test.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import { describe, it, expect, mock, beforeEach } from "bun:test";
|
||||
import { classService } from "./class.service";
|
||||
import { classes, users } from "@/db/schema";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
|
||||
// Define mock functions
|
||||
const mockFindMany = mock();
|
||||
const mockFindFirst = mock();
|
||||
const mockInsert = mock();
|
||||
const mockUpdate = mock();
|
||||
const mockDelete = mock();
|
||||
const mockValues = mock();
|
||||
const mockReturning = mock();
|
||||
const mockSet = mock();
|
||||
const mockWhere = mock();
|
||||
|
||||
// Chainable mock setup
|
||||
mockInsert.mockReturnValue({ values: mockValues });
|
||||
mockValues.mockReturnValue({ returning: mockReturning });
|
||||
|
||||
mockUpdate.mockReturnValue({ set: mockSet });
|
||||
mockSet.mockReturnValue({ where: mockWhere });
|
||||
mockWhere.mockReturnValue({ returning: mockReturning });
|
||||
|
||||
mockDelete.mockReturnValue({ where: mockWhere }); // Fix for delete chaining if needed, usually delete(table).where(...)
|
||||
|
||||
// Mock DrizzleClient
|
||||
mock.module("@/lib/DrizzleClient", () => {
|
||||
return {
|
||||
DrizzleClient: {
|
||||
query: {
|
||||
classes: {
|
||||
findMany: mockFindMany,
|
||||
findFirst: mockFindFirst,
|
||||
},
|
||||
},
|
||||
insert: mockInsert,
|
||||
update: mockUpdate,
|
||||
delete: mockDelete,
|
||||
transaction: async (cb: any) => {
|
||||
return cb({
|
||||
query: {
|
||||
classes: {
|
||||
findMany: mockFindMany,
|
||||
findFirst: mockFindFirst,
|
||||
},
|
||||
},
|
||||
insert: mockInsert,
|
||||
update: mockUpdate,
|
||||
delete: mockDelete,
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe("classService", () => {
|
||||
beforeEach(() => {
|
||||
mockFindMany.mockReset();
|
||||
mockFindFirst.mockReset();
|
||||
mockInsert.mockClear();
|
||||
mockUpdate.mockClear();
|
||||
mockDelete.mockClear();
|
||||
mockValues.mockClear();
|
||||
mockReturning.mockClear();
|
||||
mockSet.mockClear();
|
||||
mockWhere.mockClear();
|
||||
});
|
||||
|
||||
describe("getAllClasses", () => {
|
||||
it("should return all classes", async () => {
|
||||
const mockClasses = [{ id: 1n, name: "Warrior" }, { id: 2n, name: "Mage" }];
|
||||
mockFindMany.mockResolvedValue(mockClasses);
|
||||
|
||||
const result = await classService.getAllClasses();
|
||||
|
||||
expect(result).toEqual(mockClasses as any);
|
||||
expect(mockFindMany).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("assignClass", () => {
|
||||
it("should assign class to user if class exists", async () => {
|
||||
const mockClass = { id: 1n, name: "Warrior" };
|
||||
const mockUser = { id: 123n, classId: 1n };
|
||||
|
||||
mockFindFirst.mockResolvedValue(mockClass);
|
||||
mockReturning.mockResolvedValue([mockUser]);
|
||||
|
||||
const result = await classService.assignClass("123", 1n);
|
||||
|
||||
expect(result).toEqual(mockUser as any);
|
||||
// Verify class check
|
||||
expect(mockFindFirst).toHaveBeenCalledTimes(1);
|
||||
// Verify update
|
||||
expect(mockUpdate).toHaveBeenCalledWith(users);
|
||||
expect(mockSet).toHaveBeenCalledWith({ classId: 1n });
|
||||
});
|
||||
|
||||
it("should throw error if class not found", async () => {
|
||||
mockFindFirst.mockResolvedValue(undefined);
|
||||
|
||||
expect(classService.assignClass("123", 99n)).rejects.toThrow("Class not found");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getClassBalance", () => {
|
||||
it("should return class balance", async () => {
|
||||
const mockClass = { id: 1n, balance: 100n };
|
||||
mockFindFirst.mockResolvedValue(mockClass);
|
||||
|
||||
const result = await classService.getClassBalance(1n);
|
||||
|
||||
expect(result).toBe(100n);
|
||||
});
|
||||
|
||||
it("should return 0n if class has no balance or not found", async () => {
|
||||
mockFindFirst.mockResolvedValue(null);
|
||||
const result = await classService.getClassBalance(1n);
|
||||
expect(result).toBe(0n);
|
||||
|
||||
mockFindFirst.mockResolvedValue({ id: 1n, balance: null });
|
||||
const result2 = await classService.getClassBalance(1n);
|
||||
expect(result2).toBe(0n);
|
||||
});
|
||||
});
|
||||
|
||||
describe("modifyClassBalance", () => {
|
||||
it("should modify class balance successfully", async () => {
|
||||
const mockClass = { id: 1n, balance: 100n };
|
||||
const updatedClass = { id: 1n, balance: 150n };
|
||||
|
||||
mockFindFirst.mockResolvedValue(mockClass);
|
||||
mockReturning.mockResolvedValue([updatedClass]);
|
||||
|
||||
const result = await classService.modifyClassBalance(1n, 50n);
|
||||
|
||||
expect(result).toEqual(updatedClass as any);
|
||||
expect(mockUpdate).toHaveBeenCalledWith(classes);
|
||||
// Note: sql template literal matching might be tricky, checking strict call might fail if not exact object ref
|
||||
// We verify at least mockSet was called
|
||||
expect(mockSet).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should throw if class not found", async () => {
|
||||
mockFindFirst.mockResolvedValue(undefined);
|
||||
expect(classService.modifyClassBalance(1n, 50n)).rejects.toThrow("Class not found");
|
||||
});
|
||||
|
||||
it("should throw if insufficient funds", async () => {
|
||||
const mockClass = { id: 1n, balance: 10n };
|
||||
mockFindFirst.mockResolvedValue(mockClass);
|
||||
|
||||
expect(classService.modifyClassBalance(1n, -20n)).rejects.toThrow("Insufficient class funds");
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateClass", () => {
|
||||
it("should update class details", async () => {
|
||||
const updateData = { name: "Super Warrior" };
|
||||
const updatedClass = { id: 1n, name: "Super Warrior" };
|
||||
|
||||
mockReturning.mockResolvedValue([updatedClass]);
|
||||
|
||||
const result = await classService.updateClass(1n, updateData);
|
||||
|
||||
expect(result).toEqual(updatedClass as any);
|
||||
expect(mockUpdate).toHaveBeenCalledWith(classes);
|
||||
expect(mockSet).toHaveBeenCalledWith(updateData);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createClass", () => {
|
||||
it("should create a new class", async () => {
|
||||
const newClassData = { name: "Archer", description: "Bow user" };
|
||||
const createdClass = { id: 3n, ...newClassData };
|
||||
|
||||
mockReturning.mockResolvedValue([createdClass]);
|
||||
|
||||
const result = await classService.createClass(newClassData as any);
|
||||
|
||||
expect(result).toEqual(createdClass as any);
|
||||
expect(mockInsert).toHaveBeenCalledWith(classes);
|
||||
expect(mockValues).toHaveBeenCalledWith(newClassData);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteClass", () => {
|
||||
it("should delete a class", async () => {
|
||||
mockDelete.mockReturnValue({ where: mockWhere });
|
||||
// The chain is delete(table).where(...) for delete
|
||||
|
||||
// Wait, in user.service.test.ts:
|
||||
// mockDelete called without chain setup in the file provided?
|
||||
// "mockDelete = mock()"
|
||||
// And in mock: "delete: mockDelete"
|
||||
// And in usage: "await txFn.delete(users).where(...)"
|
||||
// So mockDelete must return an object with where.
|
||||
|
||||
mockDelete.mockReturnValue({ where: mockWhere });
|
||||
|
||||
await classService.deleteClass(1n);
|
||||
|
||||
expect(mockDelete).toHaveBeenCalledWith(classes);
|
||||
// We can't easily check 'where' arguments specifically without complex matcher if we don't return specific mock
|
||||
expect(mockWhere).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -64,5 +64,22 @@ export const classService = {
|
||||
return updatedClass;
|
||||
};
|
||||
return tx ? await execute(tx) : await DrizzleClient.transaction(execute);
|
||||
},
|
||||
|
||||
createClass: async (data: typeof classes.$inferInsert, tx?: any) => {
|
||||
const execute = async (txFn: any) => {
|
||||
const [newClass] = await txFn.insert(classes)
|
||||
.values(data)
|
||||
.returning();
|
||||
return newClass;
|
||||
};
|
||||
return tx ? await execute(tx) : await DrizzleClient.transaction(execute);
|
||||
},
|
||||
|
||||
deleteClass: async (id: bigint, tx?: any) => {
|
||||
const execute = async (txFn: any) => {
|
||||
await txFn.delete(classes).where(eq(classes.id, id));
|
||||
};
|
||||
return tx ? await execute(tx) : await DrizzleClient.transaction(execute);
|
||||
}
|
||||
};
|
||||
|
||||
193
src/modules/economy/economy.service.test.ts
Normal file
193
src/modules/economy/economy.service.test.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import { describe, it, expect, mock, beforeEach, setSystemTime } from "bun:test";
|
||||
import { economyService } from "./economy.service";
|
||||
import { users, userTimers, transactions } from "@/db/schema";
|
||||
|
||||
// Define mock functions
|
||||
const mockFindMany = mock();
|
||||
const mockFindFirst = mock();
|
||||
const mockInsert = mock();
|
||||
const mockUpdate = mock();
|
||||
const mockDelete = mock();
|
||||
const mockValues = mock();
|
||||
const mockReturning = mock();
|
||||
const mockSet = mock();
|
||||
const mockWhere = mock();
|
||||
const mockOnConflictDoUpdate = mock();
|
||||
|
||||
// Chainable mock setup
|
||||
mockInsert.mockReturnValue({ values: mockValues });
|
||||
mockValues.mockReturnValue({
|
||||
returning: mockReturning,
|
||||
onConflictDoUpdate: mockOnConflictDoUpdate // For claimDaily chain
|
||||
});
|
||||
mockOnConflictDoUpdate.mockResolvedValue({}); // Terminate the chain
|
||||
|
||||
mockUpdate.mockReturnValue({ set: mockSet });
|
||||
mockSet.mockReturnValue({ where: mockWhere });
|
||||
mockWhere.mockReturnValue({ returning: mockReturning });
|
||||
|
||||
// Mock DrizzleClient
|
||||
mock.module("@/lib/DrizzleClient", () => {
|
||||
// Mock Transaction Object Structure
|
||||
const createMockTx = () => ({
|
||||
query: {
|
||||
users: { findFirst: mockFindFirst },
|
||||
userTimers: { findFirst: mockFindFirst },
|
||||
},
|
||||
insert: mockInsert,
|
||||
update: mockUpdate,
|
||||
delete: mockDelete,
|
||||
});
|
||||
|
||||
return {
|
||||
DrizzleClient: {
|
||||
query: {
|
||||
users: { findFirst: mockFindFirst },
|
||||
userTimers: { findFirst: mockFindFirst },
|
||||
},
|
||||
insert: mockInsert,
|
||||
update: mockUpdate,
|
||||
delete: mockDelete,
|
||||
transaction: async (cb: any) => {
|
||||
return cb(createMockTx());
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Mock Config
|
||||
mock.module("@/lib/config", () => ({
|
||||
config: {
|
||||
economy: {
|
||||
daily: {
|
||||
amount: 100n,
|
||||
streakBonus: 10n,
|
||||
cooldownMs: 86400000, // 24 hours
|
||||
}
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
describe("economyService", () => {
|
||||
beforeEach(() => {
|
||||
mockFindFirst.mockReset();
|
||||
mockInsert.mockClear();
|
||||
mockUpdate.mockClear();
|
||||
mockDelete.mockClear();
|
||||
mockValues.mockClear();
|
||||
mockReturning.mockClear();
|
||||
mockSet.mockClear();
|
||||
mockWhere.mockClear();
|
||||
mockOnConflictDoUpdate.mockClear();
|
||||
});
|
||||
|
||||
describe("transfer", () => {
|
||||
it("should transfer amount successfully", async () => {
|
||||
const sender = { id: 1n, balance: 200n };
|
||||
mockFindFirst.mockResolvedValue(sender);
|
||||
|
||||
const result = await economyService.transfer("1", "2", 50n);
|
||||
|
||||
expect(result).toEqual({ success: true, amount: 50n });
|
||||
|
||||
// Check sender update
|
||||
expect(mockUpdate).toHaveBeenCalledWith(users);
|
||||
// We can check if mockSet was called twice
|
||||
expect(mockSet).toHaveBeenCalledTimes(2);
|
||||
|
||||
// Check transactions created
|
||||
expect(mockInsert).toHaveBeenCalledWith(transactions);
|
||||
});
|
||||
|
||||
it("should throw if amount is non-positive", async () => {
|
||||
expect(economyService.transfer("1", "2", 0n)).rejects.toThrow("Amount must be positive");
|
||||
expect(economyService.transfer("1", "2", -10n)).rejects.toThrow("Amount must be positive");
|
||||
});
|
||||
|
||||
it("should throw if transferring to self", async () => {
|
||||
expect(economyService.transfer("1", "1", 50n)).rejects.toThrow("Cannot transfer to self");
|
||||
});
|
||||
|
||||
it("should throw if sender not found", async () => {
|
||||
mockFindFirst.mockResolvedValue(undefined);
|
||||
expect(economyService.transfer("1", "2", 50n)).rejects.toThrow("Sender not found");
|
||||
});
|
||||
|
||||
it("should throw if insufficient funds", async () => {
|
||||
mockFindFirst.mockResolvedValue({ id: 1n, balance: 20n });
|
||||
expect(economyService.transfer("1", "2", 50n)).rejects.toThrow("Insufficient funds");
|
||||
});
|
||||
});
|
||||
|
||||
describe("claimDaily", () => {
|
||||
beforeEach(() => {
|
||||
setSystemTime(new Date("2023-01-01T12:00:00Z"));
|
||||
});
|
||||
|
||||
it("should claim daily reward successfully", async () => {
|
||||
const recentPast = new Date("2023-01-01T11:00:00Z"); // 1 hour ago
|
||||
|
||||
// First call finds cooldown (expired recently), second finds user
|
||||
mockFindFirst
|
||||
.mockResolvedValueOnce({ expiresAt: recentPast }) // Cooldown check - expired -> ready
|
||||
.mockResolvedValueOnce({ id: 1n, dailyStreak: 5, balance: 1000n }); // User check
|
||||
|
||||
const result = await economyService.claimDaily("1");
|
||||
|
||||
expect(result.claimed).toBe(true);
|
||||
// Streak should increase: 5 + 1 = 6
|
||||
expect(result.streak).toBe(6);
|
||||
// Base 100 + (6-1)*10 = 150
|
||||
expect(result.amount).toBe(150n);
|
||||
|
||||
// Check updates
|
||||
expect(mockUpdate).toHaveBeenCalledWith(users);
|
||||
expect(mockInsert).toHaveBeenCalledWith(userTimers);
|
||||
expect(mockInsert).toHaveBeenCalledWith(transactions);
|
||||
});
|
||||
|
||||
it("should throw if cooldown is active", async () => {
|
||||
const future = new Date("2023-01-02T12:00:00Z"); // +24h
|
||||
mockFindFirst.mockResolvedValue({ expiresAt: future });
|
||||
|
||||
expect(economyService.claimDaily("1")).rejects.toThrow("Daily already claimed");
|
||||
});
|
||||
|
||||
it("should reset streak if missed a day (long time gap)", async () => {
|
||||
// Expired 3 days ago
|
||||
const past = new Date("2023-01-01T00:00:00Z"); // now is 12:00
|
||||
// Wait, logic says: if (timeSinceReady > 24h)
|
||||
// now - expiresAt.
|
||||
// If cooldown expired 2022-12-30. Now is 2023-01-01. Gap is > 24h.
|
||||
|
||||
const expiredAt = new Date("2022-12-30T12:00:00Z");
|
||||
|
||||
mockFindFirst
|
||||
.mockResolvedValueOnce({ expiresAt: expiredAt })
|
||||
.mockResolvedValueOnce({ id: 1n, dailyStreak: 5 });
|
||||
|
||||
const result = await economyService.claimDaily("1");
|
||||
|
||||
// timeSinceReady = 48h.
|
||||
// streak = (5+1) - floor(48h / 24h) = 6 - 2 = 4.
|
||||
expect(result.streak).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe("modifyUserBalance", () => {
|
||||
it("should add balance successfully", async () => {
|
||||
mockReturning.mockResolvedValue([{ id: 1n, balance: 150n }]);
|
||||
|
||||
const result = await economyService.modifyUserBalance("1", 50n, "TEST", "Test add");
|
||||
|
||||
expect(mockUpdate).toHaveBeenCalledWith(users);
|
||||
expect(mockInsert).toHaveBeenCalledWith(transactions);
|
||||
});
|
||||
|
||||
it("should throw if insufficient funds when negative", async () => {
|
||||
mockFindFirst.mockResolvedValue({ id: 1n, balance: 20n });
|
||||
|
||||
expect(economyService.modifyUserBalance("1", -50n, "TEST", "Test sub")).rejects.toThrow("Insufficient funds");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -80,7 +80,7 @@ export const economyService = {
|
||||
});
|
||||
|
||||
if (cooldown && cooldown.expiresAt > now) {
|
||||
throw new UserError(`Daily already claimed. Ready at ${cooldown.expiresAt}`);
|
||||
throw new UserError(`Daily already claimed. Ready <t:${Math.floor(cooldown.expiresAt.getTime() / 1000)}:R>`);
|
||||
}
|
||||
|
||||
// Get user for streak logic
|
||||
|
||||
44
src/modules/economy/lootdrop.interaction.ts
Normal file
44
src/modules/economy/lootdrop.interaction.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { ActionRowBuilder, ButtonBuilder, ButtonInteraction, EmbedBuilder, ButtonStyle } from "discord.js";
|
||||
import { lootdropService } from "./lootdrop.service";
|
||||
import { createErrorEmbed } from "@/lib/embeds";
|
||||
|
||||
export async function handleLootdropInteraction(interaction: ButtonInteraction) {
|
||||
if (interaction.customId === "lootdrop_claim") {
|
||||
await interaction.deferReply({ ephemeral: true });
|
||||
|
||||
const result = await lootdropService.tryClaim(interaction.message.id, interaction.user.id, interaction.user.username);
|
||||
|
||||
if (result.success) {
|
||||
await interaction.editReply({
|
||||
content: `🎉 You successfully claimed **${result.amount} ${result.currency}**!`
|
||||
});
|
||||
|
||||
// Update original message to show claimed state
|
||||
const originalEmbed = interaction.message.embeds[0];
|
||||
if (!originalEmbed) return;
|
||||
|
||||
const newEmbed = new EmbedBuilder(originalEmbed.data)
|
||||
.setDescription(`✅ Claimed by <@${interaction.user.id}> for **${result.amount} ${result.currency}**!`)
|
||||
.setColor("#00FF00");
|
||||
|
||||
// Disable button
|
||||
// We reconstruct the button using builders for safety
|
||||
const newRow = new ActionRowBuilder<ButtonBuilder>()
|
||||
.addComponents(
|
||||
new ButtonBuilder()
|
||||
.setCustomId("lootdrop_claim_disabled")
|
||||
.setLabel("CLAIMED")
|
||||
.setStyle(ButtonStyle.Secondary)
|
||||
.setEmoji("✅")
|
||||
.setDisabled(true)
|
||||
);
|
||||
|
||||
await interaction.message.edit({ embeds: [newEmbed], components: [newRow] });
|
||||
|
||||
} else {
|
||||
await interaction.editReply({
|
||||
embeds: [createErrorEmbed(result.error || "Failed to claim.")]
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
217
src/modules/economy/lootdrop.service.test.ts
Normal file
217
src/modules/economy/lootdrop.service.test.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
import { describe, it, expect, mock, beforeEach, afterEach, spyOn } from "bun:test";
|
||||
import { lootdropService } from "./lootdrop.service";
|
||||
import { lootdrops } from "@/db/schema";
|
||||
import { eq, and, isNull } from "drizzle-orm";
|
||||
import { economyService } from "./economy.service";
|
||||
|
||||
// Mock dependencies BEFORE using service functionality
|
||||
const mockInsert = mock();
|
||||
const mockUpdate = mock();
|
||||
const mockDelete = mock();
|
||||
const mockSelect = mock();
|
||||
const mockValues = mock();
|
||||
const mockReturning = mock();
|
||||
const mockSet = mock();
|
||||
const mockWhere = mock();
|
||||
const mockFrom = mock();
|
||||
|
||||
// Mock setup
|
||||
mockInsert.mockReturnValue({ values: mockValues });
|
||||
mockUpdate.mockReturnValue({ set: mockSet });
|
||||
mockSet.mockReturnValue({ where: mockWhere });
|
||||
mockWhere.mockReturnValue({ returning: mockReturning });
|
||||
mockSelect.mockReturnValue({ from: mockFrom });
|
||||
mockFrom.mockReturnValue({ where: mockWhere });
|
||||
|
||||
// Mock DrizzleClient
|
||||
mock.module("@/lib/DrizzleClient", () => {
|
||||
return {
|
||||
DrizzleClient: {
|
||||
insert: mockInsert,
|
||||
update: mockUpdate,
|
||||
delete: mockDelete,
|
||||
select: mockSelect,
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
// Mock Config
|
||||
mock.module("@/lib/config", () => ({
|
||||
config: {
|
||||
lootdrop: {
|
||||
activityWindowMs: 60000,
|
||||
minMessages: 3,
|
||||
spawnChance: 0.5,
|
||||
cooldownMs: 10000,
|
||||
reward: {
|
||||
min: 10,
|
||||
max: 100,
|
||||
currency: "GOLD"
|
||||
}
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
describe("lootdropService", () => {
|
||||
let originalRandom: any;
|
||||
let mockModifyUserBalance: any;
|
||||
|
||||
beforeEach(() => {
|
||||
mockInsert.mockClear();
|
||||
mockUpdate.mockClear();
|
||||
mockDelete.mockClear();
|
||||
mockValues.mockClear();
|
||||
mockReturning.mockClear();
|
||||
mockSet.mockClear();
|
||||
mockWhere.mockClear();
|
||||
mockSelect.mockClear();
|
||||
mockFrom.mockClear();
|
||||
|
||||
// Reset internal state
|
||||
(lootdropService as any).channelActivity = new Map();
|
||||
(lootdropService as any).channelCooldowns = new Map();
|
||||
|
||||
// Mock Math.random
|
||||
originalRandom = Math.random;
|
||||
|
||||
// Spy
|
||||
mockModifyUserBalance = spyOn(economyService, 'modifyUserBalance').mockResolvedValue({} as any);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Math.random = originalRandom;
|
||||
mockModifyUserBalance.mockRestore();
|
||||
});
|
||||
|
||||
describe("processMessage", () => {
|
||||
it("should track activity but not spawn if minMessages not reached", async () => {
|
||||
const mockChannel = { id: "chan1", send: mock() };
|
||||
const mockMessage = {
|
||||
author: { bot: false },
|
||||
guild: {},
|
||||
channel: mockChannel
|
||||
};
|
||||
|
||||
await lootdropService.processMessage(mockMessage as any);
|
||||
await lootdropService.processMessage(mockMessage as any);
|
||||
|
||||
// Expect no spawn attempt
|
||||
expect(mockChannel.send).not.toHaveBeenCalled();
|
||||
// Internal state check if possible, or just behavior
|
||||
});
|
||||
|
||||
it("should spawn lootdrop if minMessages reached and chance hits", async () => {
|
||||
const mockChannel = { id: "chan1", send: mock() };
|
||||
const mockMessage = {
|
||||
author: { bot: false },
|
||||
guild: {},
|
||||
channel: mockChannel
|
||||
};
|
||||
|
||||
mockChannel.send.mockResolvedValue({ id: "msg1" });
|
||||
Math.random = () => 0.01; // Force hit (0.01 < 0.5)
|
||||
|
||||
// Send 3 messages
|
||||
await lootdropService.processMessage(mockMessage as any);
|
||||
await lootdropService.processMessage(mockMessage as any);
|
||||
await lootdropService.processMessage(mockMessage as any);
|
||||
|
||||
expect(mockChannel.send).toHaveBeenCalled();
|
||||
expect(mockInsert).toHaveBeenCalledWith(lootdrops);
|
||||
|
||||
// Verify DB insert
|
||||
expect(mockValues).toHaveBeenCalledWith(expect.objectContaining({
|
||||
channelId: "chan1",
|
||||
messageId: "msg1",
|
||||
currency: "GOLD"
|
||||
}));
|
||||
});
|
||||
|
||||
it("should not spawn if chance fails", async () => {
|
||||
const mockChannel = { id: "chan1", send: mock() };
|
||||
const mockMessage = {
|
||||
author: { bot: false },
|
||||
guild: {},
|
||||
channel: mockChannel
|
||||
};
|
||||
|
||||
Math.random = () => 0.99; // Force fail (0.99 > 0.5)
|
||||
|
||||
await lootdropService.processMessage(mockMessage as any);
|
||||
await lootdropService.processMessage(mockMessage as any);
|
||||
await lootdropService.processMessage(mockMessage as any);
|
||||
|
||||
expect(mockChannel.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should respect cooldowns", async () => {
|
||||
const mockChannel = { id: "chan1", send: mock() };
|
||||
const mockMessage = {
|
||||
author: { bot: false },
|
||||
guild: {},
|
||||
channel: mockChannel
|
||||
};
|
||||
mockChannel.send.mockResolvedValue({ id: "msg1" });
|
||||
|
||||
Math.random = () => 0.01; // Force hit
|
||||
|
||||
// Trigger spawn
|
||||
await lootdropService.processMessage(mockMessage as any);
|
||||
await lootdropService.processMessage(mockMessage as any);
|
||||
await lootdropService.processMessage(mockMessage as any);
|
||||
|
||||
expect(mockChannel.send).toHaveBeenCalledTimes(1);
|
||||
mockChannel.send.mockClear();
|
||||
|
||||
// Try again immediately (cooldown active)
|
||||
await lootdropService.processMessage(mockMessage as any);
|
||||
await lootdropService.processMessage(mockMessage as any);
|
||||
await lootdropService.processMessage(mockMessage as any);
|
||||
|
||||
expect(mockChannel.send).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("tryClaim", () => {
|
||||
it("should claim successfully if available", async () => {
|
||||
mockReturning.mockResolvedValue([{
|
||||
messageId: "1001",
|
||||
rewardAmount: 50,
|
||||
currency: "GOLD",
|
||||
channelId: "100"
|
||||
}]);
|
||||
|
||||
const result = await lootdropService.tryClaim("1001", "123", "UserOne");
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.amount).toBe(50);
|
||||
expect(mockModifyUserBalance).toHaveBeenCalledWith("123", 50n, "LOOTDROP_CLAIM", expect.any(String));
|
||||
});
|
||||
|
||||
it("should fail if already claimed", async () => {
|
||||
// Update returns empty (failed condition)
|
||||
mockReturning.mockResolvedValue([]);
|
||||
// Select check returns non-empty (exists)
|
||||
|
||||
const mockWhereSelect = mock().mockResolvedValue([{ messageId: "1001", claimedBy: 123n }]);
|
||||
mockFrom.mockReturnValue({ where: mockWhereSelect });
|
||||
|
||||
const result = await lootdropService.tryClaim("1001", "123", "UserOne");
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe("This lootdrop has already been claimed.");
|
||||
});
|
||||
|
||||
it("should fail if expired/not found", async () => {
|
||||
mockReturning.mockResolvedValue([]);
|
||||
|
||||
const mockWhereSelect = mock().mockResolvedValue([]); // Empty result
|
||||
mockFrom.mockReturnValue({ where: mockWhereSelect });
|
||||
|
||||
const result = await lootdropService.tryClaim("1001", "123", "UserOne");
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe("This lootdrop has expired.");
|
||||
});
|
||||
});
|
||||
});
|
||||
167
src/modules/economy/lootdrop.service.ts
Normal file
167
src/modules/economy/lootdrop.service.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
|
||||
import { Message, TextChannel, EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle, ComponentType } from "discord.js";
|
||||
import { config } from "@/lib/config";
|
||||
import { economyService } from "./economy.service";
|
||||
|
||||
import { lootdrops } from "@/db/schema";
|
||||
import { DrizzleClient } from "@/lib/DrizzleClient";
|
||||
import { eq, and, isNull, lt } from "drizzle-orm";
|
||||
|
||||
interface Lootdrop {
|
||||
messageId: string;
|
||||
channelId: string;
|
||||
rewardAmount: number;
|
||||
currency: string;
|
||||
claimedBy?: string;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
class LootdropService {
|
||||
private channelActivity: Map<string, number[]> = new Map();
|
||||
private channelCooldowns: Map<string, number> = new Map();
|
||||
|
||||
constructor() {
|
||||
// Cleanup interval for activity tracking and expired lootdrops
|
||||
setInterval(() => {
|
||||
this.cleanupActivity();
|
||||
this.cleanupExpiredLootdrops();
|
||||
}, 60000);
|
||||
}
|
||||
|
||||
private cleanupActivity() {
|
||||
const now = Date.now();
|
||||
const window = config.lootdrop.activityWindowMs;
|
||||
|
||||
for (const [channelId, timestamps] of this.channelActivity.entries()) {
|
||||
const validTimestamps = timestamps.filter(t => now - t < window);
|
||||
if (validTimestamps.length === 0) {
|
||||
this.channelActivity.delete(channelId);
|
||||
} else {
|
||||
this.channelActivity.set(channelId, validTimestamps);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async cleanupExpiredLootdrops() {
|
||||
try {
|
||||
const now = new Date();
|
||||
await DrizzleClient.delete(lootdrops)
|
||||
.where(and(
|
||||
isNull(lootdrops.claimedBy),
|
||||
lt(lootdrops.expiresAt, now)
|
||||
));
|
||||
} catch (error) {
|
||||
console.error("Failed to cleanup lootdrops:", error);
|
||||
}
|
||||
}
|
||||
|
||||
public async processMessage(message: Message) {
|
||||
if (message.author.bot || !message.guild) return;
|
||||
|
||||
const channelId = message.channel.id;
|
||||
const now = Date.now();
|
||||
|
||||
// Check cooldown
|
||||
const cooldown = this.channelCooldowns.get(channelId);
|
||||
if (cooldown && now < cooldown) return;
|
||||
|
||||
// Track activity
|
||||
const timestamps = this.channelActivity.get(channelId) || [];
|
||||
timestamps.push(now);
|
||||
this.channelActivity.set(channelId, timestamps);
|
||||
|
||||
// Filter for window
|
||||
const window = config.lootdrop.activityWindowMs;
|
||||
const recentActivity = timestamps.filter(t => now - t < window);
|
||||
|
||||
if (recentActivity.length >= config.lootdrop.minMessages) {
|
||||
// Chance to spawn
|
||||
if (Math.random() < config.lootdrop.spawnChance) {
|
||||
await this.spawnLootdrop(message.channel as TextChannel);
|
||||
// Set cooldown
|
||||
this.channelCooldowns.set(channelId, now + config.lootdrop.cooldownMs);
|
||||
this.channelActivity.set(channelId, []);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async spawnLootdrop(channel: TextChannel) {
|
||||
const min = config.lootdrop.reward.min;
|
||||
const max = config.lootdrop.reward.max;
|
||||
const reward = Math.floor(Math.random() * (max - min + 1)) + min;
|
||||
const currency = config.lootdrop.reward.currency;
|
||||
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle("💰 LOOTDROP!")
|
||||
.setDescription(`A lootdrop has appeared! Click the button below to claim **${reward} ${currency}**!`)
|
||||
.setColor("#FFD700")
|
||||
.setTimestamp();
|
||||
|
||||
const claimButton = new ButtonBuilder()
|
||||
.setCustomId("lootdrop_claim")
|
||||
.setLabel("CLAIM REWARD")
|
||||
.setStyle(ButtonStyle.Success)
|
||||
.setEmoji("💸");
|
||||
|
||||
const row = new ActionRowBuilder<ButtonBuilder>()
|
||||
.addComponents(claimButton);
|
||||
|
||||
try {
|
||||
const message = await channel.send({ embeds: [embed], components: [row] });
|
||||
|
||||
// Persist to DB
|
||||
await DrizzleClient.insert(lootdrops).values({
|
||||
messageId: message.id,
|
||||
channelId: channel.id,
|
||||
rewardAmount: reward,
|
||||
currency: currency,
|
||||
createdAt: new Date(),
|
||||
// Expire after 10 mins
|
||||
expiresAt: new Date(Date.now() + 600000)
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error("Failed to spawn lootdrop:", error);
|
||||
}
|
||||
}
|
||||
|
||||
public async tryClaim(messageId: string, userId: string, username: string): Promise<{ success: boolean; amount?: number; currency?: string; error?: string }> {
|
||||
try {
|
||||
// Atomic update: Try to set claimedBy where it is currently null
|
||||
// This acts as a lock and check in one query
|
||||
const result = await DrizzleClient.update(lootdrops)
|
||||
.set({ claimedBy: BigInt(userId) })
|
||||
.where(and(
|
||||
eq(lootdrops.messageId, messageId),
|
||||
isNull(lootdrops.claimedBy)
|
||||
))
|
||||
.returning();
|
||||
|
||||
if (result.length === 0 || !result[0]) {
|
||||
// If update affected 0 rows, check if it was because it doesn't exist or is already claimed
|
||||
const check = await DrizzleClient.select().from(lootdrops).where(eq(lootdrops.messageId, messageId));
|
||||
if (check.length === 0) {
|
||||
return { success: false, error: "This lootdrop has expired." };
|
||||
}
|
||||
return { success: false, error: "This lootdrop has already been claimed." };
|
||||
}
|
||||
|
||||
const drop = result[0];
|
||||
|
||||
await economyService.modifyUserBalance(
|
||||
userId,
|
||||
BigInt(drop.rewardAmount),
|
||||
"LOOTDROP_CLAIM",
|
||||
`Claimed lootdrop in channel ${drop.channelId}`
|
||||
);
|
||||
|
||||
return { success: true, amount: drop.rewardAmount, currency: drop.currency };
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error claiming lootdrop:", error);
|
||||
return { success: false, error: "An error occurred while processing the reward." };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const lootdropService = new LootdropService();
|
||||
242
src/modules/inventory/inventory.service.test.ts
Normal file
242
src/modules/inventory/inventory.service.test.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
import { describe, it, expect, mock, beforeEach, afterEach, spyOn } from "bun:test";
|
||||
import { inventoryService } from "./inventory.service";
|
||||
import { inventory, items, userTimers } from "@/db/schema";
|
||||
// Helper to mock resolved value for spyOn
|
||||
import { economyService } from "@/modules/economy/economy.service";
|
||||
import { levelingService } from "@/modules/leveling/leveling.service";
|
||||
|
||||
// Mock dependencies
|
||||
const mockFindFirst = mock();
|
||||
const mockFindMany = mock();
|
||||
const mockInsert = mock();
|
||||
const mockUpdate = mock();
|
||||
const mockDelete = mock();
|
||||
const mockValues = mock();
|
||||
const mockReturning = mock();
|
||||
const mockSet = mock();
|
||||
const mockWhere = mock();
|
||||
const mockSelect = mock();
|
||||
const mockFrom = mock();
|
||||
const mockOnConflictDoUpdate = mock();
|
||||
|
||||
// Chain setup
|
||||
mockInsert.mockReturnValue({ values: mockValues });
|
||||
mockValues.mockReturnValue({
|
||||
returning: mockReturning,
|
||||
onConflictDoUpdate: mockOnConflictDoUpdate
|
||||
});
|
||||
mockOnConflictDoUpdate.mockResolvedValue({});
|
||||
|
||||
mockUpdate.mockReturnValue({ set: mockSet });
|
||||
mockSet.mockReturnValue({ where: mockWhere });
|
||||
mockWhere.mockReturnValue({ returning: mockReturning });
|
||||
|
||||
mockDelete.mockReturnValue({ where: mockWhere });
|
||||
|
||||
mockSelect.mockReturnValue({ from: mockFrom });
|
||||
mockFrom.mockReturnValue({ where: mockWhere });
|
||||
|
||||
// Mock DrizzleClient
|
||||
mock.module("@/lib/DrizzleClient", () => {
|
||||
const createMockTx = () => ({
|
||||
query: {
|
||||
inventory: { findFirst: mockFindFirst, findMany: mockFindMany },
|
||||
items: { findFirst: mockFindFirst },
|
||||
userTimers: { findFirst: mockFindFirst },
|
||||
},
|
||||
insert: mockInsert,
|
||||
update: mockUpdate,
|
||||
delete: mockDelete,
|
||||
select: mockSelect,
|
||||
});
|
||||
|
||||
return {
|
||||
DrizzleClient: {
|
||||
...createMockTx(),
|
||||
transaction: async (cb: any) => cb(createMockTx()),
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
mock.module("@/lib/config", () => ({
|
||||
config: {
|
||||
inventory: {
|
||||
maxStackSize: 100n,
|
||||
maxSlots: 10
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
describe("inventoryService", () => {
|
||||
let mockModifyUserBalance: any;
|
||||
let mockAddXp: any;
|
||||
|
||||
beforeEach(() => {
|
||||
mockFindFirst.mockReset();
|
||||
mockFindMany.mockReset();
|
||||
mockInsert.mockClear();
|
||||
mockUpdate.mockClear();
|
||||
mockDelete.mockClear();
|
||||
mockValues.mockClear();
|
||||
mockReturning.mockClear();
|
||||
mockSet.mockClear();
|
||||
mockWhere.mockClear();
|
||||
mockSelect.mockClear();
|
||||
mockFrom.mockClear();
|
||||
mockOnConflictDoUpdate.mockClear();
|
||||
|
||||
// Setup Spies
|
||||
mockModifyUserBalance = spyOn(economyService, 'modifyUserBalance').mockResolvedValue({} as any);
|
||||
mockAddXp = spyOn(levelingService, 'addXp').mockResolvedValue({} as any);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockModifyUserBalance.mockRestore();
|
||||
mockAddXp.mockRestore();
|
||||
});
|
||||
|
||||
describe("addItem", () => {
|
||||
it("should add new item if slot available", async () => {
|
||||
// Check existing (none) -> Check count (0) -> Insert
|
||||
mockFindFirst.mockResolvedValue(null);
|
||||
const mockCountResult = mock().mockResolvedValue([{ count: 0 }]);
|
||||
mockFrom.mockReturnValue({ where: mockCountResult });
|
||||
|
||||
mockReturning.mockResolvedValue([{ itemId: 1, quantity: 5n }]);
|
||||
|
||||
const result = await inventoryService.addItem("1", 1, 5n);
|
||||
|
||||
expect(result).toEqual({ itemId: 1, quantity: 5n } as any);
|
||||
expect(mockInsert).toHaveBeenCalledWith(inventory);
|
||||
expect(mockValues).toHaveBeenCalledWith({
|
||||
userId: 1n,
|
||||
itemId: 1,
|
||||
quantity: 5n
|
||||
});
|
||||
});
|
||||
|
||||
it("should stack existing item up to limit", async () => {
|
||||
// Check existing (found with 10)
|
||||
mockFindFirst.mockResolvedValue({ quantity: 10n });
|
||||
mockReturning.mockResolvedValue([{ itemId: 1, quantity: 15n }]);
|
||||
|
||||
const result = await inventoryService.addItem("1", 1, 5n);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result?.quantity).toBe(15n);
|
||||
expect(mockUpdate).toHaveBeenCalledWith(inventory);
|
||||
expect(mockSet).toHaveBeenCalledWith({ quantity: 15n });
|
||||
});
|
||||
|
||||
it("should throw if max stack exceeded", async () => {
|
||||
mockFindFirst.mockResolvedValue({ quantity: 99n });
|
||||
// Max is 100
|
||||
expect(inventoryService.addItem("1", 1, 5n)).rejects.toThrow("Cannot exceed max stack size");
|
||||
});
|
||||
|
||||
it("should throw if inventory full", async () => {
|
||||
mockFindFirst.mockResolvedValue(null);
|
||||
|
||||
const mockCountResult = mock().mockResolvedValue([{ count: 10 }]); // Max slots 10
|
||||
mockFrom.mockReturnValue({ where: mockCountResult });
|
||||
|
||||
expect(inventoryService.addItem("1", 1, 1n)).rejects.toThrow("Inventory full");
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeItem", () => {
|
||||
it("should decrease quantity if enough", async () => {
|
||||
mockFindFirst.mockResolvedValue({ quantity: 10n });
|
||||
mockReturning.mockResolvedValue([{ quantity: 5n }]);
|
||||
|
||||
await inventoryService.removeItem("1", 1, 5n);
|
||||
|
||||
expect(mockUpdate).toHaveBeenCalledWith(inventory);
|
||||
// mockSet uses sql template, hard to check exact value, checking call presence
|
||||
expect(mockSet).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should delete item if quantity becomes 0", async () => {
|
||||
mockFindFirst.mockResolvedValue({ quantity: 5n });
|
||||
|
||||
const result = await inventoryService.removeItem("1", 1, 5n);
|
||||
|
||||
expect(mockDelete).toHaveBeenCalledWith(inventory);
|
||||
expect(result).toBeDefined();
|
||||
expect(result?.quantity).toBe(0n);
|
||||
});
|
||||
|
||||
it("should throw if insufficient quantity", async () => {
|
||||
mockFindFirst.mockResolvedValue({ quantity: 2n });
|
||||
expect(inventoryService.removeItem("1", 1, 5n)).rejects.toThrow("Insufficient item quantity");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buyItem", () => {
|
||||
it("should buy item successfully", async () => {
|
||||
const mockItem = { id: 1, name: "Potion", price: 100n };
|
||||
mockFindFirst.mockResolvedValue(mockItem);
|
||||
|
||||
// For addItem internal call, we need to mock findFirst again or ensure it works.
|
||||
// DrizzleClient.transaction calls callback.
|
||||
// buyItem calls findFirst for item.
|
||||
// buyItem calls modifyUserBalance.
|
||||
// buyItem calls addItem.
|
||||
|
||||
// addItem calls findFirst for inventory.
|
||||
|
||||
// So mockFindFirst needs to return specific values in sequence.
|
||||
mockFindFirst
|
||||
.mockResolvedValueOnce(mockItem) // Item check
|
||||
.mockResolvedValueOnce(null); // addItem -> existing check (null = new)
|
||||
|
||||
// addItem -> count check
|
||||
const mockCountResult = mock().mockResolvedValue([{ count: 0 }]);
|
||||
mockFrom.mockReturnValue({ where: mockCountResult });
|
||||
|
||||
mockReturning.mockResolvedValue([{}]);
|
||||
|
||||
const result = await inventoryService.buyItem("1", 1, 2n);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockModifyUserBalance).toHaveBeenCalledWith("1", -200n, 'PURCHASE', expect.stringContaining("Bought 2x"), null, expect.anything());
|
||||
expect(mockInsert).toHaveBeenCalledWith(inventory); // from addItem
|
||||
});
|
||||
});
|
||||
|
||||
describe("useItem", () => {
|
||||
it("should apply effects and consume item", async () => {
|
||||
const mockItem = {
|
||||
id: 1,
|
||||
name: "XP Potion",
|
||||
usageData: {
|
||||
consume: true,
|
||||
effects: [
|
||||
{ type: "ADD_XP", amount: 100 },
|
||||
{ type: "XP_BOOST", durationMinutes: 60, multiplier: 2.0 }
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
// inventory entry
|
||||
mockFindFirst.mockResolvedValue({ quantity: 1n, item: mockItem });
|
||||
|
||||
// For removeItem:
|
||||
// removeItem calls findFirst (inventory).
|
||||
// So sequence:
|
||||
// 1. useItem -> findFirst (inventory + item)
|
||||
// 2. removeItem -> findFirst (inventory)
|
||||
|
||||
mockFindFirst
|
||||
.mockResolvedValueOnce({ quantity: 1n, item: mockItem }) // useItem check
|
||||
.mockResolvedValueOnce({ quantity: 1n }); // removeItem check
|
||||
|
||||
const result = await inventoryService.useItem("1", 1);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockAddXp).toHaveBeenCalledWith("1", 100n, expect.anything());
|
||||
expect(mockInsert).toHaveBeenCalledWith(userTimers); // XP Boost
|
||||
expect(mockDelete).toHaveBeenCalledWith(inventory); // Consume
|
||||
});
|
||||
});
|
||||
});
|
||||
210
src/modules/leveling/leveling.service.test.ts
Normal file
210
src/modules/leveling/leveling.service.test.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import { describe, it, expect, mock, beforeEach, afterEach, setSystemTime } from "bun:test";
|
||||
import { levelingService } from "./leveling.service";
|
||||
import { users, userTimers } from "@/db/schema";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
|
||||
// Mock dependencies
|
||||
const mockFindFirst = mock();
|
||||
const mockUpdate = mock();
|
||||
const mockSet = mock();
|
||||
const mockWhere = mock();
|
||||
const mockReturning = mock();
|
||||
const mockInsert = mock();
|
||||
const mockValues = mock();
|
||||
const mockOnConflictDoUpdate = mock();
|
||||
|
||||
// Chain setup
|
||||
mockUpdate.mockReturnValue({ set: mockSet });
|
||||
mockSet.mockReturnValue({ where: mockWhere });
|
||||
mockWhere.mockReturnValue({ returning: mockReturning });
|
||||
|
||||
mockInsert.mockReturnValue({ values: mockValues });
|
||||
mockValues.mockReturnValue({ onConflictDoUpdate: mockOnConflictDoUpdate });
|
||||
mockOnConflictDoUpdate.mockResolvedValue({});
|
||||
|
||||
mock.module("@/lib/DrizzleClient", () => {
|
||||
const createMockTx = () => ({
|
||||
query: {
|
||||
users: { findFirst: mockFindFirst },
|
||||
userTimers: { findFirst: mockFindFirst },
|
||||
},
|
||||
update: mockUpdate,
|
||||
insert: mockInsert,
|
||||
});
|
||||
|
||||
return {
|
||||
DrizzleClient: {
|
||||
...createMockTx(),
|
||||
transaction: async (cb: any) => cb(createMockTx()),
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
mock.module("@/lib/config", () => ({
|
||||
config: {
|
||||
leveling: {
|
||||
base: 100,
|
||||
exponent: 1.5,
|
||||
chat: {
|
||||
minXp: 10,
|
||||
maxXp: 20,
|
||||
cooldownMs: 60000
|
||||
}
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
describe("levelingService", () => {
|
||||
let originalRandom: any;
|
||||
|
||||
beforeEach(() => {
|
||||
mockFindFirst.mockReset();
|
||||
mockUpdate.mockClear();
|
||||
mockSet.mockClear();
|
||||
mockWhere.mockClear();
|
||||
mockReturning.mockClear();
|
||||
mockInsert.mockClear();
|
||||
mockValues.mockClear();
|
||||
mockOnConflictDoUpdate.mockClear();
|
||||
originalRandom = Math.random;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Math.random = originalRandom;
|
||||
});
|
||||
|
||||
describe("getXpForLevel", () => {
|
||||
it("should calculate correct XP", () => {
|
||||
// base 100, exp 1.5
|
||||
// lvl 1: 100 * 1^1.5 = 100
|
||||
// lvl 2: 100 * 2^1.5 = 100 * 2.828 = 282
|
||||
expect(levelingService.getXpForLevel(1)).toBe(100);
|
||||
expect(levelingService.getXpForLevel(2)).toBe(282);
|
||||
});
|
||||
});
|
||||
|
||||
describe("addXp", () => {
|
||||
it("should add XP without level up", async () => {
|
||||
// User current: level 1, xp 0
|
||||
// Add 50
|
||||
// Next level (1) needed: 100. (Note: Logic in service seems to use currentLevel for calculation of next step.
|
||||
// Service implementation:
|
||||
// let xpForNextLevel = ... getXpForLevel(currentLevel)
|
||||
// wait, if I am level 1, I need X XP to reach level 2?
|
||||
// Service code:
|
||||
// while (newXp >= xpForNextLevel) { ... currentLevel++ }
|
||||
// So if I am level 1, calling getXpForLevel(1) returns 100.
|
||||
// If I have 100 XP, I level up to 2.
|
||||
|
||||
mockFindFirst.mockResolvedValue({ xp: 0n, level: 1 });
|
||||
mockReturning.mockResolvedValue([{ xp: 50n, level: 1 }]);
|
||||
|
||||
const result = await levelingService.addXp("1", 50n);
|
||||
|
||||
expect(result.levelUp).toBe(false);
|
||||
expect(result.currentLevel).toBe(1);
|
||||
expect(mockUpdate).toHaveBeenCalledWith(users);
|
||||
expect(mockSet).toHaveBeenCalledWith({ xp: 50n, level: 1 });
|
||||
});
|
||||
|
||||
it("should level up if XP sufficient", async () => {
|
||||
// Current: Lvl 1, XP 0. Next Lvl needed: 100.
|
||||
// Add 120.
|
||||
// newXp = 120.
|
||||
// 120 >= 100.
|
||||
// newXp -= 100 -> 20.
|
||||
// currentLevel -> 2.
|
||||
// Next needed for Lvl 2 -> 282.
|
||||
// 20 < 282. Loop ends.
|
||||
|
||||
mockFindFirst.mockResolvedValue({ xp: 0n, level: 1 });
|
||||
mockReturning.mockResolvedValue([{ xp: 20n, level: 2 }]);
|
||||
|
||||
const result = await levelingService.addXp("1", 120n);
|
||||
|
||||
expect(result.levelUp).toBe(true);
|
||||
expect(result.currentLevel).toBe(2);
|
||||
expect(mockSet).toHaveBeenCalledWith({ xp: 20n, level: 2 });
|
||||
});
|
||||
|
||||
it("should handle multiple level ups", async () => {
|
||||
// Lvl 1 (100 needed). Lvl 2 (282 needed). Total for Lvl 3 = 100 + 282 = 382.
|
||||
// Add 400.
|
||||
// 400 >= 100 -> rem 300, Lvl 2.
|
||||
// 300 >= 282 -> rem 18, Lvl 3.
|
||||
|
||||
mockFindFirst.mockResolvedValue({ xp: 0n, level: 1 });
|
||||
mockReturning.mockResolvedValue([{ xp: 18n, level: 3 }]);
|
||||
|
||||
const result = await levelingService.addXp("1", 400n);
|
||||
|
||||
expect(result.currentLevel).toBe(3);
|
||||
});
|
||||
|
||||
it("should throw if user not found", async () => {
|
||||
mockFindFirst.mockResolvedValue(undefined);
|
||||
expect(levelingService.addXp("1", 50n)).rejects.toThrow("User not found");
|
||||
});
|
||||
});
|
||||
|
||||
describe("processChatXp", () => {
|
||||
beforeEach(() => {
|
||||
setSystemTime(new Date("2023-01-01T12:00:00Z"));
|
||||
});
|
||||
|
||||
it("should award XP if no cooldown", async () => {
|
||||
mockFindFirst
|
||||
.mockResolvedValueOnce(undefined) // Cooldown check
|
||||
.mockResolvedValueOnce(undefined) // XP Boost check
|
||||
.mockResolvedValueOnce({ xp: 0n, level: 1 }); // addXp -> getUser
|
||||
|
||||
mockReturning.mockResolvedValue([{ xp: 15n, level: 1 }]); // addXp -> update
|
||||
|
||||
Math.random = () => 0.5; // mid range? 10-20.
|
||||
// floor(0.5 * (20 - 10 + 1)) + 10 = floor(0.5 * 11) + 10 = floor(5.5) + 10 = 15.
|
||||
|
||||
const result = await levelingService.processChatXp("1");
|
||||
|
||||
expect(result.awarded).toBe(true);
|
||||
expect((result as any).amount).toBe(15n);
|
||||
expect(mockInsert).toHaveBeenCalledWith(userTimers); // Cooldown set
|
||||
});
|
||||
|
||||
it("should respect cooldown", async () => {
|
||||
const future = new Date("2023-01-01T12:00:10Z");
|
||||
mockFindFirst.mockResolvedValue({ expiresAt: future });
|
||||
|
||||
const result = await levelingService.processChatXp("1");
|
||||
|
||||
expect(result.awarded).toBe(false);
|
||||
expect(result.reason).toBe("cooldown");
|
||||
expect(mockUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should apply XP boost", async () => {
|
||||
const now = new Date();
|
||||
const future = new Date(now.getTime() + 10000);
|
||||
|
||||
mockFindFirst
|
||||
.mockResolvedValueOnce(undefined) // Cooldown
|
||||
.mockResolvedValueOnce({ expiresAt: future, metadata: { multiplier: 2.0 } }) // Boost
|
||||
.mockResolvedValueOnce({ xp: 0n, level: 1 }); // User
|
||||
|
||||
Math.random = () => 0.0; // Min value = 10.
|
||||
// Boost 2x -> 20.
|
||||
|
||||
mockReturning.mockResolvedValue([{ xp: 20n, level: 1 }]);
|
||||
|
||||
const result = await levelingService.processChatXp("1");
|
||||
|
||||
// Check if amount passed to addXp was boosted
|
||||
// Wait, result.amount is the returned amount from addXp ??
|
||||
// processChatXp returns { awarded: true, amount, ...resultFromAddXp }
|
||||
// So result.amount is the calculated amount.
|
||||
|
||||
expect((result as any).amount).toBe(20n);
|
||||
// Implementation: amount = floor(amount * multiplier)
|
||||
// min 10 * 2 = 20.
|
||||
});
|
||||
});
|
||||
});
|
||||
151
src/modules/quest/quest.service.test.ts
Normal file
151
src/modules/quest/quest.service.test.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { describe, it, expect, mock, beforeEach, afterEach, spyOn } from "bun:test";
|
||||
import { questService } from "./quest.service";
|
||||
import { userQuests } from "@/db/schema";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { economyService } from "@/modules/economy/economy.service";
|
||||
import { levelingService } from "@/modules/leveling/leveling.service";
|
||||
|
||||
// Mock dependencies
|
||||
const mockFindFirst = mock();
|
||||
const mockFindMany = mock();
|
||||
const mockInsert = mock();
|
||||
const mockUpdate = mock();
|
||||
const mockDelete = mock();
|
||||
const mockValues = mock();
|
||||
const mockReturning = mock();
|
||||
const mockSet = mock();
|
||||
const mockWhere = mock();
|
||||
const mockOnConflictDoNothing = mock();
|
||||
|
||||
// Chain setup
|
||||
mockInsert.mockReturnValue({ values: mockValues });
|
||||
mockValues.mockReturnValue({
|
||||
onConflictDoNothing: mockOnConflictDoNothing
|
||||
});
|
||||
mockOnConflictDoNothing.mockReturnValue({ returning: mockReturning });
|
||||
|
||||
mockUpdate.mockReturnValue({ set: mockSet });
|
||||
mockSet.mockReturnValue({ where: mockWhere });
|
||||
mockWhere.mockReturnValue({ returning: mockReturning });
|
||||
|
||||
// Mock DrizzleClient
|
||||
mock.module("@/lib/DrizzleClient", () => {
|
||||
const createMockTx = () => ({
|
||||
query: {
|
||||
userQuests: { findFirst: mockFindFirst, findMany: mockFindMany },
|
||||
},
|
||||
insert: mockInsert,
|
||||
update: mockUpdate,
|
||||
delete: mockDelete,
|
||||
});
|
||||
|
||||
return {
|
||||
DrizzleClient: {
|
||||
...createMockTx(),
|
||||
transaction: async (cb: any) => cb(createMockTx()),
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
describe("questService", () => {
|
||||
let mockModifyUserBalance: any;
|
||||
let mockAddXp: any;
|
||||
|
||||
beforeEach(() => {
|
||||
mockFindFirst.mockReset();
|
||||
mockFindMany.mockReset();
|
||||
mockInsert.mockClear();
|
||||
mockUpdate.mockClear();
|
||||
mockValues.mockClear();
|
||||
mockReturning.mockClear();
|
||||
mockSet.mockClear();
|
||||
mockWhere.mockClear();
|
||||
mockOnConflictDoNothing.mockClear();
|
||||
|
||||
// Setup Spies
|
||||
mockModifyUserBalance = spyOn(economyService, 'modifyUserBalance').mockResolvedValue({} as any);
|
||||
mockAddXp = spyOn(levelingService, 'addXp').mockResolvedValue({} as any);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockModifyUserBalance.mockRestore();
|
||||
mockAddXp.mockRestore();
|
||||
});
|
||||
|
||||
describe("assignQuest", () => {
|
||||
it("should assign quest", async () => {
|
||||
mockReturning.mockResolvedValue([{ userId: 1n, questId: 101 }]);
|
||||
|
||||
const result = await questService.assignQuest("1", 101);
|
||||
|
||||
expect(result).toEqual([{ userId: 1n, questId: 101 }] as any);
|
||||
expect(mockInsert).toHaveBeenCalledWith(userQuests);
|
||||
expect(mockValues).toHaveBeenCalledWith({
|
||||
userId: 1n,
|
||||
questId: 101,
|
||||
progress: 0
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateProgress", () => {
|
||||
it("should update progress", async () => {
|
||||
mockReturning.mockResolvedValue([{ userId: 1n, questId: 101, progress: 50 }]);
|
||||
|
||||
const result = await questService.updateProgress("1", 101, 50);
|
||||
|
||||
expect(result).toEqual([{ userId: 1n, questId: 101, progress: 50 }] as any);
|
||||
expect(mockUpdate).toHaveBeenCalledWith(userQuests);
|
||||
expect(mockSet).toHaveBeenCalledWith({ progress: 50 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("completeQuest", () => {
|
||||
it("should complete quest and grant rewards", async () => {
|
||||
const mockUserQuest = {
|
||||
userId: 1n,
|
||||
questId: 101,
|
||||
completedAt: null,
|
||||
quest: {
|
||||
rewards: { balance: 100, xp: 50 }
|
||||
}
|
||||
};
|
||||
mockFindFirst.mockResolvedValue(mockUserQuest);
|
||||
|
||||
const result = await questService.completeQuest("1", 101);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.rewards.balance).toBe(100n);
|
||||
expect(result.rewards.xp).toBe(50n);
|
||||
|
||||
// Check updates
|
||||
expect(mockUpdate).toHaveBeenCalledWith(userQuests);
|
||||
expect(mockSet).toHaveBeenCalledWith({ completedAt: expect.any(Date) });
|
||||
|
||||
// Check service calls
|
||||
expect(mockModifyUserBalance).toHaveBeenCalledWith("1", 100n, 'QUEST_REWARD', expect.any(String), null, expect.anything());
|
||||
expect(mockAddXp).toHaveBeenCalledWith("1", 50n, expect.anything());
|
||||
});
|
||||
|
||||
it("should throw if quest not assigned", async () => {
|
||||
mockFindFirst.mockResolvedValue(null);
|
||||
expect(questService.completeQuest("1", 101)).rejects.toThrow("Quest not assigned");
|
||||
});
|
||||
|
||||
it("should throw if already completed", async () => {
|
||||
mockFindFirst.mockResolvedValue({ completedAt: new Date() });
|
||||
expect(questService.completeQuest("1", 101)).rejects.toThrow("Quest already completed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getUserQuests", () => {
|
||||
it("should return user quests", async () => {
|
||||
const mockData = [{ questId: 1 }, { questId: 2 }];
|
||||
mockFindMany.mockResolvedValue(mockData);
|
||||
|
||||
const result = await questService.getUserQuests("1");
|
||||
|
||||
expect(result).toEqual(mockData as any);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { userTimers } from "@/db/schema";
|
||||
import { eq, and, lt } from "drizzle-orm";
|
||||
import { DrizzleClient } from "@/lib/DrizzleClient";
|
||||
import { KyokoClient } from "@/lib/BotClient";
|
||||
import { AuroraClient } from "@/lib/BotClient";
|
||||
import { env } from "@/lib/env";
|
||||
|
||||
/**
|
||||
@@ -51,7 +51,7 @@ export const schedulerService = {
|
||||
|
||||
if (guildId) {
|
||||
// We try to fetch, if bot is not in guild or lacks perms, it will catch
|
||||
const guild = await KyokoClient.guilds.fetch(guildId);
|
||||
const guild = await AuroraClient.guilds.fetch(guildId);
|
||||
const member = await guild.members.fetch(userIdStr);
|
||||
await member.roles.remove(roleId);
|
||||
console.log(`👋 Removed temporary role ${roleId} from ${member.user.tag}`);
|
||||
|
||||
181
src/modules/trade/trade.service.test.ts
Normal file
181
src/modules/trade/trade.service.test.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
import { describe, it, expect, mock, beforeEach, afterEach, spyOn } from "bun:test";
|
||||
import { TradeService } from "./trade.service";
|
||||
import { itemTransactions } from "@/db/schema";
|
||||
import { economyService } from "@/modules/economy/economy.service";
|
||||
import { inventoryService } from "@/modules/inventory/inventory.service";
|
||||
|
||||
// Mock dependencies
|
||||
const mockInsert = mock();
|
||||
const mockValues = mock();
|
||||
|
||||
mockInsert.mockReturnValue({ values: mockValues });
|
||||
|
||||
// Mock DrizzleClient
|
||||
mock.module("@/lib/DrizzleClient", () => {
|
||||
return {
|
||||
DrizzleClient: {
|
||||
transaction: async (cb: any) => {
|
||||
const txMock = {
|
||||
insert: mockInsert, // For transaction logs
|
||||
};
|
||||
return cb(txMock);
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe("TradeService", () => {
|
||||
const userA = { id: "1", username: "UserA" };
|
||||
const userB = { id: "2", username: "UserB" };
|
||||
|
||||
let mockModifyUserBalance: any;
|
||||
let mockAddItem: any;
|
||||
let mockRemoveItem: any;
|
||||
|
||||
beforeEach(() => {
|
||||
mockInsert.mockClear();
|
||||
mockValues.mockClear();
|
||||
|
||||
// Clear sessions
|
||||
(TradeService as any).sessions.clear();
|
||||
|
||||
// Spies
|
||||
mockModifyUserBalance = spyOn(economyService, 'modifyUserBalance').mockResolvedValue({} as any);
|
||||
mockAddItem = spyOn(inventoryService, 'addItem').mockResolvedValue({} as any);
|
||||
mockRemoveItem = spyOn(inventoryService, 'removeItem').mockResolvedValue({} as any);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockModifyUserBalance.mockRestore();
|
||||
mockAddItem.mockRestore();
|
||||
mockRemoveItem.mockRestore();
|
||||
});
|
||||
|
||||
|
||||
describe("createSession", () => {
|
||||
it("should create a new session", () => {
|
||||
const session = TradeService.createSession("thread1", userA, userB);
|
||||
|
||||
expect(session.threadId).toBe("thread1");
|
||||
expect(session.state).toBe("NEGOTIATING");
|
||||
expect(session.userA.id).toBe("1");
|
||||
expect(session.userB.id).toBe("2");
|
||||
expect(TradeService.getSession("thread1")).toBe(session);
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateMoney", () => {
|
||||
it("should update money offer", () => {
|
||||
TradeService.createSession("thread1", userA, userB);
|
||||
TradeService.updateMoney("thread1", "1", 100n);
|
||||
|
||||
const session = TradeService.getSession("thread1");
|
||||
expect(session?.userA.offer.money).toBe(100n);
|
||||
});
|
||||
|
||||
it("should unlock participants when offer changes", () => {
|
||||
const session = TradeService.createSession("thread1", userA, userB);
|
||||
session.userA.locked = true;
|
||||
session.userB.locked = true;
|
||||
|
||||
TradeService.updateMoney("thread1", "1", 100n);
|
||||
|
||||
expect(session.userA.locked).toBe(false);
|
||||
expect(session.userB.locked).toBe(false);
|
||||
});
|
||||
|
||||
it("should throw if not in trade", () => {
|
||||
TradeService.createSession("thread1", userA, userB);
|
||||
expect(() => TradeService.updateMoney("thread1", "3", 100n)).toThrow("User not in trade");
|
||||
});
|
||||
});
|
||||
|
||||
describe("addItem", () => {
|
||||
it("should add item to offer", () => {
|
||||
TradeService.createSession("thread1", userA, userB);
|
||||
TradeService.addItem("thread1", "1", { id: 10, name: "Sword" }, 1n);
|
||||
|
||||
const session = TradeService.getSession("thread1");
|
||||
expect(session?.userA.offer.items).toHaveLength(1);
|
||||
expect(session?.userA.offer.items[0]).toEqual({ id: 10, name: "Sword", quantity: 1n });
|
||||
});
|
||||
|
||||
it("should stack items if already offered", () => {
|
||||
TradeService.createSession("thread1", userA, userB);
|
||||
TradeService.addItem("thread1", "1", { id: 10, name: "Sword" }, 1n);
|
||||
TradeService.addItem("thread1", "1", { id: 10, name: "Sword" }, 2n);
|
||||
|
||||
const session = TradeService.getSession("thread1");
|
||||
expect(session?.userA.offer.items[0]!.quantity).toBe(3n);
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeItem", () => {
|
||||
it("should remove item from offer", () => {
|
||||
const session = TradeService.createSession("thread1", userA, userB);
|
||||
session.userA.offer.items.push({ id: 10, name: "Sword", quantity: 1n });
|
||||
|
||||
TradeService.removeItem("thread1", "1", 10);
|
||||
|
||||
expect(session.userA.offer.items).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toggleLock", () => {
|
||||
it("should toggle lock status", () => {
|
||||
TradeService.createSession("thread1", userA, userB);
|
||||
|
||||
const locked1 = TradeService.toggleLock("thread1", "1");
|
||||
expect(locked1).toBe(true);
|
||||
|
||||
const locked2 = TradeService.toggleLock("thread1", "1");
|
||||
expect(locked2).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("executeTrade", () => {
|
||||
it("should execute trade successfully", async () => {
|
||||
const session = TradeService.createSession("thread1", userA, userB);
|
||||
|
||||
// Setup offers
|
||||
session.userA.offer.money = 100n;
|
||||
session.userA.offer.items = [{ id: 10, name: "Sword", quantity: 1n }];
|
||||
|
||||
session.userB.offer.money = 50n; // B paying 50 back? Or just swap.
|
||||
session.userB.offer.items = [];
|
||||
|
||||
// Lock both
|
||||
session.userA.locked = true;
|
||||
session.userB.locked = true;
|
||||
|
||||
await TradeService.executeTrade("thread1");
|
||||
|
||||
expect(session.state).toBe("COMPLETED");
|
||||
|
||||
// Verify Money Transfer A -> B (100)
|
||||
expect(mockModifyUserBalance).toHaveBeenCalledWith("1", -100n, 'TRADE_OUT', expect.any(String), "2", expect.anything());
|
||||
expect(mockModifyUserBalance).toHaveBeenCalledWith("2", 100n, 'TRADE_IN', expect.any(String), "1", expect.anything());
|
||||
|
||||
// Verify Money Transfer B -> A (50)
|
||||
expect(mockModifyUserBalance).toHaveBeenCalledWith("2", -50n, 'TRADE_OUT', expect.any(String), "1", expect.anything());
|
||||
expect(mockModifyUserBalance).toHaveBeenCalledWith("1", 50n, 'TRADE_IN', expect.any(String), "2", expect.anything());
|
||||
|
||||
// Verify Item Transfer A -> B (Sword)
|
||||
expect(mockRemoveItem).toHaveBeenCalledWith("1", 10, 1n, expect.anything());
|
||||
expect(mockAddItem).toHaveBeenCalledWith("2", 10, 1n, expect.anything());
|
||||
|
||||
// Verify DB Logs (Item Transaction)
|
||||
// 2 calls (sender log, receiver log) for 1 item
|
||||
expect(mockInsert).toHaveBeenCalledTimes(2);
|
||||
expect(mockInsert).toHaveBeenCalledWith(itemTransactions);
|
||||
});
|
||||
|
||||
it("should throw if not locked", async () => {
|
||||
const session = TradeService.createSession("thread1", userA, userB);
|
||||
session.userA.locked = true;
|
||||
// B not locked
|
||||
|
||||
expect(TradeService.executeTrade("thread1")).rejects.toThrow("Both players must accept");
|
||||
});
|
||||
});
|
||||
});
|
||||
120
src/modules/user/enrollment.interaction.ts
Normal file
120
src/modules/user/enrollment.interaction.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { ButtonInteraction, MessageFlags } from "discord.js";
|
||||
import { config } from "@/lib/config";
|
||||
import { createErrorEmbed } from "@/lib/embeds";
|
||||
import { classService } from "@modules/class/class.service";
|
||||
import { userService } from "@modules/user/user.service";
|
||||
import { sendWebhookMessage } from "@/lib/webhookUtils";
|
||||
|
||||
export async function handleEnrollmentInteraction(interaction: ButtonInteraction) {
|
||||
if (!interaction.inCachedGuild()) {
|
||||
await interaction.reply({ content: "This action can only be performed in a server.", flags: MessageFlags.Ephemeral });
|
||||
return;
|
||||
}
|
||||
|
||||
const { studentRole, visitorRole } = config;
|
||||
|
||||
if (!studentRole || !visitorRole) {
|
||||
await interaction.reply({
|
||||
embeds: [createErrorEmbed("No student or visitor role configured for enrollment.", "Configuration Error")],
|
||||
flags: MessageFlags.Ephemeral
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. Ensure user exists in DB and check current enrollment status
|
||||
const user = await userService.getOrCreateUser(interaction.user.id, interaction.user.username);
|
||||
|
||||
// Check DB enrollment
|
||||
if (user.class) {
|
||||
await interaction.reply({
|
||||
embeds: [createErrorEmbed("You are already enrolled in a class.", "Enrollment Failed")],
|
||||
flags: MessageFlags.Ephemeral
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const member = interaction.member;
|
||||
|
||||
// Check Discord role enrollment (Double safety)
|
||||
if (member.roles.cache.has(studentRole)) {
|
||||
await interaction.reply({
|
||||
embeds: [createErrorEmbed("You already have the student role.", "Enrollment Failed")],
|
||||
flags: MessageFlags.Ephemeral
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Get available classes
|
||||
const allClasses = await classService.getAllClasses();
|
||||
const validClasses = allClasses.filter(c => c.roleId);
|
||||
|
||||
if (validClasses.length === 0) {
|
||||
await interaction.reply({
|
||||
embeds: [createErrorEmbed("No classes with specified roles found in database.", "Configuration Error")],
|
||||
flags: MessageFlags.Ephemeral
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Pick random class
|
||||
const selectedClass = validClasses[Math.floor(Math.random() * validClasses.length)]!;
|
||||
const classRoleId = selectedClass.roleId!;
|
||||
|
||||
// Check if the role exists in the guild
|
||||
const classRole = interaction.guild.roles.cache.get(classRoleId);
|
||||
if (!classRole) {
|
||||
await interaction.reply({
|
||||
embeds: [createErrorEmbed(`The configured role ID \`${classRoleId}\` for class **${selectedClass.name}** does not exist in this server.`, "Configuration Error")],
|
||||
flags: MessageFlags.Ephemeral
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. Perform Enrollment Actions
|
||||
|
||||
await member.roles.remove(visitorRole);
|
||||
await member.roles.add(studentRole);
|
||||
await member.roles.add(classRole);
|
||||
|
||||
// Persist to DB
|
||||
await classService.assignClass(user.id.toString(), selectedClass.id);
|
||||
|
||||
await interaction.reply({
|
||||
content: `🎉 You have been successfully enrolled! You received the **${classRole.name}** role.`,
|
||||
flags: MessageFlags.Ephemeral
|
||||
});
|
||||
|
||||
// 5. Send Welcome Message (if configured)
|
||||
if (config.welcomeChannelId) {
|
||||
const welcomeChannel = interaction.guild.channels.cache.get(config.welcomeChannelId);
|
||||
if (welcomeChannel && welcomeChannel.isTextBased()) {
|
||||
const rawMessage = config.welcomeMessage || "Welcome to Aurora, {user}! You have been enrolled as a **{class}**.";
|
||||
|
||||
const processedMessage = rawMessage
|
||||
.replace(/{user}/g, member.toString())
|
||||
.replace(/{username}/g, member.user.username)
|
||||
.replace(/{class}/g, selectedClass.name)
|
||||
.replace(/{guild}/g, interaction.guild.name);
|
||||
|
||||
let payload;
|
||||
try {
|
||||
payload = JSON.parse(processedMessage);
|
||||
} catch {
|
||||
payload = processedMessage;
|
||||
}
|
||||
|
||||
// Fire and forget webhook
|
||||
sendWebhookMessage(welcomeChannel, payload, interaction.client.user, "New Student Enrollment")
|
||||
.catch((err: any) => console.error("Failed to send welcome message:", err));
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error("Enrollment error:", error);
|
||||
await interaction.reply({
|
||||
embeds: [createErrorEmbed("An unexpected error occurred during enrollment. Please contact an administrator.", "System Error")],
|
||||
flags: MessageFlags.Ephemeral
|
||||
});
|
||||
}
|
||||
}
|
||||
99
src/modules/user/user.service.test.ts
Normal file
99
src/modules/user/user.service.test.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
|
||||
import { describe, it, expect, mock, beforeEach, afterEach } from "bun:test";
|
||||
import { userService } from "./user.service";
|
||||
|
||||
// Define mock functions outside so we can control them in tests
|
||||
const mockFindFirst = mock();
|
||||
const mockInsert = mock();
|
||||
const mockUpdate = mock();
|
||||
const mockDelete = mock();
|
||||
const mockValues = mock();
|
||||
const mockReturning = mock();
|
||||
const mockSet = mock();
|
||||
const mockWhere = mock();
|
||||
|
||||
// Chainable mock setup
|
||||
mockInsert.mockReturnValue({ values: mockValues });
|
||||
mockValues.mockReturnValue({ returning: mockReturning });
|
||||
|
||||
mockUpdate.mockReturnValue({ set: mockSet });
|
||||
mockSet.mockReturnValue({ where: mockWhere });
|
||||
mockWhere.mockReturnValue({ returning: mockReturning });
|
||||
|
||||
|
||||
// Mock DrizzleClient
|
||||
mock.module("@/lib/DrizzleClient", () => {
|
||||
return {
|
||||
DrizzleClient: {
|
||||
query: {
|
||||
users: {
|
||||
findFirst: mockFindFirst,
|
||||
},
|
||||
},
|
||||
insert: mockInsert,
|
||||
update: mockUpdate,
|
||||
delete: mockDelete,
|
||||
transaction: async (cb: any) => {
|
||||
// Pass the mock client itself as the transaction object
|
||||
// This simplifies things as we use the same structure for tx and client
|
||||
return cb({
|
||||
query: {
|
||||
users: {
|
||||
findFirst: mockFindFirst,
|
||||
},
|
||||
},
|
||||
insert: mockInsert,
|
||||
update: mockUpdate,
|
||||
delete: mockDelete,
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe("userService", () => {
|
||||
beforeEach(() => {
|
||||
mockFindFirst.mockReset();
|
||||
mockInsert.mockClear();
|
||||
mockValues.mockClear();
|
||||
mockReturning.mockClear();
|
||||
});
|
||||
|
||||
describe("getUserById", () => {
|
||||
it("should return a user when found", async () => {
|
||||
const mockUser = { id: 123n, username: "testuser", class: null };
|
||||
mockFindFirst.mockResolvedValue(mockUser);
|
||||
|
||||
const result = await userService.getUserById("123");
|
||||
|
||||
expect(result).toEqual(mockUser as any);
|
||||
expect(mockFindFirst).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should return undefined when user not found", async () => {
|
||||
mockFindFirst.mockResolvedValue(undefined);
|
||||
|
||||
const result = await userService.getUserById("999");
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(mockFindFirst).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createUser", () => {
|
||||
it("should create and return a new user", async () => {
|
||||
const newUser = { id: 456n, username: "newuser", classId: null };
|
||||
mockReturning.mockResolvedValue([newUser]);
|
||||
|
||||
const result = await userService.createUser("456", "newuser");
|
||||
|
||||
expect(result).toEqual(newUser as any);
|
||||
expect(mockInsert).toHaveBeenCalledTimes(1);
|
||||
expect(mockValues).toHaveBeenCalledWith({
|
||||
id: 456n,
|
||||
username: "newuser",
|
||||
classId: undefined
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user