Compare commits

...

11 Commits

Author SHA1 Message Date
안명현 c864be267f Merge origin/main into myong_dev 2026-04-07 14:57:41 +09:00
안명현 41a97c474e Implement fishing progression features 2026-04-07 14:51:25 +09:00
이정수 e4bcf53308 docs: add project structure documentation and update main index file 2026-04-06 14:28:57 +09:00
mineseo-kim 2a8b6d41dc Merge branch 'delete-redis'
# Conflicts:
#	yarn.lock
2026-04-02 11:18:10 +09:00
mineseo-kim c10822ea87 빌드 성공 2026-04-01 17:41:09 +09:00
mineseo-kim 57d38a4e09 빌드성공 2026-03-31 10:49:35 +09:00
mineseo-kim 57805644fb Merge remote-tracking branch 'origin/main' into delete-redis
# Conflicts:
#	prisma/schema.prisma
#	src/client/KordClient.ts
#	yarn.lock
2026-03-31 10:27:05 +09:00
mineseo-kim aad37ef855 빌드 성공
빌드 성공본 업데이트
2026-03-31 10:26:16 +09:00
mineseo-kim c1d18d7d8f Service 고도화 작업
난 한국어 커밋 메세지가 좋아
2026-03-31 10:13:38 +09:00
mineseo-kim 1f91bfb9bf Merge branch 'main' into delete-redis
# Conflicts:
#	package.json
#	yarn.lock
2026-03-31 09:29:49 +09:00
pandoli365 e43af4f944 레디스 제거 버전 작업 2026-03-30 20:48:03 +09:00
39 changed files with 10707 additions and 245 deletions

View File

@ -6,6 +6,5 @@ DISCORD_CLIENT_ID=your_client_id_here
# User/pass from docker-compose.yml # User/pass from docker-compose.yml
DATABASE_URL="postgresql://kord:password@localhost:5432/kord_db?schema=public" DATABASE_URL="postgresql://kord:password@localhost:5432/kord_db?schema=public"
# Redis Configuration ## NOTE
REDIS_HOST="localhost" ## This project does not use Redis.
REDIS_PORT=6379

View File

@ -2,7 +2,7 @@ FROM node:20-alpine AS builder
WORKDIR /app WORKDIR /app
COPY package.json yarn.lock .yarnrc.yml ./ COPY package.json yarn.lock .yarnrc.yml ./
COPY .yarn ./.yarn COPY .yarn ./.yarn
RUN corepack enable && yarn install RUN corepack enable && yarn install --immutable
# Generate Prisma Client # Generate Prisma Client
COPY prisma ./prisma/ COPY prisma ./prisma/
@ -17,9 +17,8 @@ FROM node:20-alpine AS runner
WORKDIR /app WORKDIR /app
COPY package.json yarn.lock .yarnrc.yml ./ COPY package.json yarn.lock .yarnrc.yml ./
COPY .yarn ./.yarn COPY .yarn ./.yarn
RUN corepack enable && yarn install RUN corepack enable && yarn install --immutable --production
COPY --from=builder /app/node_modules/@prisma/client ./node_modules/@prisma/client
COPY --from=builder /app/dist ./dist COPY --from=builder /app/dist ./dist
CMD ["yarn", "node", "dist/index.js"] CMD ["node", "dist/index.js"]

View File

@ -182,7 +182,9 @@
후속 버전에서는 아래 요소를 추가할 수 있습니다. 후속 버전에서는 아래 요소를 추가할 수 있습니다.
- 물고기 희귀도 - 물고기 희귀도
- 물고기 크기(cm) 시스템
- 개별 물고기 인벤토리 - 개별 물고기 인벤토리
- 물고기 도감 / 컬렉션
- 미끼 종류 - 미끼 종류
- 낚싯대 및 업그레이드 - 낚싯대 및 업그레이드
- 물고기 판매 시스템 - 물고기 판매 시스템
@ -253,6 +255,41 @@ Phase 1에서는 직접 `gold`를 주는 방식이 가장 단순하고 호환성
이 정도면 초반 통계 추적에 충분하고, 너무 이르게 인벤토리를 과설계하지 않을 수 있습니다. 이 정도면 초반 통계 추적에 충분하고, 너무 이르게 인벤토리를 과설계하지 않을 수 있습니다.
도감/크기 확장 시에는 아래 모델을 추가합니다.
- `FishingCollectionEntry`
- `userId`
- `guildId`
- `fishId`
- `catchCount`
- `bestRarity`
- `bestSizeCm`
- `lastCaughtAt`
- `createdAt`
- `updatedAt`
이 모델은 유저가 어떤 물고기를 몇 번 잡았는지, 최고 레어도와 최고 크기가 무엇인지 추적하는 데 사용합니다.
### 물고기 크기 시스템
- 낚시 성공 시 물고기마다 `cm` 단위의 크기를 부여합니다.
- 기본 크기 범위는 물고기별 데이터에서 관리합니다.
- 최종 크기는 `물고기 기본 크기 범위 × 레어도 보정치`로 계산합니다.
- 즉, 같은 물고기라도 레어도가 높을수록 더 큰 개체가 등장할 수 있습니다.
- 성공 결과 메시지에는 잡은 물고기의 크기를 함께 표시합니다.
- 도감에는 해당 물고기의 최고 크기를 기록합니다.
### 도감 (Dex / Collection)
- `/fishing dex` 명령을 통해 개인 도감을 조회할 수 있어야 합니다.
- 도감에는 아래 정보를 보여줍니다.
- 잡아본 물고기 목록
- 각 물고기의 포획 횟수
- 최고 레어도
- 최고 크기(cm)
- 마지막 포획 시각
- 아직 잡지 못한 물고기는 후속 버전에서 실루엣/잠금 상태로 표시할 수 있습니다.
### 세션 모델 ### 세션 모델
낚시 플레이 자체는 즉각적인 반응을 위해 메모리 세션 기반이 적합합니다. 낚시 플레이 자체는 즉각적인 반응을 위해 메모리 세션 기반이 적합합니다.
@ -318,17 +355,23 @@ Phase 1에서는 직접 `gold`를 주는 방식이 가장 단순하고 호환성
### Phase 2 ### Phase 2
- `/fishing status` 추가 - `/fishing status` 추가
- `/fishing ranking` 추가
- 사용자별 낚시 통계 추가 - 사용자별 낚시 통계 추가
- 낚시 프로필 영속화 - 낚시 프로필 영속화
- 물고기 이동 패턴 개선 - 물고기 이동 패턴 개선
- 보상 밸런스 조정 - 보상 밸런스 조정
- `/fishing dex` 추가
- 물고기 크기(cm) 시스템 추가
- 도감용 포획 기록 저장
- 길드 내 최고 크기 기준 Top 10 랭킹 표시
### Phase 3 ### Phase 3
- 희귀도 체계 추가 - 희귀도 체계 추가
- 인벤토리 / 도감 추가 - 인벤토리 / 도감 고도화
- 미끼 / 낚싯대 보정치 추가 - 미끼 / 낚싯대 보정치 추가
- 리더보드 지원 - 리더보드 지원
- 미포획 물고기 잠금/실루엣 UI 추가
## 검증 / 테스트 ## 검증 / 테스트

44
Docs/Project_Structure.md Normal file
View File

@ -0,0 +1,44 @@
# 프로젝트 구조 (Project Structure)
본 문서는 Kord 프로젝트의 주요 디렉터리와 아키텍처 구조를 설명합니다.
## 디렉터리 안내
### `src/` - 주요 소스 코드
본 봇의 모든 핵심 비즈니스 로직과 시스템 코드가 위치합니다.
- **`client/`**: `KordClient` 등 디스코드 봇 클라이언트 초기화 및 상태를 관리합니다.
- **`commands/`**: 디스코드 슬래시 명령어의 로직이 위치합니다.
- **`core/`**: 어플리케이션의 핵심 인프라 구성을 담당합니다. (`db.ts`, `command.ts` 기반 베이스 클래스 등)
- **`config/`**: 봇 구동 환경 및 전역 설정 관련 파일입니다.
- **`events/`** & **`handlers/`**: 디스코드 이벤트(messageCreate, interactionCreate 등)의 처리 및 바인딩을 담당합니다.
- **`i18n/`**: 다국어(한국어, 영어 등) 지원을 위한 로케일 데이터 및 번역 함수를 관리합니다.
- **`interactions/`**: 버튼, 셀렉트 메뉴, 모달 등 컴포넌트 상호작용 로직입니다.
- **`service/`** / **`services/`**: 각 도메인 별 비즈니스 로직 및 Prisma DB 읽기/쓰기 작업을 추상화한 서비스 계층입니다. (e.g. `FishingService`, `RefinementService`)
- **`utils/`**: 공통으로 사용되는 유틸리티 및 헬퍼 함수들입니다.
### `resource/` - 에셋 및 데이터 리소스
- 미니게임 아트웍(낚시 물고기 이미지, 무기 이미지 등) 및 정적 JSON 데이터(카탈로그, 확률표 등)를 보관합니다.
### `prisma/` - 데이터베이스 관리
- **`schema.prisma`**: PostgreSQL 데이터베이스의 스키마 명세입니다.
- **`migrations/`**: Prisma Migrate에 의해 자동 생성된 마이그레이션 이력입니다.
- **`seed.ts`**: 초기 데이터베이스 시딩을 위한 스크립트입니다.
### `Docs/` - 공식 문서
기능 명세, 트러블슈팅, 작업 내역, 데이터베이스 스키마 및 규칙 등 관리용 문서를 포함합니다. [`Docs/index.md`](index.md)를 중심으로 카테고리가 분류되어 있습니다.
### `tests/` - 테스트 코드
Jest 프레임워크를 기반으로 한 단위 테스트(Unit Test) 로직이 위치합니다. 주 도메인이나 `core`, `services` 코드에 대한 검증을 수행합니다.
---
## 향후 모놀리식 분리 (Modular Architecture) 진행 사항
최근 프로젝트 구조는 단일 스키마 구조에서 도메인/모듈 별로 패키지를 분리하기 위한 기초 작업이 진행되었습니다.
루트 디렉터리에 위치한 `schema_base.prisma`, `schema_feature.prisma`, `schema_main.prisma``package_feature.json`, `package_main.json` 파일들은 향후 핵심(Core/Base) 로직과 기능(Feature) 모듈을 독립적으로 빌드/관리하기 위해 도입될 예정(Work-in-Progress)입니다.

View File

@ -0,0 +1,29 @@
# 2026-04-07 Fishing Dex and Size Implementation
## 요약
낚시 미니게임에 물고기 크기(`cm`) 시스템과 도감(`/fishing dex`) 기능을 추가했다.
## 구현 내용
- 물고기별 기본 크기 범위를 `fish_catalog.json`에 추가
- 레어도별 크기 보정치를 `fish_rarities.json`에 추가
- 낚시 성공 시 최종 물고기 크기를 계산하여 결과 메시지에 표시
- `FishingCollectionEntry` 모델 추가
- 물고기별 포획 수
- 최고 레어도
- 최고 크기
- 마지막 포획 시각
- `/fishing dex` 서브커맨드 추가
- 유저별 물고기 도감 조회
- 포획 수, 최고 레어도, 최고 크기 표시
- 성공 결과 메시지에 크기 필드 추가
## 검증
- `yarn prisma generate`
- `yarn build`
- `yarn test --runInBand`
- `yarn prisma migrate deploy`
모든 단계가 정상 통과했다.

View File

@ -0,0 +1,39 @@
# 2026-04-07 Fishing Mini-Game Phase 2 Implementation
## 요약
낚시 미니게임의 Phase 2로 `FishingProfile` 기반 통계 영속화와 `/fishing status` 조회 기능을 추가했다.
## 구현 내용
- `FishingProfile` Prisma 모델 추가
- `userId`, `guildId` 복합 키
- 총 시도 수, 성공/실패 수
- 누적 획득 골드
- 최고 보상
- 레어도별 포획 수
- 마지막 낚시 시각
- `FishingService`에 프로필 저장 로직 추가
- 성공 시 보상과 레어도별 포획 수 누적
- 실패 시 실패 횟수 누적
- 모든 세션 종료 시 총 시도 수와 마지막 낚시 시각 갱신
- `/fishing status` 서브커맨드 추가
- 본인 또는 지정 유저의 낚시 통계 조회
- 총 시도, 성공률, 누적 골드, 최고 보상, 마지막 낚시 시각 표시
- 레어도별 포획 수를 별도 필드로 표시
- 낚시 i18n 문자열 추가
- 통계 Embed 제목/필드명/빈 기록 메시지
## 검증
- `yarn prisma generate`
- `yarn prisma migrate deploy`
- `yarn build`
- `yarn test --runInBand`
모든 단계가 정상 통과했다.
## 비고
- `FishingProfile`은 현재 낚시 진행 통계 전용 모델이다.
- 이후 랭킹, 도감, 업적, 장비 시스템은 이 프로필을 기반으로 확장할 수 있다.

View File

@ -0,0 +1,26 @@
# 2026-04-07: 낚시 크기 랭킹 구현
## 개요
낚시 시스템에 `/fishing ranking` 명령을 추가해, 서버 내 최고 물고기 크기 기록 상위 10개를 조회할 수 있도록 구현했습니다.
랭킹은 `FishingCollectionEntry`에 저장된 각 유저의 물고기별 최고 크기 기록을 기준으로 정렬하며, 각 항목에는 아래 정보가 포함됩니다.
- 유저
- 물고기 종류
- 최고 레어도
- 최고 크기(cm)
## 구현 내용
- `/fishing ranking` 서브커맨드 추가
- 길드 기준 최고 크기 기록 Top 10 조회 서비스 추가
- 같은 크기일 경우 최고 레어도, 포획 횟수 순으로 정렬
- 랭킹이 비어 있을 때의 안내 메시지 추가
- 낚시 기획서에 랭킹 항목 반영
## 사용자 확인 포인트
- `/fishing ranking` 실행 시 서버 기준 상위 10개 기록이 표시되는지
- 각 항목에 유저, 물고기, 레어도, 크기가 함께 보이는지
- 기록이 없는 서버에서는 빈 상태 안내가 표시되는지

242
Docs/database-schema.md Normal file
View File

@ -0,0 +1,242 @@
# 데이터베이스 테이블 구조
이 문서는 [Prisma 스키마](../prisma/schema.prisma)를 기준으로 한 **PostgreSQL** 테이블 구조 요약입니다.
## 개요
| 항목 | 값 |
|------|-----|
| DB | PostgreSQL |
| ORM | Prisma (`prisma-client-js`) |
| 연결 | `DATABASE_URL` 환경 변수 |
## 열거형 (Enum)
### `SubscriptionTier`
구독 단계.
| 값 | 설명 |
|----|------|
| `FREE` | 기본 |
| `STANDARD` | 스탠다드 |
| `PRO` | 프로 |
| `PREMIUM` | 프리미엄 |
### `DeleteCondition`
임시 음성 채널 삭제 조건.
| 값 | 설명 |
|----|------|
| `OWNER_LEAVE` | 소유자 퇴장 시 |
| `EMPTY` | 비었을 때 (기본) |
### `EventStatus`
길드 이벤트 상태.
| 값 | 설명 |
|----|------|
| `SCHEDULED` | 예정 (기본) |
| `CANCELLED` | 취소 |
| `COMPLETED` | 완료 |
---
## 테이블 목록
### `GuildConfig`
디스코드 길드별 봇 설정.
| 컬럼 | 타입 | 제약 / 기본값 |
|------|------|----------------|
| `guildId` | `String` | PK |
| `prefix` | `String` | 기본 `!` |
| `mimicEnabled` | `Boolean` | 기본 `false` |
| `bigEmojiEnabled` | `Boolean` | 기본 `false` |
| `locale` | `String?` | nullable |
| `createdAt` | `DateTime` | 생성 시각 |
| `updatedAt` | `DateTime` | 자동 갱신 |
---
### `InviteRole`
길드·초대 코드별 역할 매핑.
| 컬럼 | 타입 | 제약 / 기본값 |
|------|------|----------------|
| `id` | `String` | PK, UUID |
| `guildId` | `String` | |
| `inviteCode` | `String` | |
| `roleId` | `String` | |
| `createdAt` | `DateTime` | |
**유니크:** `(guildId, inviteCode)`
---
### `UserSubscription`
사용자 구독 정보.
| 컬럼 | 타입 | 제약 / 기본값 |
|------|------|----------------|
| `userId` | `String` | PK |
| `tier` | `SubscriptionTier` | 기본 `FREE` |
| `createdAt` | `DateTime` | |
| `updatedAt` | `DateTime` | |
**관계:** `GuildOwnership[]` (1:N)
---
### `GuildOwnership`
구독 사용자가 소유하는 길드.
| 컬럼 | 타입 | 제약 / 기본값 |
|------|------|----------------|
| `guildId` | `String` | PK |
| `ownerId` | `String` | FK → `UserSubscription.userId`, `ON DELETE CASCADE` |
| `createdAt` | `DateTime` | |
**인덱스:** `ownerId`
---
### `VoiceGenerator`
음성 채널 생성기(부모 채널) 설정.
| 컬럼 | 타입 | 제약 / 기본값 |
|------|------|----------------|
| `channelId` | `String` | PK |
| `guildId` | `String` | |
| `categoryId` | `String?` | |
| `createdAt` | `DateTime` | |
| `updatedAt` | `DateTime` | |
**인덱스:** `guildId`
---
### `TempVoiceChannel`
생성된 임시 음성 채널.
| 컬럼 | 타입 | 제약 / 기본값 |
|------|------|----------------|
| `channelId` | `String` | PK |
| `guildId` | `String` | |
| `ownerId` | `String` | |
| `deleteWhen` | `DeleteCondition` | 기본 `EMPTY` |
| `createdAt` | `DateTime` | |
**인덱스:** `guildId`, `ownerId`
---
### `UserVoiceProfile`
길드별 사용자 음성 프로필(표시 이름·인원 제한 등).
| 컬럼 | 타입 | 제약 / 기본값 |
|------|------|----------------|
| `userId` | `String` | 복합 PK |
| `guildId` | `String` | 복합 PK |
| `customName` | `String?` | |
| `userLimit` | `Int?` | |
| `createdAt` | `DateTime` | |
| `updatedAt` | `DateTime` | |
**PK:** `(userId, guildId)`
---
### `UserLocale`
사용자별 로케일.
| 컬럼 | 타입 | 제약 / 기본값 |
|------|------|----------------|
| `userId` | `String` | PK |
| `locale` | `String` | |
| `createdAt` | `DateTime` | |
| `updatedAt` | `DateTime` | |
---
### `AuditChannel`
감사 로그 전달 채널·비활성 카테고리.
| 컬럼 | 타입 | 제약 / 기본값 |
|------|------|----------------|
| `guildId` | `String` | PK |
| `channelId` | `String` | |
| `disabledCategories` | `String[]` | 기본 `["BOOT", "SYSTEM"]` |
| `createdAt` | `DateTime` | |
| `updatedAt` | `DateTime` | |
---
### `VoiceGuildConfig`
길드 단위 음성(임시 채널) 기본 설정.
| 컬럼 | 타입 | 제약 / 기본값 |
|------|------|----------------|
| `guildId` | `String` | PK |
| `defaultNameTemplate` | `String` | 기본 `{{username}}'s Room` |
| `defaultUserLimit` | `Int` | 기본 `0` |
| `createdAt` | `DateTime` | |
| `updatedAt` | `DateTime` | |
---
### `GuildEvent`
길드 일정/이벤트.
| 컬럼 | 타입 | 제약 / 기본값 |
|------|------|----------------|
| `id` | `String` | PK, UUID |
| `guildId` | `String` | |
| `title` | `String` | |
| `description` | `String?` | |
| `startsAt` | `DateTime` | |
| `timezone` | `String` | 기본 `Asia/Seoul` |
| `status` | `EventStatus` | 기본 `SCHEDULED` |
| `announcementChannelId` | `String?` | |
| `createdByUserId` | `String` | |
| `reminderEnabled` | `Boolean` | 기본 `true` |
| `reminderOffsets` | `Int[]` | 기본 `[]` |
| `sentReminderOffsets` | `Int[]` | 기본 `[]` |
| `remindedOneHour` | `Boolean` | 기본 `false` |
| `remindedTenMinutes` | `Boolean` | 기본 `false` |
| `startedAnnounced` | `Boolean` | 기본 `false` |
| `announcedAt` | `DateTime?` | |
| `createdAt` | `DateTime` | |
| `updatedAt` | `DateTime` | |
**인덱스:** `(guildId, startsAt)`, `(guildId, status)`
---
## 관계 요약
```mermaid
erDiagram
UserSubscription ||--o{ GuildOwnership : "userId"
```
- **UserSubscription** 1 — N **GuildOwnership** (`ownerId` → `userId`, 길드 삭제 시 소유권 행 CASCADE 삭제)
그 외 테이블은 Prisma 모델상 **명시적 `relation` 블록**이 없으며, `guildId` / `userId` / `channelId` 등이 애플리케이션 레벨에서 Discord ID로 연결됩니다.
## 스키마 변경 시
실제 DDL은 `prisma/migrations/` 아래 마이그레이션 SQL과 동기화됩니다. 구조를 바꾼 뒤에는 이 문서와 [schema.prisma](../prisma/schema.prisma)를 함께 맞추는 것이 좋습니다.

View File

@ -1,7 +1,12 @@
# Kord Documentation Index # Kord Documentation Index
이 루트 색인 문서는 프로젝트 내의 모든 구조화된 문서를 카테고리별로 모아 탐색을 돕기 위해 작성되었습니다. 이 루트 색인 문서는 프로젝트 내의 모든 구조화된 문서를 카테고리별로 모아 탐색을 돕기 위해 작성되었습니다.
## 아키텍처 및 시스템 (Architecture & System)
- [프로젝트 구조 (Project Structure)](Project_Structure.md)
- [데이터베이스 스키마 구조 (Database Schema)](database-schema.md)
## 정책 및 규칙 (Rules) ## 정책 및 규칙 (Rules)
- [보안 가이드라인 (Security Rules)](Rules/security_guidelines.md) - [보안 가이드라인 (Security Rules)](Rules/security_guidelines.md)
@ -58,3 +63,6 @@
- [2026-03-30: 미니게임 시스템 및 재련 게임 구현 (Mini-Game System & Refinement Implementation)](WorkDone/2026-03-30_RefinementImplementation.md) - [2026-03-30: 미니게임 시스템 및 재련 게임 구현 (Mini-Game System & Refinement Implementation)](WorkDone/2026-03-30_RefinementImplementation.md)
- [2026-03-31: 낚시 미니게임 Phase 1 구현 (Fishing Mini-Game Phase 1 Implementation)](WorkDone/2026-03-31_Fishing_MiniGame_Phase1_Implementation.md) - [2026-03-31: 낚시 미니게임 Phase 1 구현 (Fishing Mini-Game Phase 1 Implementation)](WorkDone/2026-03-31_Fishing_MiniGame_Phase1_Implementation.md)
- [2026-03-31: 낚시 미니게임 Phase 1 완료 (Fishing Mini-Game Phase 1 Completion)](WorkDone/2026-03-31_Fishing_MiniGame_Phase1_Completion.md) - [2026-03-31: 낚시 미니게임 Phase 1 완료 (Fishing Mini-Game Phase 1 Completion)](WorkDone/2026-03-31_Fishing_MiniGame_Phase1_Completion.md)
- [2026-04-07: 낚시 미니게임 Phase 2 구현 (Fishing Mini-Game Phase 2 Implementation)](WorkDone/2026-04-07_Fishing_MiniGame_Phase2_Implementation.md)
- [2026-04-07: 낚시 도감 및 크기 시스템 구현 (Fishing Dex and Size Implementation)](WorkDone/2026-04-07_Fishing_Dex_And_Size_Implementation.md)
- [2026-04-07: 낚시 크기 랭킹 구현 (Fishing Size Ranking Implementation)](WorkDone/2026-04-07_Fishing_Size_Ranking_Implementation.md)

View File

@ -4,14 +4,13 @@ Kord는 Discord 서버 관리를 돕는 강력하고 유연한 다기능 봇입
## 1. 개요 (Overview) ## 1. 개요 (Overview)
**Kord**는 효율적인 서버 운영을 위해 설계된 Discord 봇입니다. TypeScript와 Discord.js를 기반으로 구축되었으며, Prisma(PostgreSQL)와 Redis를 활용하여 안정적이고 확장 가능한 아키텍처를 제공합니다. 임시 음성 채널 관리, 상세 감사 로그, 권한 진단 등의 핵심 기능을 통해 서버 관리자의 부담을 줄여줍니다. **Kord**는 효율적인 서버 운영을 위해 설계된 Discord 봇입니다. TypeScript와 Discord.js를 기반으로 구축되었으며, Prisma(PostgreSQL)를 활용하여 안정적이고 확장 가능한 아키텍처를 제공합니다. 임시 음성 채널 관리, 상세 감사 로그, 권한 진단 등의 핵심 기능을 통해 서버 관리자의 부담을 줄여줍니다.
## 2. 요구사항 (Requirements) ## 2. 요구사항 (Requirements)
- **Runtime**: Node.js v20 이상 - **Runtime**: Node.js v20 이상
- **Package Manager**: Yarn v4 (Berry) - **Package Manager**: Yarn v4 (Berry)
- **Database**: PostgreSQL (Prisma 사용) - **Database**: PostgreSQL (Prisma 사용)
- **Cache**: Redis (다중 인스턴스 동기화 및 캐싱)
- **Discord**: Bot Token 및 Client ID (Slash Command 등록용) - **Discord**: Bot Token 및 Client ID (Slash Command 등록용)
## 3. 테스트 방법 (Test Methods) ## 3. 테스트 방법 (Test Methods)
@ -59,7 +58,7 @@ Kord는 Discord 서버 관리를 돕는 강력하고 유연한 다기능 봇입
1. **빌드**: `yarn build` 1. **빌드**: `yarn build`
2. **실행**: `yarn start` 2. **실행**: `yarn start`
3. **Docker**: `docker-compose up -d`를 통해 전체 스택(Bot, DB, Redis)을 실행할 수 있습니다. 3. **Docker**: `docker-compose up -d`를 통해 전체 스택(Bot, DB)을 실행할 수 있습니다.
## 5. 기능 목록 (Feature List) ## 5. 기능 목록 (Feature List)

View File

@ -1,6 +1,15 @@
version: '3.8' version: '3.8'
services: services:
kord:
build: .
container_name: kord-bot
env_file:
- .env
depends_on:
- postgres
restart: unless-stopped
postgres: postgres:
image: postgres:15-alpine image: postgres:15-alpine
container_name: kord-postgres container_name: kord-postgres
@ -14,15 +23,5 @@ services:
- postgres_data:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql/data
restart: unless-stopped restart: unless-stopped
redis:
image: redis:7-alpine
container_name: kord-redis
ports:
- "6379:6379"
volumes:
- redis_data:/data
restart: unless-stopped
volumes: volumes:
postgres_data: postgres_data:
redis_data:

8825
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -5,7 +5,8 @@
"@discordjs/opus": "^0.10.0", "@discordjs/opus": "^0.10.0",
"@discordjs/voice": "^0.19.2", "@discordjs/voice": "^0.19.2",
"@prisma/adapter-pg": "^7.6.0", "@prisma/adapter-pg": "^7.6.0",
"@prisma/client": "7.6.0", "@prisma/client": "^7.6.0",
"@prisma/config": "^7.6.0",
"@types/pg": "^8.20.0", "@types/pg": "^8.20.0",
"discord.js": "^14.25.1", "discord.js": "^14.25.1",
"dotenv": "^17.3.1", "dotenv": "^17.3.1",
@ -24,7 +25,7 @@
"eslint": "^10.1.0", "eslint": "^10.1.0",
"jest": "^30.3.0", "jest": "^30.3.0",
"prettier": "^3.8.1", "prettier": "^3.8.1",
"prisma": "7.6.0", "prisma": "^7.6.0",
"ts-jest": "^29.4.6", "ts-jest": "^29.4.6",
"tsx": "^4.21.0", "tsx": "^4.21.0",
"typescript": "^6.0.2" "typescript": "^6.0.2"

View File

@ -0,0 +1,9 @@
-- CreateTable
CREATE TABLE "guild_payment" (
"id" TEXT NOT NULL,
"music" BOOLEAN NOT NULL DEFAULT false,
"minigame" BOOLEAN NOT NULL DEFAULT false,
"broadcast" BOOLEAN NOT NULL DEFAULT false,
CONSTRAINT "guild_payment_pkey" PRIMARY KEY ("id")
);

View File

@ -0,0 +1,21 @@
CREATE TABLE "FishingProfile" (
"userId" TEXT NOT NULL,
"guildId" TEXT NOT NULL,
"totalCastCount" INTEGER NOT NULL DEFAULT 0,
"successCount" INTEGER NOT NULL DEFAULT 0,
"failCount" INTEGER NOT NULL DEFAULT 0,
"totalGoldEarned" INTEGER NOT NULL DEFAULT 0,
"bestCatchReward" INTEGER NOT NULL DEFAULT 0,
"commonCatchCount" INTEGER NOT NULL DEFAULT 0,
"uncommonCatchCount" INTEGER NOT NULL DEFAULT 0,
"rareCatchCount" INTEGER NOT NULL DEFAULT 0,
"epicCatchCount" INTEGER NOT NULL DEFAULT 0,
"legendaryCatchCount" INTEGER NOT NULL DEFAULT 0,
"lastCastAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "FishingProfile_pkey" PRIMARY KEY ("userId","guildId")
);
CREATE INDEX "FishingProfile_guildId_successCount_idx" ON "FishingProfile"("guildId", "successCount" DESC);

View File

@ -0,0 +1,16 @@
CREATE TABLE "FishingCollectionEntry" (
"userId" TEXT NOT NULL,
"guildId" TEXT NOT NULL,
"fishId" TEXT NOT NULL,
"catchCount" INTEGER NOT NULL DEFAULT 0,
"bestRarityId" TEXT NOT NULL,
"bestRarityRank" INTEGER NOT NULL DEFAULT 0,
"bestSizeCm" DOUBLE PRECISION NOT NULL DEFAULT 0,
"lastCaughtAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "FishingCollectionEntry_pkey" PRIMARY KEY ("userId","guildId","fishId")
);
CREATE INDEX "FishingCollectionEntry_guildId_userId_idx" ON "FishingCollectionEntry"("guildId", "userId");

View File

@ -1,5 +1,6 @@
generator client { generator client {
provider = "prisma-client-js" provider = "prisma-client-js"
previewFeatures = ["driverAdapters"]
} }
datasource db { datasource db {
@ -140,47 +141,73 @@ enum EventStatus {
COMPLETED COMPLETED
} }
// ─── Mini Game System ─────────────────────────────────────────────────────── /// 길드별 유료 기능(특성) 플래그. 행이 없거나 해당 컬럼이 false면 미결제로 간주합니다.
model GuildPayment {
id String @id
music Boolean @default(false)
minigame Boolean @default(false)
broadcast Boolean @default(false)
@@map("guild_payment")
}
// 서버별 미니게임 활성화 상태 관리
model MiniGameConfig { model MiniGameConfig {
id String @id @default(uuid()) id String @id @default(uuid())
guildId String guildId String
gameKey String gameKey String
enabled Boolean @default(false) enabled Boolean @default(false)
channelId String? channelId String?
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@unique([guildId, gameKey])
@@index([guildId]) @@index([guildId])
@@unique([guildId, gameKey])
} }
// 재련 - 유저 상태
model RefinementProfile { model RefinementProfile {
userId String userId String
guildId String guildId String
gold Int @default(1000) gold Int @default(1000)
weaponLevel Int @default(0) weaponLevel Int @default(0)
maxWeaponLevel Int @default(0) maxWeaponLevel Int @default(0)
durability Int @default(10) durability Int @default(10)
tryCount Int @default(0) tryCount Int @default(0)
successCount Int @default(0) successCount Int @default(0)
failCount Int @default(0) failCount Int @default(0)
destroyCount Int @default(0) destroyCount Int @default(0)
battleWin Int @default(0) battleWin Int @default(0)
battleLoss Int @default(0) battleLoss Int @default(0)
dailyBattleCount Int @default(0) dailyBattleCount Int @default(0)
lastBattleReset DateTime @default(now()) isDisabled Boolean @default(false)
isDisabled Boolean @default(false) lastCheckIn DateTime?
lastCheckIn DateTime? lastBattleReset DateTime @default(now())
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@id([userId, guildId]) @@id([userId, guildId])
@@index([guildId, weaponLevel(sort: Desc)]) @@index([guildId, weaponLevel(sort: Desc)])
} }
// 재련 - 레벨별 수치 설정 (수치 대조 및 수정용) model ActivityLog {
id String @id @default(uuid())
guildId String
hour Int
dayOfWeek Int
count Int @default(0)
weekStart DateTime
@@index([guildId, weekStart])
@@unique([guildId, hour, dayOfWeek, weekStart])
}
model FeverState {
guildId String @id
isActive Boolean @default(false)
peakHour Int?
bonusRate Float @default(0.1)
expiresAt DateTime?
updatedAt DateTime @updatedAt
}
model RefinementLevelConfig { model RefinementLevelConfig {
level Int @id level Int @id
successRate Float successRate Float
@ -191,41 +218,52 @@ model RefinementLevelConfig {
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
} }
// 재련 - 레벨 차이별 승률 설정 (승률 곡선 관리)
model RefinementBattleConfig { model RefinementBattleConfig {
levelGap Int @id // (공격자 레벨 - 방어자 레벨) levelGap Int @id
winRate Float winRate Float
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
} }
// 재련 - 전역 시스템 설정 (일일 제한, 초기 자본 등)
model RefinementSystemConfig { model RefinementSystemConfig {
key String @id key String @id
value String value String
description String? description String?
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
} }
model FishingProfile {
userId String
guildId String
totalCastCount Int @default(0)
successCount Int @default(0)
failCount Int @default(0)
totalGoldEarned Int @default(0)
bestCatchReward Int @default(0)
commonCatchCount Int @default(0)
uncommonCatchCount Int @default(0)
rareCatchCount Int @default(0)
epicCatchCount Int @default(0)
legendaryCatchCount Int @default(0)
lastCastAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// 서버 활동 추이 (시간대별 메시지 수) @@id([userId, guildId])
model ActivityLog { @@index([guildId, successCount(sort: Desc)])
id String @id @default(uuid())
guildId String
hour Int
dayOfWeek Int
count Int @default(0)
weekStart DateTime
@@unique([guildId, hour, dayOfWeek, weekStart])
@@index([guildId, weekStart])
} }
// 피버 상태 model FishingCollectionEntry {
model FeverState { userId String
guildId String @id guildId String
isActive Boolean @default(false) fishId String
peakHour Int? catchCount Int @default(0)
bonusRate Float @default(0.1) bestRarityId String
expiresAt DateTime? bestRarityRank Int @default(0)
updatedAt DateTime @updatedAt bestSizeCm Float @default(0)
lastCaughtAt DateTime
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@id([userId, guildId, fishId])
@@index([guildId, userId])
} }

View File

@ -11,6 +11,10 @@
"min": 120, "min": 120,
"max": 180 "max": 180
}, },
"sizeCm": {
"min": 9,
"max": 16
},
"reactionWindowSec": 2.0, "reactionWindowSec": 2.0,
"distanceReductionByPosition": { "distanceReductionByPosition": {
"left": { "left": {
@ -38,6 +42,10 @@
"min": 150, "min": 150,
"max": 220 "max": 220
}, },
"sizeCm": {
"min": 18,
"max": 32
},
"reactionWindowSec": 1.8, "reactionWindowSec": 1.8,
"distanceReductionByPosition": { "distanceReductionByPosition": {
"left": { "left": {
@ -65,6 +73,10 @@
"min": 180, "min": 180,
"max": 260 "max": 260
}, },
"sizeCm": {
"min": 14,
"max": 26
},
"reactionWindowSec": 1.6, "reactionWindowSec": 1.6,
"distanceReductionByPosition": { "distanceReductionByPosition": {
"left": { "left": {
@ -92,6 +104,10 @@
"min": 200, "min": 200,
"max": 300 "max": 300
}, },
"sizeCm": {
"min": 22,
"max": 38
},
"reactionWindowSec": 1.5, "reactionWindowSec": 1.5,
"distanceReductionByPosition": { "distanceReductionByPosition": {
"left": { "left": {
@ -119,6 +135,10 @@
"min": 230, "min": 230,
"max": 340 "max": 340
}, },
"sizeCm": {
"min": 35,
"max": 70
},
"reactionWindowSec": 1.4, "reactionWindowSec": 1.4,
"distanceReductionByPosition": { "distanceReductionByPosition": {
"left": { "left": {
@ -146,6 +166,10 @@
"min": 260, "min": 260,
"max": 380 "max": 380
}, },
"sizeCm": {
"min": 12,
"max": 24
},
"reactionWindowSec": 1.3, "reactionWindowSec": 1.3,
"distanceReductionByPosition": { "distanceReductionByPosition": {
"left": { "left": {
@ -173,6 +197,10 @@
"min": 320, "min": 320,
"max": 460 "max": 460
}, },
"sizeCm": {
"min": 28,
"max": 55
},
"reactionWindowSec": 1.25, "reactionWindowSec": 1.25,
"distanceReductionByPosition": { "distanceReductionByPosition": {
"left": { "left": {
@ -200,6 +228,10 @@
"min": 380, "min": 380,
"max": 540 "max": 540
}, },
"sizeCm": {
"min": 20,
"max": 42
},
"reactionWindowSec": 1.2, "reactionWindowSec": 1.2,
"distanceReductionByPosition": { "distanceReductionByPosition": {
"left": { "left": {
@ -227,6 +259,10 @@
"min": 520, "min": 520,
"max": 720 "max": 720
}, },
"sizeCm": {
"min": 90,
"max": 160
},
"reactionWindowSec": 1.2, "reactionWindowSec": 1.2,
"distanceReductionByPosition": { "distanceReductionByPosition": {
"left": { "left": {
@ -254,6 +290,10 @@
"min": 800, "min": 800,
"max": 1200 "max": 1200
}, },
"sizeCm": {
"min": 140,
"max": 260
},
"reactionWindowSec": 1.2, "reactionWindowSec": 1.2,
"distanceReductionByPosition": { "distanceReductionByPosition": {
"left": { "left": {

View File

@ -10,6 +10,10 @@
"rewardMultiplier": 1.0, "rewardMultiplier": 1.0,
"reactionWindowMultiplier": 1.0, "reactionWindowMultiplier": 1.0,
"tensionMultiplier": 1.0, "tensionMultiplier": 1.0,
"sizeMultiplier": {
"min": 1.0,
"max": 1.05
},
"backgroundColor": "#6B7280" "backgroundColor": "#6B7280"
}, },
{ {
@ -20,6 +24,10 @@
"rewardMultiplier": 1.2, "rewardMultiplier": 1.2,
"reactionWindowMultiplier": 1.08, "reactionWindowMultiplier": 1.08,
"tensionMultiplier": 1.08, "tensionMultiplier": 1.08,
"sizeMultiplier": {
"min": 1.02,
"max": 1.12
},
"backgroundColor": "#22C55E" "backgroundColor": "#22C55E"
}, },
{ {
@ -30,6 +38,10 @@
"rewardMultiplier": 1.55, "rewardMultiplier": 1.55,
"reactionWindowMultiplier": 1.16, "reactionWindowMultiplier": 1.16,
"tensionMultiplier": 1.14, "tensionMultiplier": 1.14,
"sizeMultiplier": {
"min": 1.1,
"max": 1.25
},
"backgroundColor": "#3B82F6" "backgroundColor": "#3B82F6"
}, },
{ {
@ -40,6 +52,10 @@
"rewardMultiplier": 2.1, "rewardMultiplier": 2.1,
"reactionWindowMultiplier": 1.28, "reactionWindowMultiplier": 1.28,
"tensionMultiplier": 1.24, "tensionMultiplier": 1.24,
"sizeMultiplier": {
"min": 1.22,
"max": 1.45
},
"backgroundColor": "#A855F7" "backgroundColor": "#A855F7"
}, },
{ {
@ -50,6 +66,10 @@
"rewardMultiplier": 3.0, "rewardMultiplier": 3.0,
"reactionWindowMultiplier": 1.42, "reactionWindowMultiplier": 1.42,
"tensionMultiplier": 1.36, "tensionMultiplier": 1.36,
"sizeMultiplier": {
"min": 1.4,
"max": 1.8
},
"backgroundColor": "#F59E0B" "backgroundColor": "#F59E0B"
} }
] ]

65
src/cache/index.ts vendored
View File

@ -1,23 +1,52 @@
import Redis from 'ioredis'; type CacheEntry = { value: string; expiresAtMs?: number };
import { env } from '../config/env';
import { logger } from '../utils/logger';
export const redis = new Redis({ const nowMs = () => Date.now();
host: env.REDIS_HOST,
port: env.REDIS_PORT,
lazyConnect: true,
});
redis.on('error', (err) => { class LocalCache {
logger.error('Redis Error:', err); private store = new Map<string, CacheEntry>();
});
export const connectRedis = async () => { private isExpired(entry: CacheEntry | undefined) {
try { return entry?.expiresAtMs !== undefined && entry.expiresAtMs <= nowMs();
await redis.connect();
logger.info('Connected to Redis successfully.');
} catch (error) {
logger.error('Failed to connect to Redis:', error);
process.exit(1);
} }
private getEntry(key: string): CacheEntry | undefined {
const entry = this.store.get(key);
if (!entry) return undefined;
if (this.isExpired(entry)) {
this.store.delete(key);
return undefined;
}
return entry;
}
async get(key: string): Promise<string | null> {
const entry = this.getEntry(key);
return entry ? entry.value : null;
}
async set(key: string, value: string, opts?: { exSeconds?: number; nx?: boolean }): Promise<boolean> {
const existing = this.getEntry(key);
if (opts?.nx && existing) return false;
const expiresAtMs = opts?.exSeconds !== undefined ? nowMs() + opts.exSeconds * 1000 : undefined;
this.store.set(key, { value, expiresAtMs });
return true;
}
async del(key: string): Promise<boolean> {
const existed = this.getEntry(key) !== undefined;
this.store.delete(key);
return existed;
}
}
export const cache = new LocalCache();
/**
* Best-effort local process lock.
* - Returns true only once per key during ttl window.
* - Single process only (no cross-instance coordination).
*/
export const tryAcquireLock = async (key: string, ttlSeconds: number): Promise<boolean> => {
return cache.set(`lock:${key}`, '1', { exSeconds: ttlSeconds, nx: true });
}; };

View File

@ -5,8 +5,6 @@ import { loadCommands } from '../handlers/CommandLoader';
import { loadEvents } from '../handlers/EventLoader'; import { loadEvents } from '../handlers/EventLoader';
import { handleGlobalExceptions } from '../utils/errorHandler'; import { handleGlobalExceptions } from '../utils/errorHandler';
import { connectDB } from '../database'; import { connectDB } from '../database';
import { connectRedis } from '../cache';
import { FeverService } from '../services/FeverService';
export class KordClient extends Client { export class KordClient extends Client {
public commands: Collection<string, any> = new Collection(); public commands: Collection<string, any> = new Collection();
@ -29,7 +27,6 @@ export class KordClient extends Client {
// Connect to external services // Connect to external services
await connectDB(); await connectDB();
await connectRedis();
// Load Handlers // Load Handlers
await loadCommands(this); await loadCommands(this);

View File

@ -1,6 +1,7 @@
import { import {
ChannelType, ChannelType,
ChatInputCommandInteraction, ChatInputCommandInteraction,
EmbedBuilder,
SlashCommandBuilder, SlashCommandBuilder,
} from 'discord.js'; } from 'discord.js';
import { prisma } from '../database'; import { prisma } from '../database';
@ -37,6 +38,46 @@ export default {
.setDescriptionLocalizations({ .setDescriptionLocalizations({
ko: '진행 중인 낚시 세션을 종료하고 스레드를 삭제합니다.', ko: '진행 중인 낚시 세션을 종료하고 스레드를 삭제합니다.',
}), }),
)
.addSubcommand((subcommand) =>
subcommand
.setName('status')
.setDescription('View fishing statistics.')
.setDescriptionLocalizations({
ko: '낚시 통계를 확인합니다.',
})
.addUserOption((option) =>
option
.setName('user')
.setDescription('User to view')
.setDescriptionLocalizations({
ko: '조회할 유저',
}),
),
)
.addSubcommand((subcommand) =>
subcommand
.setName('dex')
.setDescription('View your fishing collection book.')
.setDescriptionLocalizations({
ko: '낚시 도감을 확인합니다.',
})
.addUserOption((option) =>
option
.setName('user')
.setDescription('User to view')
.setDescriptionLocalizations({
ko: '조회할 유저',
}),
),
)
.addSubcommand((subcommand) =>
subcommand
.setName('ranking')
.setDescription('View the biggest fish size ranking in this server.')
.setDescriptionLocalizations({
ko: '이 서버의 물고기 크기 랭킹을 확인합니다.',
}),
), ),
async execute(interaction: ChatInputCommandInteraction, locale: SupportedLocale) { async execute(interaction: ChatInputCommandInteraction, locale: SupportedLocale) {
@ -122,6 +163,135 @@ export default {
await interaction.editReply({ await interaction.editReply({
content: t(locale, 'commands.fishing.endDeleted'), content: t(locale, 'commands.fishing.endDeleted'),
}); });
return;
}
if (subcommand === 'status') {
const targetUser = interaction.options.getUser('user') ?? interaction.user;
const profile = await FishingService.getProfile(targetUser.id, interaction.guildId);
const totalCasts = profile?.totalCastCount ?? 0;
const successCount = profile?.successCount ?? 0;
const failCount = profile?.failCount ?? 0;
const totalGoldEarned = profile?.totalGoldEarned ?? 0;
const bestCatchReward = profile?.bestCatchReward ?? 0;
const successRate = totalCasts > 0 ? ((successCount / totalCasts) * 100).toFixed(1) : '0.0';
const rarityBreakdown = [
`${profile?.commonCatchCount ?? 0}`,
`🟢 ${profile?.uncommonCatchCount ?? 0}`,
`🔵 ${profile?.rareCatchCount ?? 0}`,
`🟣 ${profile?.epicCatchCount ?? 0}`,
`🟠 ${profile?.legendaryCatchCount ?? 0}`,
].join('\n');
const embed = new EmbedBuilder()
.setColor(0x3b82f6)
.setTitle(t(locale, 'commands.fishing.profileTitle', { user: targetUser.username }))
.setThumbnail(targetUser.displayAvatarURL())
.addFields(
{
name: t(locale, 'commands.fishing.totalCasts'),
value: String(totalCasts),
inline: true,
},
{
name: t(locale, 'commands.fishing.successRate'),
value: `${successRate}% (${successCount}/${successCount + failCount})`,
inline: true,
},
{
name: t(locale, 'commands.fishing.totalGoldEarned'),
value: `${totalGoldEarned} G`,
inline: true,
},
{
name: t(locale, 'commands.fishing.bestCatchReward'),
value: `${bestCatchReward} G`,
inline: true,
},
{
name: t(locale, 'commands.fishing.lastCastAt'),
value: profile?.lastCastAt
? `<t:${Math.floor(profile.lastCastAt.getTime() / 1000)}:R>`
: t(locale, 'commands.fishing.noRecord'),
inline: true,
},
{
name: t(locale, 'commands.fishing.rarityBreakdown'),
value: rarityBreakdown,
inline: false,
},
);
if (!profile) {
embed.setDescription(t(locale, 'commands.fishing.profileEmpty'));
}
await interaction.reply({ embeds: [embed], ephemeral: true });
return;
}
if (subcommand === 'dex') {
const targetUser = interaction.options.getUser('user') ?? interaction.user;
const collection = await FishingService.getCollection(targetUser.id, interaction.guildId);
const embed = new EmbedBuilder()
.setColor(0x14b8a6)
.setTitle(t(locale, 'commands.fishing.dexTitle', { user: targetUser.username }))
.setThumbnail(targetUser.displayAvatarURL());
if (!collection.length) {
embed.setDescription(t(locale, 'commands.fishing.dexEmpty'));
await interaction.reply({ embeds: [embed], ephemeral: true });
return;
}
for (const entry of collection.slice(0, 10)) {
const fishName = FishingService.getFishDisplayName(entry.fishId);
const rarityName = FishingService.getRarityDisplayNameById(entry.bestRarityId, locale);
embed.addFields({
name: fishName,
value: [
`${t(locale, 'commands.fishing.catchCount')}: ${entry.catchCount}`,
`${t(locale, 'commands.fishing.bestRarity')}: ${rarityName}`,
`${t(locale, 'commands.fishing.bestSize')}: ${entry.bestSizeCm.toFixed(1)} cm`,
].join('\n'),
inline: true,
});
}
await interaction.reply({ embeds: [embed], ephemeral: true });
return;
}
if (subcommand === 'ranking') {
const ranking = await FishingService.getSizeRanking(interaction.guildId);
const embed = new EmbedBuilder()
.setColor(0xf59e0b)
.setTitle(t(locale, 'commands.fishing.rankingTitle'));
if (!ranking.length) {
embed.setDescription(t(locale, 'commands.fishing.rankingEmpty'));
await interaction.reply({ embeds: [embed], ephemeral: true });
return;
}
for (const [index, entry] of ranking.entries()) {
const fishName = FishingService.getFishDisplayName(entry.fishId);
const rarityName = FishingService.getRarityDisplayNameById(entry.bestRarityId, locale);
embed.addFields({
name: `#${index + 1} <@${entry.userId}>`,
value: [
`${t(locale, 'commands.fishing.targetFish')}: ${fishName}`,
`${t(locale, 'commands.fishing.rarity')}: ${rarityName}`,
`${t(locale, 'commands.fishing.size')}: ${entry.bestSizeCm.toFixed(1)} cm`,
].join('\n'),
inline: false,
});
}
await interaction.reply({ embeds: [embed], ephemeral: true });
} }
}, },
}; };

View File

@ -11,8 +11,6 @@ export const env = {
DISCORD_TOKEN: process.env.DISCORD_TOKEN || '', DISCORD_TOKEN: process.env.DISCORD_TOKEN || '',
DISCORD_CLIENT_ID: process.env.DISCORD_CLIENT_ID || '', DISCORD_CLIENT_ID: process.env.DISCORD_CLIENT_ID || '',
DATABASE_URL: process.env.DATABASE_URL || '', DATABASE_URL: process.env.DATABASE_URL || '',
REDIS_HOST: process.env.REDIS_HOST || 'localhost',
REDIS_PORT: parseInt(process.env.REDIS_PORT || '6379', 10),
VOICE_WAITING_ROOM_ID: process.env.VOICE_WAITING_ROOM_ID || '', VOICE_WAITING_ROOM_ID: process.env.VOICE_WAITING_ROOM_ID || '',
VOICE_CATEGORY_ID: process.env.VOICE_CATEGORY_ID || '', VOICE_CATEGORY_ID: process.env.VOICE_CATEGORY_ID || '',
INSTANCE_ID: generateInstanceId(), INSTANCE_ID: generateInstanceId(),

226
src/core/command.ts Normal file
View File

@ -0,0 +1,226 @@
import {
SlashCommandBuilder,
SlashCommandOptionsOnlyBuilder,
SlashCommandSubcommandsOnlyBuilder,
ChatInputCommandInteraction,
} from 'discord.js';
/** Values returned from {@link SlashCommandBuilder} after chaining options or subcommands only. */
export type SlashCommandData =
| SlashCommandBuilder
| SlashCommandOptionsOnlyBuilder
| SlashCommandSubcommandsOnlyBuilder;
import { prisma } from '../database';
import { SupportedLocale } from '../i18n';
import { SubscriptionTier } from '@prisma/client';
/**
* · .
*
* - help , , , .
* - `Command` .
*/
export enum CommandTrait {
/** 음악 재생·대기열 등 */
Music = 'music',
/** 미니게임 */
Minigame = 'minigame',
/** 방송 연동·알림 등 */
Broadcast = 'broadcast',
/** 위에 해당하지 않는 일반 명령(설정·감사·음성 등) */
General = 'general',
}
/** {@link CommandTrait.General}을 제외한 특성은 `guild_payment` 플래그가 필요합니다. */
export function traitRequiresPayment(trait: CommandTrait): boolean {
return (
trait === CommandTrait.Music || trait === CommandTrait.Minigame || trait === CommandTrait.Broadcast
);
}
/**
* .
*
* @returns `true`. `reply` `false`.
*/
export async function ensureGuildPaidForTrait(
interaction: ChatInputCommandInteraction,
trait: CommandTrait,
): Promise<boolean> {
if (!traitRequiresPayment(trait)) {
return true;
}
const guildId = interaction.guildId;
if (!guildId) {
await interaction.reply({
content: '이 명령은 서버에서만 사용할 수 있습니다.',
ephemeral: true,
});
return false;
}
// Payment flags were replaced by subscription tiers.
// A guild is considered "paid" if it has an owner whose subscription tier is above FREE.
const ownership = await prisma.guildOwnership.findUnique({
where: { guildId },
include: { owner: true },
});
const paid = ownership?.owner?.tier != null && ownership.owner.tier !== SubscriptionTier.FREE;
if (!paid) {
await interaction.reply({
content: '결제가 되지않았습니다',
ephemeral: true,
});
return false;
}
return true;
}
/**
* `CommandLoader` .
*
* `src/handlers/CommandLoader.ts` `default` export가
* `data`( ) `execute`( ) ,
* `client.commands.set(command.data.name, command)` .
* , .
*
* `trait` {@link Command.toModule} , .
*/
export type CommandModule = {
data: SlashCommandData;
execute: (interaction: ChatInputCommandInteraction, locale: SupportedLocale) => Promise<void>;
trait?: CommandTrait;
};
/**
* .
*
* ** **
* 1. {@link trait} {@link CommandTrait} .
* 2. {@link define} `SlashCommandBuilder` ··· .
* 3. {@link handle} (·DB· ) .
* 4. `export default new MyCommand().toModule()` `toModule()`
* `commands/*.ts` (`trait` ).
*
* ** ** (`interactionCreate` `execute`)
* 1. `guildOnly === true` DM .
* 2. {@link CommandTrait.Music} / {@link CommandTrait.Minigame} / {@link CommandTrait.Broadcast}
* `guild_payment` `true` . ( ·`false` )
* 3. {@link beforeHandle} `false` ( ) {@link handle} .
* 4. {@link handle} .
*
* `events/interactionCreate.ts` resolve한 `command.execute(interaction, locale)` ,
* -only .
*/
export abstract class Command {
private cachedData: SlashCommandData | null = null;
/**
* . .
*
* : 음악 {@link CommandTrait.Music}, {@link CommandTrait.Minigame},
* {@link CommandTrait.Broadcast}, {@link CommandTrait.General}.
*/
protected abstract readonly trait: CommandTrait;
/**
* `true` **() ** .
*
* DM이나 {@link handle}
* (ephemeral) return .
* (·· ) .
*/
protected guildOnly = false;
/**
* .
*
* {@link define} .
* `data` .
*/
get data(): SlashCommandData {
if (!this.cachedData) {
this.cachedData = this.define();
}
return this.cachedData;
}
/**
* (, , , , , `setDefaultMemberPermissions` )
* `SlashCommandBuilder` .
*/
protected abstract define(): SlashCommandData;
/**
* .
*
* `interaction` , `locale` / resolve된 .
*/
protected abstract handle(
interaction: ChatInputCommandInteraction,
locale: SupportedLocale,
): Promise<void>;
/**
* {@link handle} .
*
* , rate limit, ** **
* .
*
* @returns `true` {@link handle} .
* `false` ** `reply`/`deferReply` **
* `handle` .
*/
protected async beforeHandle(
_interaction: ChatInputCommandInteraction,
_locale: SupportedLocale,
): Promise<boolean> {
return true;
}
/**
* / .
*
* -only {@link ensureGuildPaidForTrait} `beforeHandle` `handle` .
* .
*/
async execute(
interaction: ChatInputCommandInteraction,
locale: SupportedLocale,
): Promise<void> {
if (this.guildOnly && !interaction.inGuild()) {
await interaction.reply({
content: 'This command can only be used in a server.',
ephemeral: true,
});
return;
}
if (!(await ensureGuildPaidForTrait(interaction, this.trait))) {
return;
}
if (!(await this.beforeHandle(interaction, locale))) {
return;
}
await this.handle(interaction, locale);
}
/**
* `CommandModule` `default export` .
*
* `execute` , `this` .
*/
toModule(): CommandModule {
return {
data: this.data,
execute: (interaction, locale) => this.execute(interaction, locale),
trait: this.trait,
};
}
}

49
src/core/db.ts Normal file
View File

@ -0,0 +1,49 @@
import { Prisma, PrismaClient } from '@prisma/client';
import { prisma } from '../database';
export type DbClient = PrismaClient;
export type TxClient = Prisma.TransactionClient;
function isRootClient(client: DbClient | TxClient): client is DbClient {
return typeof (client as DbClient).$transaction === 'function';
}
/**
* Runs `fn` inside a DB transaction.
*
* - If `fn` throws/rejects, **all operations are rolled back**.
* - Prefer this over array-based transactions when you need multiple steps
* (reads + conditional writes) to be atomic.
*/
export async function transaction<T>(
fn: (tx: TxClient) => Promise<T>,
options?: {
maxWait?: number;
timeout?: number;
isolationLevel?: Prisma.TransactionIsolationLevel;
},
): Promise<T> {
return prisma.$transaction(fn, options);
}
/**
* Utility to support both "already in a transaction" and "start a new one".
*
* If `client` is a root PrismaClient, it starts a transaction.
* If `client` is already a TransactionClient, it reuses it.
*/
export async function withTransaction<T>(
client: DbClient | TxClient,
fn: (tx: TxClient) => Promise<T>,
options?: {
maxWait?: number;
timeout?: number;
isolationLevel?: Prisma.TransactionIsolationLevel;
},
): Promise<T> {
if (isRootClient(client)) {
return client.$transaction(fn, options);
}
return fn(client);
}

View File

@ -6,7 +6,7 @@ import { VoiceService } from '../services/VoiceService';
import { PresenceService } from '../services/PresenceService'; import { PresenceService } from '../services/PresenceService';
import { EventService } from '../services/EventService'; import { EventService } from '../services/EventService';
import { auditLogService } from '../services/AuditLogService'; import { auditLogService } from '../services/AuditLogService';
import { redis } from '../cache'; import { tryAcquireLock } from '../cache';
import { env } from '../config/env'; import { env } from '../config/env';
export default { export default {
@ -22,7 +22,7 @@ export default {
try { try {
const lockKey = 'commands:sync:lock'; const lockKey = 'commands:sync:lock';
// EX 300 = 5 minutes lock. Only one instance needs to do this per boot cycle. // EX 300 = 5 minutes lock. Only one instance needs to do this per boot cycle.
const acquired = await redis.set(lockKey, '1', 'EX', 300, 'NX'); const acquired = await tryAcquireLock(lockKey, 300);
if (acquired) { if (acquired) {
const commandsData = Array.from(client.commands.values()).map(c => c.data.toJSON()); const commandsData = Array.from(client.commands.values()).map(c => c.data.toJSON());

View File

@ -263,6 +263,9 @@ export const en: TranslationSchema = {
enterDescription: 'Create or reopen your fishing thread.', enterDescription: 'Create or reopen your fishing thread.',
castDescription: 'Start a fishing session inside your fishing thread.', castDescription: 'Start a fishing session inside your fishing thread.',
endDescription: 'End your fishing thread and delete it.', endDescription: 'End your fishing thread and delete it.',
statusDescription: 'View fishing statistics.',
dexDescription: 'View your fishing collection book.',
rankingDescription: 'View the biggest fish size ranking in this server.',
disabled: 'The fishing mini-game is disabled in this server.', disabled: 'The fishing mini-game is disabled in this server.',
restrictedChannel: 'Fishing can only be started in {{channel}}.', restrictedChannel: 'Fishing can only be started in {{channel}}.',
enterTextChannelOnly: 'Fishing threads can only be opened from a regular text channel.', enterTextChannelOnly: 'Fishing threads can only be opened from a regular text channel.',
@ -275,17 +278,34 @@ export const en: TranslationSchema = {
ownerOnly: 'Only the owner of this fishing session can use these controls.', ownerOnly: 'Only the owner of this fishing session can use these controls.',
wrongThread: 'This fishing control can only be used inside your fishing thread.', wrongThread: 'This fishing control can only be used inside your fishing thread.',
endDeleted: 'Your fishing thread has been closed and is being deleted.', endDeleted: 'Your fishing thread has been closed and is being deleted.',
profileTitle: '{{user}} Fishing Profile',
profileEmpty: 'There is no fishing record yet.',
dexTitle: '{{user}} Fishing Dex',
dexEmpty: 'There are no discovered fish yet.',
rankingTitle: 'Fishing Size Ranking',
rankingEmpty: 'There are no fishing records in this server yet.',
titleActive: 'Fishing Session', titleActive: 'Fishing Session',
titleEnded: 'Fishing Session Ended', titleEnded: 'Fishing Session Ended',
status: 'Status', status: 'Status',
rarity: 'Rarity', rarity: 'Rarity',
size: 'Size',
catchCount: 'Catch Count',
bestRarity: 'Best Rarity',
bestSize: 'Best Size',
targetFish: 'Target Fish', targetFish: 'Target Fish',
distance: 'Distance', distance: 'Distance',
tension: 'Line Tension', tension: 'Line Tension',
reward: 'Reward', reward: 'Reward',
successRate: 'Success Rate',
totalCasts: 'Total Casts',
totalGoldEarned: 'Total Gold',
bestCatchReward: 'Best Reward',
rarityBreakdown: 'Rarity Breakdown',
lastCastAt: 'Last Cast',
noRecord: 'No record',
threadHint: 'Use /fishing cast to play again, or /fishing end to delete the thread.', threadHint: 'Use /fishing cast to play again, or /fishing end to delete the thread.',
catchResultTitle: 'Big Catch!', catchResultTitle: 'Big Catch!',
catchResultBody: 'You caught a **{{rarity}} {{fish}}** and earned **{{reward}} G**.', catchResultBody: 'You caught a **{{rarity}} {{fish}}** measuring **{{sizeCm}} cm** and earned **{{reward}} G**.',
states: { states: {
hooked: 'Hooked', hooked: 'Hooked',
resting: 'Resting', resting: 'Resting',

View File

@ -263,6 +263,9 @@ export const ko: TranslationSchema = {
enterDescription: '낚시 전용 스레드를 생성하거나 다시 엽니다.', enterDescription: '낚시 전용 스레드를 생성하거나 다시 엽니다.',
castDescription: '자신의 낚시 스레드 안에서 낚시 세션을 시작합니다.', castDescription: '자신의 낚시 스레드 안에서 낚시 세션을 시작합니다.',
endDescription: '낚시 스레드를 종료하고 삭제합니다.', endDescription: '낚시 스레드를 종료하고 삭제합니다.',
statusDescription: '낚시 통계를 확인합니다.',
dexDescription: '낚시 도감을 확인합니다.',
rankingDescription: '이 서버의 물고기 크기 랭킹을 확인합니다.',
disabled: '이 서버에서는 낚시 미니게임이 비활성화되어 있습니다.', disabled: '이 서버에서는 낚시 미니게임이 비활성화되어 있습니다.',
restrictedChannel: '낚시는 {{channel}} 채널에서만 시작할 수 있습니다.', restrictedChannel: '낚시는 {{channel}} 채널에서만 시작할 수 있습니다.',
enterTextChannelOnly: '낚시 스레드는 일반 텍스트 채널에서만 열 수 있습니다.', enterTextChannelOnly: '낚시 스레드는 일반 텍스트 채널에서만 열 수 있습니다.',
@ -275,17 +278,34 @@ export const ko: TranslationSchema = {
ownerOnly: '이 낚시 세션의 소유자만 조작할 수 있습니다.', ownerOnly: '이 낚시 세션의 소유자만 조작할 수 있습니다.',
wrongThread: '이 조작은 자신의 낚시 스레드 안에서만 사용할 수 있습니다.', wrongThread: '이 조작은 자신의 낚시 스레드 안에서만 사용할 수 있습니다.',
endDeleted: '낚시 스레드를 종료했습니다. 스레드를 삭제합니다.', endDeleted: '낚시 스레드를 종료했습니다. 스레드를 삭제합니다.',
profileTitle: '{{user}}의 낚시 프로필',
profileEmpty: '아직 낚시 기록이 없습니다.',
dexTitle: '{{user}}의 낚시 도감',
dexEmpty: '아직 발견한 물고기가 없습니다.',
rankingTitle: '낚시 크기 랭킹',
rankingEmpty: '아직 이 서버에 낚시 기록이 없습니다.',
titleActive: '낚시 세션', titleActive: '낚시 세션',
titleEnded: '낚시 세션 종료', titleEnded: '낚시 세션 종료',
status: '상태', status: '상태',
rarity: '레어도', rarity: '레어도',
size: '크기',
catchCount: '포획 수',
bestRarity: '최고 레어도',
bestSize: '최고 크기',
targetFish: '대상 물고기', targetFish: '대상 물고기',
distance: '거리', distance: '거리',
tension: '끊어짐 게이지', tension: '끊어짐 게이지',
reward: '보상', reward: '보상',
successRate: '성공률',
totalCasts: '총 시도',
totalGoldEarned: '누적 골드',
bestCatchReward: '최고 보상',
rarityBreakdown: '레어도별 포획',
lastCastAt: '최근 낚시',
noRecord: '기록 없음',
threadHint: '/fishing cast로 다시 시작하거나 /fishing end로 스레드를 삭제할 수 있습니다.', threadHint: '/fishing cast로 다시 시작하거나 /fishing end로 스레드를 삭제할 수 있습니다.',
catchResultTitle: '낚시 성공!', catchResultTitle: '낚시 성공!',
catchResultBody: '**{{rarity}} {{fish}}**를 낚았습니다. **{{reward}} G**를 획득했습니다.', catchResultBody: '**{{rarity}} {{fish}}**를 낚았습니다. 크기는 **{{sizeCm}} cm**, 보상은 **{{reward}} G**입니다.',
states: { states: {
hooked: '입질 중', hooked: '입질 중',
resting: '휴식 중', resting: '휴식 중',

View File

@ -1,4 +1,4 @@
/** /**
* i18n Type Definitions & Interfaces for Kord Bot. * i18n Type Definitions & Interfaces for Kord Bot.
* *
* Designed with provider interface pattern for future infrastructure swap * Designed with provider interface pattern for future infrastructure swap
@ -217,6 +217,9 @@ export interface TranslationSchema {
enterDescription: string; enterDescription: string;
castDescription: string; castDescription: string;
endDescription: string; endDescription: string;
statusDescription: string;
dexDescription: string;
rankingDescription: string;
disabled: string; disabled: string;
restrictedChannel: string; restrictedChannel: string;
enterTextChannelOnly: string; enterTextChannelOnly: string;
@ -229,14 +232,31 @@ export interface TranslationSchema {
ownerOnly: string; ownerOnly: string;
wrongThread: string; wrongThread: string;
endDeleted: string; endDeleted: string;
profileTitle: string;
profileEmpty: string;
dexTitle: string;
dexEmpty: string;
rankingTitle: string;
rankingEmpty: string;
titleActive: string; titleActive: string;
titleEnded: string; titleEnded: string;
status: string; status: string;
rarity: string; rarity: string;
size: string;
catchCount: string;
bestRarity: string;
bestSize: string;
targetFish: string; targetFish: string;
distance: string; distance: string;
tension: string; tension: string;
reward: string; reward: string;
successRate: string;
totalCasts: string;
totalGoldEarned: string;
bestCatchReward: string;
rarityBreakdown: string;
lastCastAt: string;
noRecord: string;
threadHint: string; threadHint: string;
catchResultTitle: string; catchResultTitle: string;
catchResultBody: string; catchResultBody: string;

223
src/service/command.ts Normal file
View File

@ -0,0 +1,223 @@
import {
SlashCommandBuilder,
SlashCommandOptionsOnlyBuilder,
SlashCommandSubcommandsOnlyBuilder,
ChatInputCommandInteraction,
} from 'discord.js';
/** Values returned from {@link SlashCommandBuilder} after chaining options or subcommands only. */
export type SlashCommandData =
| SlashCommandBuilder
| SlashCommandOptionsOnlyBuilder
| SlashCommandSubcommandsOnlyBuilder;
import { prisma } from '../database';
import { SupportedLocale } from '../i18n';
/**
* · .
*
* - help , , , .
* - `Command` .
*/
export enum CommandTrait {
/** 음악 재생·대기열 등 */
Music = 'music',
/** 미니게임 */
Minigame = 'minigame',
/** 방송 연동·알림 등 */
Broadcast = 'broadcast',
/** 위에 해당하지 않는 일반 명령(설정·감사·음성 등) */
General = 'general',
}
/** {@link CommandTrait.General}을 제외한 특성은 `guild_payment` 플래그가 필요합니다. */
export function traitRequiresPayment(trait: CommandTrait): boolean {
return (
trait === CommandTrait.Music || trait === CommandTrait.Minigame || trait === CommandTrait.Broadcast
);
}
/**
* .
*
* @returns `true`. `reply` `false`.
*/
export async function ensureGuildPaidForTrait(
interaction: ChatInputCommandInteraction,
trait: CommandTrait,
): Promise<boolean> {
if (!traitRequiresPayment(trait)) {
return true;
}
const guildId = interaction.guildId;
if (!guildId) {
await interaction.reply({
content: '이 명령은 서버에서만 사용할 수 있습니다.',
ephemeral: true,
});
return false;
}
const row = await (prisma as any).guildPayment.findUnique({ where: { id: guildId } });
const paid =
row != null &&
((trait === CommandTrait.Music && row.music) ||
(trait === CommandTrait.Minigame && row.minigame) ||
(trait === CommandTrait.Broadcast && row.broadcast));
if (!paid) {
await interaction.reply({
content: '결제가 되지않았습니다',
ephemeral: true,
});
return false;
}
return true;
}
/**
* `CommandLoader` .
*
* `src/handlers/CommandLoader.ts` `default` export가
* `data`( ) `execute`( ) ,
* `client.commands.set(command.data.name, command)` .
* , .
*
* `trait` {@link Command.toModule} , .
*/
export type CommandModule = {
data: SlashCommandData;
execute: (interaction: ChatInputCommandInteraction, locale: SupportedLocale) => Promise<void>;
trait?: CommandTrait;
};
/**
* .
*
* ** **
* 1. {@link trait} {@link CommandTrait} .
* 2. {@link define} `SlashCommandBuilder` ··· .
* 3. {@link handle} (·DB· ) .
* 4. `export default new MyCommand().toModule()` `toModule()`
* `commands/*.ts` (`trait` ).
*
* ** ** (`interactionCreate` `execute`)
* 1. `guildOnly === true` DM .
* 2. {@link CommandTrait.Music} / {@link CommandTrait.Minigame} / {@link CommandTrait.Broadcast}
* `guild_payment` `true` . ( ·`false` )
* 3. {@link beforeHandle} `false` ( ) {@link handle} .
* 4. {@link handle} .
*
* `events/interactionCreate.ts` resolve한 `command.execute(interaction, locale)` ,
* -only .
*/
export abstract class Command {
private cachedData: SlashCommandData | null = null;
/**
* . .
*
* : 음악 {@link CommandTrait.Music}, {@link CommandTrait.Minigame},
* {@link CommandTrait.Broadcast}, {@link CommandTrait.General}.
*/
protected abstract readonly trait: CommandTrait;
/**
* `true` **() ** .
*
* DM이나 {@link handle}
* (ephemeral) return .
* (·· ) .
*/
protected guildOnly = false;
/**
* .
*
* {@link define} .
* `data` .
*/
get data(): SlashCommandData {
if (!this.cachedData) {
this.cachedData = this.define();
}
return this.cachedData;
}
/**
* (, , , , , `setDefaultMemberPermissions` )
* `SlashCommandBuilder` .
*/
protected abstract define(): SlashCommandData;
/**
* .
*
* `interaction` , `locale` / resolve된 .
*/
protected abstract handle(
interaction: ChatInputCommandInteraction,
locale: SupportedLocale,
): Promise<void>;
/**
* {@link handle} .
*
* , rate limit, ** **
* .
*
* @returns `true` {@link handle} .
* `false` ** `reply`/`deferReply` **
* `handle` .
*/
protected async beforeHandle(
_interaction: ChatInputCommandInteraction,
_locale: SupportedLocale,
): Promise<boolean> {
return true;
}
/**
* / .
*
* -only {@link ensureGuildPaidForTrait} `beforeHandle` `handle` .
* .
*/
async execute(
interaction: ChatInputCommandInteraction,
locale: SupportedLocale,
): Promise<void> {
if (this.guildOnly && !interaction.inGuild()) {
await interaction.reply({
content: 'This command can only be used in a server.',
ephemeral: true,
});
return;
}
if (!(await ensureGuildPaidForTrait(interaction, this.trait))) {
return;
}
if (!(await this.beforeHandle(interaction, locale))) {
return;
}
await this.handle(interaction, locale);
}
/**
* `CommandModule` `default export` .
*
* `execute` , `this` .
*/
toModule(): CommandModule {
return {
data: this.data,
execute: (interaction, locale) => this.execute(interaction, locale),
trait: this.trait,
};
}
}

83
src/service/test.ts Normal file
View File

@ -0,0 +1,83 @@
/**
* `Command` ().
*
* `handlers/CommandLoader` `src/commands/`
* .
* `src/commands/your-command.ts` `setName` .
*
* {@link CommandTrait.Music} `guild_payment.music === true`
* ( ).
*/
import {
SlashCommandBuilder,
ChatInputCommandInteraction,
SlashCommandStringOption,
} from 'discord.js';
import { Command, CommandTrait } from './command';
import { SupportedLocale } from '../i18n';
class ExampleSlashCommand extends Command {
/** 음악 유료 특성 — DB `guild_payment.music` 플래그를 검사합니다. */
protected readonly trait = CommandTrait.Music;
/** 길드에서만 쓰도록 기본 가드 사용 */
protected guildOnly = true;
protected define() {
return (
new SlashCommandBuilder()
.setName('command_usage_demo')
.setDescription('Example: Command base class usage (not registered from this path).')
.setDescriptionLocalizations({
ko: '예시: Command 베이스 클래스 사용법 (이 경로에서는 등록되지 않음).',
})
// 서브커맨드/옵션은 기존과 같이 붙이면 됩니다.
.addStringOption((option: SlashCommandStringOption) =>
option
.setName('message')
.setDescription('Echo text')
.setRequired(false),
)
);
}
/**
* `beforeHandle` .
* `false` `handle` .
*/
protected async beforeHandle(
interaction: ChatInputCommandInteraction,
_locale: SupportedLocale,
): Promise<boolean> {
const msg = interaction.options.getString('message');
if (msg === 'block') {
await interaction.reply({ content: 'Blocked by beforeHandle.', ephemeral: true });
return false;
}
return true;
}
protected async handle(
interaction: ChatInputCommandInteraction,
locale: SupportedLocale,
): Promise<void> {
const message = interaction.options.getString('message');
const guildName = interaction.guild?.name ?? 'unknown';
const line =
message != null && message.length > 0
? `[${locale}] **${guildName}** — ${message}`
: `[${locale}] **${guildName}** — (no message option)`;
await interaction.reply({ content: line, ephemeral: true });
}
}
/**
* `commands/*.ts` :
*
* ```ts
* export default new ExampleSlashCommand().toModule();
* ```
*/
export default new ExampleSlashCommand().toModule();

View File

@ -161,7 +161,7 @@ export class EventService {
const diff = event.startsAt.getTime() - now.getTime(); const diff = event.startsAt.getTime() - now.getTime();
const dueOffsets = event.reminderOffsets.filter(offset => const dueOffsets = event.reminderOffsets.filter((offset: number) =>
offset > 0 && offset > 0 &&
!event.sentReminderOffsets.includes(offset) && !event.sentReminderOffsets.includes(offset) &&
diff <= offset * 60 * 1000 && diff <= offset * 60 * 1000 &&

View File

@ -15,6 +15,7 @@
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import sharp from 'sharp'; import sharp from 'sharp';
import { prisma } from '../database';
import { SupportedLocale, t } from '../i18n'; import { SupportedLocale, t } from '../i18n';
import { RefinementService } from './RefinementService'; import { RefinementService } from './RefinementService';
import { logger } from '../utils/logger'; import { logger } from '../utils/logger';
@ -33,6 +34,7 @@ interface FishingCatalogEntry {
displayName: string; displayName: string;
spawnRate: number; spawnRate: number;
rewardGold: FishingRange; rewardGold: FishingRange;
sizeCm: FishingRange;
reactionWindowSec: number; reactionWindowSec: number;
distanceReductionByPosition: Record<FishingDirection, FishingRange>; distanceReductionByPosition: Record<FishingDirection, FishingRange>;
artResourcePaths: string[]; artResourcePaths: string[];
@ -50,6 +52,10 @@ interface FishingRarityEntry {
rewardMultiplier: number; rewardMultiplier: number;
reactionWindowMultiplier: number; reactionWindowMultiplier: number;
tensionMultiplier: number; tensionMultiplier: number;
sizeMultiplier: {
min: number;
max: number;
};
backgroundColor: string; backgroundColor: string;
} }
@ -74,6 +80,7 @@ interface FishingSession {
lineTension: number; lineTension: number;
status: FishingState; status: FishingState;
reward: number | null; reward: number | null;
catchSizeCm: number | null;
tickInterval: NodeJS.Timeout | null; tickInterval: NodeJS.Timeout | null;
isRendering: boolean; isRendering: boolean;
needsRender: boolean; needsRender: boolean;
@ -217,6 +224,55 @@ export class FishingService {
await this.queueAction(session, action as FishingAction); await this.queueAction(session, action as FishingAction);
} }
static async getProfile(userId: string, guildId: string) {
return (prisma as any).fishingProfile.findUnique({
where: {
userId_guildId: { userId, guildId },
},
});
}
static async getCollection(userId: string, guildId: string) {
return (prisma as any).fishingCollectionEntry.findMany({
where: {
userId,
guildId,
},
orderBy: [
{ bestRarityRank: 'desc' },
{ bestSizeCm: 'desc' },
{ catchCount: 'desc' },
],
});
}
static async getSizeRanking(guildId: string) {
return (prisma as any).fishingCollectionEntry.findMany({
where: {
guildId,
},
orderBy: [
{ bestSizeCm: 'desc' },
{ bestRarityRank: 'desc' },
{ catchCount: 'desc' },
{ lastCaughtAt: 'asc' },
],
take: 10,
});
}
static getFishDisplayName(fishId: string) {
return this.fishingCatalog.find((fish) => fish.id === fishId)?.displayName ?? fishId;
}
static getRarityDisplayNameById(rarityId: string, locale: SupportedLocale) {
const rarity = this.fishingRarities.find((entry) => entry.id === rarityId);
if (!rarity) {
return rarityId;
}
return this.getRarityDisplayName(rarity, locale);
}
private static async tickSession(session: FishingSession) { private static async tickSession(session: FishingSession) {
if (!this.sessionsByUser.has(this.getUserKey(session.guildId, session.userId))) { if (!this.sessionsByUser.has(this.getUserKey(session.guildId, session.userId))) {
this.clearTick(session); this.clearTick(session);
@ -272,6 +328,7 @@ export class FishingService {
Math.round(this.rollRange(session.currentFish.rewardGold) * session.currentRarity.rewardMultiplier), Math.round(this.rollRange(session.currentFish.rewardGold) * session.currentRarity.rewardMultiplier),
); );
session.reward = reward; session.reward = reward;
session.catchSizeCm = this.rollCatchSizeCm(session.currentFish, session.currentRarity);
await RefinementService.addGold(session.userId, session.guildId, reward); await RefinementService.addGold(session.userId, session.guildId, reward);
await this.finishSession(session, 'success', false); await this.finishSession(session, 'success', false);
return; return;
@ -316,6 +373,8 @@ export class FishingService {
logger.info(`[Fishing] Finished session for ${session.userId} with state ${finalState}.`); logger.info(`[Fishing] Finished session for ${session.userId} with state ${finalState}.`);
await this.recordProfileResult(session, finalState);
await this.renderSession(session, true); await this.renderSession(session, true);
if (finalState === 'success') { if (finalState === 'success') {
@ -480,6 +539,7 @@ export class FishingService {
lineTension: 0, lineTension: 0,
status: 'hooked' as FishingState, status: 'hooked' as FishingState,
reward: null, reward: null,
catchSizeCm: null,
tickInterval: null, tickInterval: null,
isRendering: false, isRendering: false,
needsRender: false, needsRender: false,
@ -553,6 +613,7 @@ export class FishingService {
t(session.locale, 'commands.fishing.catchResultBody', { t(session.locale, 'commands.fishing.catchResultBody', {
rarity: rarityName, rarity: rarityName,
fish: session.currentFish.displayName, fish: session.currentFish.displayName,
sizeCm: (session.catchSizeCm ?? 0).toFixed(1),
reward: String(session.reward ?? 0), reward: String(session.reward ?? 0),
}), }),
) )
@ -560,6 +621,11 @@ export class FishingService {
name: t(session.locale, 'commands.fishing.rarity'), name: t(session.locale, 'commands.fishing.rarity'),
value: rarityName, value: rarityName,
inline: true, inline: true,
})
.addFields({
name: t(session.locale, 'commands.fishing.size'),
value: `${(session.catchSizeCm ?? 0).toFixed(1)} cm`,
inline: true,
}); });
if (artPath && fs.existsSync(artPath)) { if (artPath && fs.existsSync(artPath)) {
@ -699,6 +765,111 @@ export class FishingService {
return locale === 'ko' && rarity.displayNameKo ? rarity.displayNameKo : rarity.displayName; return locale === 'ko' && rarity.displayNameKo ? rarity.displayNameKo : rarity.displayName;
} }
private static async recordProfileResult(session: FishingSession, finalState: 'success' | 'failed') {
const reward = session.reward ?? 0;
const rarityField = this.getRarityCountField(session.currentRarity.id);
const existingProfile = await this.getProfile(session.userId, session.guildId);
await (prisma as any).fishingProfile.upsert({
where: {
userId_guildId: {
userId: session.userId,
guildId: session.guildId,
},
},
create: {
userId: session.userId,
guildId: session.guildId,
totalCastCount: 1,
successCount: finalState === 'success' ? 1 : 0,
failCount: finalState === 'failed' ? 1 : 0,
totalGoldEarned: reward,
bestCatchReward: reward,
commonCatchCount: rarityField === 'commonCatchCount' && finalState === 'success' ? 1 : 0,
uncommonCatchCount: rarityField === 'uncommonCatchCount' && finalState === 'success' ? 1 : 0,
rareCatchCount: rarityField === 'rareCatchCount' && finalState === 'success' ? 1 : 0,
epicCatchCount: rarityField === 'epicCatchCount' && finalState === 'success' ? 1 : 0,
legendaryCatchCount: rarityField === 'legendaryCatchCount' && finalState === 'success' ? 1 : 0,
lastCastAt: new Date(),
},
update: {
totalCastCount: { increment: 1 },
successCount: finalState === 'success' ? { increment: 1 } : undefined,
failCount: finalState === 'failed' ? { increment: 1 } : undefined,
totalGoldEarned: reward > 0 ? { increment: reward } : undefined,
bestCatchReward: reward > 0 ? Math.max(reward, existingProfile?.bestCatchReward ?? 0) : undefined,
[rarityField]: finalState === 'success' ? { increment: 1 } : undefined,
lastCastAt: new Date(),
},
});
if (finalState === 'success') {
await this.recordCollectionCatch(session);
}
}
private static async recordCollectionCatch(session: FishingSession) {
const rarityRank = this.getRarityRank(session.currentRarity.id);
const existingEntry = await (prisma as any).fishingCollectionEntry.findUnique({
where: {
userId_guildId_fishId: {
userId: session.userId,
guildId: session.guildId,
fishId: session.currentFish.id,
},
},
});
const bestRarityRank = Math.max(existingEntry?.bestRarityRank ?? 0, rarityRank);
const bestRarityId = bestRarityRank === rarityRank
? session.currentRarity.id
: existingEntry?.bestRarityId ?? session.currentRarity.id;
const bestSizeCm = Math.max(existingEntry?.bestSizeCm ?? 0, session.catchSizeCm ?? 0);
await (prisma as any).fishingCollectionEntry.upsert({
where: {
userId_guildId_fishId: {
userId: session.userId,
guildId: session.guildId,
fishId: session.currentFish.id,
},
},
create: {
userId: session.userId,
guildId: session.guildId,
fishId: session.currentFish.id,
catchCount: 1,
bestRarityId: session.currentRarity.id,
bestRarityRank: rarityRank,
bestSizeCm: session.catchSizeCm ?? 0,
lastCaughtAt: new Date(),
},
update: {
catchCount: { increment: 1 },
bestRarityId,
bestRarityRank,
bestSizeCm,
lastCaughtAt: new Date(),
},
});
}
private static getRarityCountField(rarityId: string) {
if (rarityId === 'legendary') return 'legendaryCatchCount';
if (rarityId === 'epic') return 'epicCatchCount';
if (rarityId === 'rare') return 'rareCatchCount';
if (rarityId === 'uncommon') return 'uncommonCatchCount';
return 'commonCatchCount';
}
private static getRarityRank(rarityId: string) {
if (rarityId === 'legendary') return 5;
if (rarityId === 'epic') return 4;
if (rarityId === 'rare') return 3;
if (rarityId === 'uncommon') return 2;
return 1;
}
private static getRarityBadge(rarityId: string) { private static getRarityBadge(rarityId: string) {
if (rarityId === 'legendary') return '🟠'; if (rarityId === 'legendary') return '🟠';
if (rarityId === 'epic') return '🟣'; if (rarityId === 'epic') return '🟣';
@ -735,6 +906,12 @@ export class FishingService {
return Number.parseInt(value.replace('#', ''), 16); return Number.parseInt(value.replace('#', ''), 16);
} }
private static rollCatchSizeCm(fish: FishingCatalogEntry, rarity: FishingRarityEntry) {
const base = this.rollDecimalRange(fish.sizeCm);
const multiplier = this.rollDecimalRange(rarity.sizeMultiplier);
return Math.round(base * multiplier * 10) / 10;
}
private static formatSelectedAction(action: FishingAction | null) { private static formatSelectedAction(action: FishingAction | null) {
if (action === 'left') return '⬅️'; if (action === 'left') return '⬅️';
if (action === 'center') return '⏺️'; if (action === 'center') return '⏺️';
@ -753,6 +930,14 @@ export class FishingService {
private static getUserKey(guildId: string, userId: string) { private static getUserKey(guildId: string, userId: string) {
return `${guildId}:${userId}`; return `${guildId}:${userId}`;
} }
private static rollDecimalRange(range: { min: number; max: number }) {
if (range.min === range.max) {
return range.min;
}
return range.min + Math.random() * (range.max - range.min);
}
} }
export function buildFishingGauge(current: number, max: number, width: number) { export function buildFishingGauge(current: number, max: number, width: number) {

View File

@ -1,5 +1,5 @@
import { Client, Guild, Invite, GuildMember } from 'discord.js'; import { Client, Guild, Invite, GuildMember } from 'discord.js';
import { redis } from '../cache'; import { cache } from '../cache';
import { prisma } from '../database'; import { prisma } from '../database';
import { logger } from '../utils/logger'; import { logger } from '../utils/logger';
@ -18,7 +18,7 @@ export class InviteService {
code: inv.code, code: inv.code,
uses: inv.uses || 0 uses: inv.uses || 0
})); }));
await redis.set(`invites:${guild.id}`, JSON.stringify(inviteData)); await cache.set(`invites:${guild.id}`, JSON.stringify(inviteData));
} catch (error) { } catch (error) {
logger.error(`InviteMaster: Failed to cache invites for guild ${guild.id}:`, error); logger.error(`InviteMaster: Failed to cache invites for guild ${guild.id}:`, error);
} }
@ -41,7 +41,7 @@ export class InviteService {
try { try {
// Fetch current active invites // Fetch current active invites
const newInvites = await guild.invites.fetch(); const newInvites = await guild.invites.fetch();
const cachedData = await redis.get(`invites:${guild.id}`); const cachedData = await cache.get(`invites:${guild.id}`);
let usedInvite: Invite | undefined; let usedInvite: Invite | undefined;

View File

@ -88,7 +88,7 @@ const FEATURE_DEFINITIONS: FeatureDefinition[] = [
], ],
resolveChannelIds: async (guildId) => { resolveChannelIds: async (guildId) => {
const generators = await prisma.voiceGenerator.findMany({ where: { guildId } }); const generators = await prisma.voiceGenerator.findMany({ where: { guildId } });
return generators.map((g) => g.channelId); return generators.map((g: { channelId: string }) => g.channelId);
}, },
}, },
@ -103,7 +103,9 @@ const FEATURE_DEFINITIONS: FeatureDefinition[] = [
], ],
resolveChannelIds: async (guildId) => { resolveChannelIds: async (guildId) => {
const generators = await prisma.voiceGenerator.findMany({ where: { guildId } }); const generators = await prisma.voiceGenerator.findMany({ where: { guildId } });
return generators.map((g) => g.categoryId).filter((id): id is string => id !== null); return generators
.map((g: { categoryId: string | null }) => g.categoryId)
.filter((id: string | null): id is string => id !== null);
}, },
}, },
@ -120,7 +122,7 @@ const FEATURE_DEFINITIONS: FeatureDefinition[] = [
scope: 'hierarchy', scope: 'hierarchy',
resolveTargetRoleIds: async (guildId) => { resolveTargetRoleIds: async (guildId) => {
const inviteRoles = await prisma.inviteRole.findMany({ where: { guildId } }); const inviteRoles = await prisma.inviteRole.findMany({ where: { guildId } });
return inviteRoles.map((ir) => ir.roleId); return inviteRoles.map((ir: { roleId: string }) => ir.roleId);
}, },
}, },

View File

@ -1,7 +1,7 @@
import { VoiceState, ChannelType, PermissionFlagsBits, VoiceChannel, ActionRowBuilder, StringSelectMenuBuilder, StringSelectMenuOptionBuilder, Client, GuildMember } from 'discord.js'; import { VoiceState, ChannelType, PermissionFlagsBits, VoiceChannel, ActionRowBuilder, StringSelectMenuBuilder, StringSelectMenuOptionBuilder, Client, GuildMember } from 'discord.js';
import { prisma } from '../database'; import { prisma } from '../database';
import { logger } from '../utils/logger'; import { logger } from '../utils/logger';
import { redis } from '../cache'; import { tryAcquireLock } from '../cache';
import { ErrorDefs } from '../errors/ErrorCodes'; import { ErrorDefs } from '../errors/ErrorCodes';
import { t, SupportedLocale, DEFAULT_LOCALE } from '../i18n'; import { t, SupportedLocale, DEFAULT_LOCALE } from '../i18n';
import { getContextLocale } from '../i18n/localeHelper'; import { getContextLocale } from '../i18n/localeHelper';
@ -10,8 +10,7 @@ import { auditLogService } from './AuditLogService';
export class VoiceService { export class VoiceService {
public static async syncChannels(client: Client) { public static async syncChannels(client: Client) {
const lockKey = 'voice:sync:lock'; const lockKey = 'voice:sync:lock';
// NX = only set if not exists, EX = expire in 60s const acquired = await tryAcquireLock(lockKey, 60);
const acquired = await redis.set(lockKey, '1', 'EX', 60, 'NX');
if (!acquired) { if (!acquired) {
logger.info('VoiceService: Another instance is already syncing channels. Skipping.'); logger.info('VoiceService: Another instance is already syncing channels. Skipping.');
return; return;
@ -55,9 +54,8 @@ export class VoiceService {
} }
} }
logger.info('VoiceService: Channel synchronization complete.'); logger.info('VoiceService: Channel synchronization complete.');
} finally { } catch (error) {
// Free the lock just in case, though EX ensures it doesn't hang forever logger.error('VoiceService: Failed during channel synchronization', error);
await redis.del(lockKey).catch(() => {});
} }
} }
public static async handleVoiceStateUpdate(oldState: VoiceState, newState: VoiceState) { public static async handleVoiceStateUpdate(oldState: VoiceState, newState: VoiceState) {

View File

@ -1,6 +1,6 @@
import { TextChannel, WebhookClient } from 'discord.js'; import { TextChannel, WebhookClient } from 'discord.js';
import { logger } from '../utils/logger'; import { logger } from '../utils/logger';
import { redis } from '../cache'; import { cache } from '../cache';
export class WebhookService { export class WebhookService {
private static readonly MAX_WEBHOOKS = 10; private static readonly MAX_WEBHOOKS = 10;
@ -9,7 +9,7 @@ export class WebhookService {
public static async getWebhookClient(channel: TextChannel): Promise<WebhookClient | null> { public static async getWebhookClient(channel: TextChannel): Promise<WebhookClient | null> {
try { try {
// 1. Check cache // 1. Check cache
const cachedData = await redis.get(`webhook:${channel.id}`); const cachedData = await cache.get(`webhook:${channel.id}`);
if (cachedData) { if (cachedData) {
const { id, token } = JSON.parse(cachedData); const { id, token } = JSON.parse(cachedData);
return new WebhookClient({ id, token }); return new WebhookClient({ id, token });
@ -40,14 +40,11 @@ export class WebhookService {
logger.info(`Created new webhook for channel ${channel.id}`); logger.info(`Created new webhook for channel ${channel.id}`);
} }
// 3. Save to Redis Cache (expire in 1 day to ensure token freshness) // 3. Save to cache (expire in 1 day to ensure token freshness)
if (kordWebhook.token) { if (kordWebhook.token) {
await redis.set( await cache.set(`webhook:${channel.id}`, JSON.stringify({ id: kordWebhook.id, token: kordWebhook.token }), {
`webhook:${channel.id}`, exSeconds: 86400,
JSON.stringify({ id: kordWebhook.id, token: kordWebhook.token }), });
'EX',
86400
);
return new WebhookClient({ id: kordWebhook.id, token: kordWebhook.token }); return new WebhookClient({ id: kordWebhook.id, token: kordWebhook.token });
} }

54
tests/core/db.test.ts Normal file
View File

@ -0,0 +1,54 @@
jest.mock('../../src/database', () => {
const tx = { __tx: true };
const prisma = {
$transaction: jest.fn(async (fn: (tx: unknown) => Promise<unknown>) => fn(tx)),
};
return { prisma };
});
import { prisma } from '../../src/database';
import { transaction, withTransaction } from '../../src/core/db';
describe('core/db transaction helpers', () => {
beforeEach(() => {
(prisma.$transaction as jest.Mock).mockClear();
});
test('transaction() executes callback via prisma.$transaction', async () => {
const result = await transaction(async (_tx) => {
return 123;
});
expect(result).toBe(123);
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
});
test('transaction() propagates error (rollback is handled by Prisma)', async () => {
await expect(
transaction(async () => {
throw new Error('boom');
}),
).rejects.toThrow('boom');
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
});
test('withTransaction() starts a new transaction for root client', async () => {
const rootClient = prisma as unknown as { $transaction: (fn: any) => Promise<any> };
const result = await withTransaction(rootClient as any, async (_tx) => 'ok');
expect(result).toBe('ok');
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
});
test('withTransaction() reuses existing tx client (does not nest)', async () => {
const txClient = {} as any; // does not have $transaction
const result = await withTransaction(txClient, async (_tx) => 'ok');
expect(result).toBe('ok');
expect(prisma.$transaction).toHaveBeenCalledTimes(0);
});
});

240
yarn.lock
View File

@ -573,30 +573,30 @@ __metadata:
linkType: hard linkType: hard
"@emnapi/core@npm:^1.4.3": "@emnapi/core@npm:^1.4.3":
version: 1.9.1 version: 1.9.2
resolution: "@emnapi/core@npm:1.9.1" resolution: "@emnapi/core@npm:1.9.2"
dependencies: dependencies:
"@emnapi/wasi-threads": "npm:1.2.0" "@emnapi/wasi-threads": "npm:1.2.1"
tslib: "npm:^2.4.0" tslib: "npm:^2.4.0"
checksum: 10c0/00e7a99a2bc3ad908ca8272ba861a934da87dffa8797a41316c4a3b571a1e4d2743e2fa14b1a0f131fa4a3c2018ddb601cd2a8cb7f574fa940af696df3c2fe8d checksum: 10c0/5500393f953951bad0768fafaa9191f2d938956b20c6d6a79e5ab696a613a25ce6ad23422bc18e86e6ce8deb147619d8d0d7d413a69f84adc01a6633cc353cd9
languageName: node languageName: node
linkType: hard linkType: hard
"@emnapi/runtime@npm:^1.4.3, @emnapi/runtime@npm:^1.7.0": "@emnapi/runtime@npm:^1.4.3, @emnapi/runtime@npm:^1.7.0":
version: 1.9.1 version: 1.9.2
resolution: "@emnapi/runtime@npm:1.9.1" resolution: "@emnapi/runtime@npm:1.9.2"
dependencies: dependencies:
tslib: "npm:^2.4.0" tslib: "npm:^2.4.0"
checksum: 10c0/750edca117e0363ab2de10622f8ee60e57d8690c2f29c49704813da5cd627c641798d7f3cb0d953c62fdc71688e02e333ddbf2c1204f38b47e3e40657332a6f5 checksum: 10c0/61c3a59e0c36784558b8d58eb02bd04815aa5fb0dbfbaf84d1b3050a78aa0cc63ea129ae806bd1e48062bfeb7fc36eb0e5431740d62f64ea51bdf426404b8caa
languageName: node languageName: node
linkType: hard linkType: hard
"@emnapi/wasi-threads@npm:1.2.0": "@emnapi/wasi-threads@npm:1.2.1":
version: 1.2.0 version: 1.2.1
resolution: "@emnapi/wasi-threads@npm:1.2.0" resolution: "@emnapi/wasi-threads@npm:1.2.1"
dependencies: dependencies:
tslib: "npm:^2.4.0" tslib: "npm:^2.4.0"
checksum: 10c0/1e3724b5814b06c14782fda87eee9b9aa68af01576c81ffeaefdf621ddb74386e419d5b3b1027b6a8172397729d95a92f814fc4b8d3c224376428faa07a6a01a checksum: 10c0/32fcfa81ab396533b2ec1f4082b1ff779a05d9c836bbbd3f4398405b0e6814c0d9503b7993130e37bc6941dbc1ded49f55e9700ae9ca4e803bab2b5bc5deb331
languageName: node languageName: node
linkType: hard linkType: hard
@ -1570,7 +1570,7 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@prisma/client@npm:7.6.0": "@prisma/client@npm:^7.6.0":
version: 7.6.0 version: 7.6.0
resolution: "@prisma/client@npm:7.6.0" resolution: "@prisma/client@npm:7.6.0"
dependencies: dependencies:
@ -1587,7 +1587,7 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@prisma/config@npm:7.6.0": "@prisma/config@npm:7.6.0, @prisma/config@npm:^7.6.0":
version: 7.6.0 version: 7.6.0
resolution: "@prisma/config@npm:7.6.0" resolution: "@prisma/config@npm:7.6.0"
dependencies: dependencies:
@ -1879,9 +1879,9 @@ __metadata:
linkType: hard linkType: hard
"@sinclair/typebox@npm:^0.34.0": "@sinclair/typebox@npm:^0.34.0":
version: 0.34.48 version: 0.34.49
resolution: "@sinclair/typebox@npm:0.34.48" resolution: "@sinclair/typebox@npm:0.34.49"
checksum: 10c0/e09f26d8ad471a07ee64004eea7c4ec185349a1f61c03e87e71ea33cbe98e97959940076c2e52968a955ffd4c215bf5ba7035e77079511aac7935f25e989e29d checksum: 10c0/16b7d87f039a49b68c10bb4cdcae2ce5242b2472228851fd6483731616aba4ef977690aa517b230a8d20da8185bb416eb34e326f30568b3963c1cf26b05d1ad8
languageName: node languageName: node
linkType: hard linkType: hard
@ -2227,137 +2227,137 @@ __metadata:
linkType: hard linkType: hard
"@typescript-eslint/eslint-plugin@npm:^8.57.2": "@typescript-eslint/eslint-plugin@npm:^8.57.2":
version: 8.57.2 version: 8.58.0
resolution: "@typescript-eslint/eslint-plugin@npm:8.57.2" resolution: "@typescript-eslint/eslint-plugin@npm:8.58.0"
dependencies: dependencies:
"@eslint-community/regexpp": "npm:^4.12.2" "@eslint-community/regexpp": "npm:^4.12.2"
"@typescript-eslint/scope-manager": "npm:8.57.2" "@typescript-eslint/scope-manager": "npm:8.58.0"
"@typescript-eslint/type-utils": "npm:8.57.2" "@typescript-eslint/type-utils": "npm:8.58.0"
"@typescript-eslint/utils": "npm:8.57.2" "@typescript-eslint/utils": "npm:8.58.0"
"@typescript-eslint/visitor-keys": "npm:8.57.2" "@typescript-eslint/visitor-keys": "npm:8.58.0"
ignore: "npm:^7.0.5" ignore: "npm:^7.0.5"
natural-compare: "npm:^1.4.0" natural-compare: "npm:^1.4.0"
ts-api-utils: "npm:^2.4.0" ts-api-utils: "npm:^2.5.0"
peerDependencies: peerDependencies:
"@typescript-eslint/parser": ^8.57.2 "@typescript-eslint/parser": ^8.58.0
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: ">=4.8.4 <6.0.0" typescript: ">=4.8.4 <6.1.0"
checksum: 10c0/92f3a45f6c2104cef5294bfba972c475b1d3fafb6070efa1178b38cb951e7dfbaf89eae50bfd95f4a476fe51783e218b115bd7cbc09fc9bc7c0ca6c5233861d2 checksum: 10c0/ac45c30f6ba9e188a01144708aa845e7ee8bb8a4d4f9aa6d2dce7784852d0821d42b031fee6832069935c3b885feff6d4014e30145b99693d25d7f563266a9f8
languageName: node languageName: node
linkType: hard linkType: hard
"@typescript-eslint/parser@npm:^8.57.2": "@typescript-eslint/parser@npm:^8.57.2":
version: 8.57.2 version: 8.58.0
resolution: "@typescript-eslint/parser@npm:8.57.2" resolution: "@typescript-eslint/parser@npm:8.58.0"
dependencies: dependencies:
"@typescript-eslint/scope-manager": "npm:8.57.2" "@typescript-eslint/scope-manager": "npm:8.58.0"
"@typescript-eslint/types": "npm:8.57.2" "@typescript-eslint/types": "npm:8.58.0"
"@typescript-eslint/typescript-estree": "npm:8.57.2" "@typescript-eslint/typescript-estree": "npm:8.58.0"
"@typescript-eslint/visitor-keys": "npm:8.57.2" "@typescript-eslint/visitor-keys": "npm:8.58.0"
debug: "npm:^4.4.3" debug: "npm:^4.4.3"
peerDependencies: peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: ">=4.8.4 <6.0.0" typescript: ">=4.8.4 <6.1.0"
checksum: 10c0/afd8a30bd42ac56b212f3182d1b60e4556542eb22147b5b7a9a606d3c79ee35e596baf0bd7672d7e236472d246efc86e06265a46be26150ac12b05e4c45d16a6 checksum: 10c0/56c7ec21675cec4730760bfa37c29e42e80b4d6444e2beca55fad9ef53731392270d142797482ea798405be0d7e28ec6c9c16a1ee2ee1c94f73d3bf0ed29763c
languageName: node languageName: node
linkType: hard linkType: hard
"@typescript-eslint/project-service@npm:8.57.2": "@typescript-eslint/project-service@npm:8.58.0":
version: 8.57.2 version: 8.58.0
resolution: "@typescript-eslint/project-service@npm:8.57.2" resolution: "@typescript-eslint/project-service@npm:8.58.0"
dependencies: dependencies:
"@typescript-eslint/tsconfig-utils": "npm:^8.57.2" "@typescript-eslint/tsconfig-utils": "npm:^8.58.0"
"@typescript-eslint/types": "npm:^8.57.2" "@typescript-eslint/types": "npm:^8.58.0"
debug: "npm:^4.4.3" debug: "npm:^4.4.3"
peerDependencies: peerDependencies:
typescript: ">=4.8.4 <6.0.0" typescript: ">=4.8.4 <6.1.0"
checksum: 10c0/f84e3165b0a214318d4bc119018b87c044170d7638945e84bd4cee2d752b62c1797ce722ca1161cd06f48512d0115ef75500e6c8fc01005ad4bb39fb48dd77bf checksum: 10c0/e6d0cb2f7708ccb31a2ff9eb35817d4999c26e1f1cd3c607539e21d0c73a234daa77c73ee1163bc4e8b139252d619823c444759f1ddabdd138cab4885e9c9794
languageName: node languageName: node
linkType: hard linkType: hard
"@typescript-eslint/scope-manager@npm:8.57.2": "@typescript-eslint/scope-manager@npm:8.58.0":
version: 8.57.2 version: 8.58.0
resolution: "@typescript-eslint/scope-manager@npm:8.57.2" resolution: "@typescript-eslint/scope-manager@npm:8.58.0"
dependencies: dependencies:
"@typescript-eslint/types": "npm:8.57.2" "@typescript-eslint/types": "npm:8.58.0"
"@typescript-eslint/visitor-keys": "npm:8.57.2" "@typescript-eslint/visitor-keys": "npm:8.58.0"
checksum: 10c0/532b1a97a5c2fce51400fa1a94e09615b4df84ce1f2d107206a3f3935074cada396a3e30f155582a698981832868e1afea1641ff779ad9456fdc94169b7def64 checksum: 10c0/bd5c16780f22d62359af0f69909f38a15fa3c55e609124a7cd5c2a04322fe41e586d81066f3ad1dcc3c1eff24dbcb48b78d099626d611fbd680c20c005d48f1d
languageName: node languageName: node
linkType: hard linkType: hard
"@typescript-eslint/tsconfig-utils@npm:8.57.2, @typescript-eslint/tsconfig-utils@npm:^8.57.2": "@typescript-eslint/tsconfig-utils@npm:8.58.0, @typescript-eslint/tsconfig-utils@npm:^8.58.0":
version: 8.57.2 version: 8.58.0
resolution: "@typescript-eslint/tsconfig-utils@npm:8.57.2" resolution: "@typescript-eslint/tsconfig-utils@npm:8.58.0"
peerDependencies: peerDependencies:
typescript: ">=4.8.4 <6.0.0" typescript: ">=4.8.4 <6.1.0"
checksum: 10c0/199dad2d96efc88ce94f5f3e12e97205537bf7a7152e56ef1d84dfbe7bd1babebea9b9f396c01b6c447505a4eb02c1cbbd2c28828c587b51b41b15d017a11d2f checksum: 10c0/0a07fe1a28b2513e625882bc8d4c4e0c5a105cdbcb987beae12fc66dbe71dc9638013e4d1fa8ad10d828a2acd5e3fed987c189c00d41fed0e880009f99adf1b2
languageName: node languageName: node
linkType: hard linkType: hard
"@typescript-eslint/type-utils@npm:8.57.2": "@typescript-eslint/type-utils@npm:8.58.0":
version: 8.57.2 version: 8.58.0
resolution: "@typescript-eslint/type-utils@npm:8.57.2" resolution: "@typescript-eslint/type-utils@npm:8.58.0"
dependencies: dependencies:
"@typescript-eslint/types": "npm:8.57.2" "@typescript-eslint/types": "npm:8.58.0"
"@typescript-eslint/typescript-estree": "npm:8.57.2" "@typescript-eslint/typescript-estree": "npm:8.58.0"
"@typescript-eslint/utils": "npm:8.57.2" "@typescript-eslint/utils": "npm:8.58.0"
debug: "npm:^4.4.3" debug: "npm:^4.4.3"
ts-api-utils: "npm:^2.4.0" ts-api-utils: "npm:^2.5.0"
peerDependencies: peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: ">=4.8.4 <6.0.0" typescript: ">=4.8.4 <6.1.0"
checksum: 10c0/9c479cd0e809d26b7da7b31e830520bc016aaf528bc10a8b8279374808cb76a27f1b4adc77c84156417dc70f6a9e8604f47717b555a27293da2b9b5cfda70411 checksum: 10c0/1223733d41f8463be92ef1ad048d546f9663152212b22dc968abbd9f8e4486bd4082e16baa51d2d281e0d4815563bc4b1ecf01684e2940b7897ba17aa26d1196
languageName: node languageName: node
linkType: hard linkType: hard
"@typescript-eslint/types@npm:8.57.2, @typescript-eslint/types@npm:^8.57.2": "@typescript-eslint/types@npm:8.58.0, @typescript-eslint/types@npm:^8.58.0":
version: 8.57.2 version: 8.58.0
resolution: "@typescript-eslint/types@npm:8.57.2" resolution: "@typescript-eslint/types@npm:8.58.0"
checksum: 10c0/3cd87dd77d28b3ac2fed56a17909b0d11633628d4d733aa148dfd7af72e2cc3ec0e6114b72fac0ff538e8a47e907b4b10dab4095170ae1bd73719ef0b8eaf2e7 checksum: 10c0/f2fe1321758a04591c20d77caba956ae76b77cff0b976a0224b37077d80b1ebd826874d15ec79c3a3b7d57ee5679e5d10756db1b082bde3d51addbd3a8431d38
languageName: node languageName: node
linkType: hard linkType: hard
"@typescript-eslint/typescript-estree@npm:8.57.2": "@typescript-eslint/typescript-estree@npm:8.58.0":
version: 8.57.2 version: 8.58.0
resolution: "@typescript-eslint/typescript-estree@npm:8.57.2" resolution: "@typescript-eslint/typescript-estree@npm:8.58.0"
dependencies: dependencies:
"@typescript-eslint/project-service": "npm:8.57.2" "@typescript-eslint/project-service": "npm:8.58.0"
"@typescript-eslint/tsconfig-utils": "npm:8.57.2" "@typescript-eslint/tsconfig-utils": "npm:8.58.0"
"@typescript-eslint/types": "npm:8.57.2" "@typescript-eslint/types": "npm:8.58.0"
"@typescript-eslint/visitor-keys": "npm:8.57.2" "@typescript-eslint/visitor-keys": "npm:8.58.0"
debug: "npm:^4.4.3" debug: "npm:^4.4.3"
minimatch: "npm:^10.2.2" minimatch: "npm:^10.2.2"
semver: "npm:^7.7.3" semver: "npm:^7.7.3"
tinyglobby: "npm:^0.2.15" tinyglobby: "npm:^0.2.15"
ts-api-utils: "npm:^2.4.0" ts-api-utils: "npm:^2.5.0"
peerDependencies: peerDependencies:
typescript: ">=4.8.4 <6.0.0" typescript: ">=4.8.4 <6.1.0"
checksum: 10c0/2c5d143f0abbafd07a45f0b956aab5d6487b27f74fe93bee93e0a3f8edc8913f1522faf8d7d5215f3809a8d12f5729910ea522156552f2481b66e6d05ab311ae checksum: 10c0/a8cb94cb765b27740a54f9b5378bd8f0dc49e301ceed99a0791dc9d1f61c2a54e3212f7ed9120c8c2df80104ad3117150cf5e7fe8a0b7eec3ed04969a79b103e
languageName: node languageName: node
linkType: hard linkType: hard
"@typescript-eslint/utils@npm:8.57.2": "@typescript-eslint/utils@npm:8.58.0":
version: 8.57.2 version: 8.58.0
resolution: "@typescript-eslint/utils@npm:8.57.2" resolution: "@typescript-eslint/utils@npm:8.58.0"
dependencies: dependencies:
"@eslint-community/eslint-utils": "npm:^4.9.1" "@eslint-community/eslint-utils": "npm:^4.9.1"
"@typescript-eslint/scope-manager": "npm:8.57.2" "@typescript-eslint/scope-manager": "npm:8.58.0"
"@typescript-eslint/types": "npm:8.57.2" "@typescript-eslint/types": "npm:8.58.0"
"@typescript-eslint/typescript-estree": "npm:8.57.2" "@typescript-eslint/typescript-estree": "npm:8.58.0"
peerDependencies: peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: ">=4.8.4 <6.0.0" typescript: ">=4.8.4 <6.1.0"
checksum: 10c0/5771f3d4206004cc817a6556a472926b4c1c885dc448049c10ffab1d5aac7bd59450a391fb57ce8ef31a8367e9c8ddb3bc9370c4e83fc8b61f50fd5189390e8f checksum: 10c0/457e01a6e6d954dbfe13c49ece3cf8a55e5d8cf19ea9ae7086c0e205d89e3cdbb91153062ab440d2e78ad3f077b174adc42bfb1b6fc24299020a0733e7f9c11c
languageName: node languageName: node
linkType: hard linkType: hard
"@typescript-eslint/visitor-keys@npm:8.57.2": "@typescript-eslint/visitor-keys@npm:8.58.0":
version: 8.57.2 version: 8.58.0
resolution: "@typescript-eslint/visitor-keys@npm:8.57.2" resolution: "@typescript-eslint/visitor-keys@npm:8.58.0"
dependencies: dependencies:
"@typescript-eslint/types": "npm:8.57.2" "@typescript-eslint/types": "npm:8.58.0"
eslint-visitor-keys: "npm:^5.0.0" eslint-visitor-keys: "npm:^5.0.0"
checksum: 10c0/8ceb8c228bf97b3e4b343bf6e42a91998d2522f459eb6b53c6bfad4898a9df74295660893dee6b698bdbbda537e968bfc13a3c56fc341089ebfba13db766a574 checksum: 10c0/75f3c9c097a308cc6450822a0f81d44c8b79b524e99dd2c41ded347b12f148ab3bd459ce9cc6bd00f8f0725c5831baab6d2561596ead3394ab76dddbeb32cce1
languageName: node languageName: node
linkType: hard linkType: hard
@ -2762,11 +2762,11 @@ __metadata:
linkType: hard linkType: hard
"baseline-browser-mapping@npm:^2.9.0": "baseline-browser-mapping@npm:^2.9.0":
version: 2.10.11 version: 2.10.12
resolution: "baseline-browser-mapping@npm:2.10.11" resolution: "baseline-browser-mapping@npm:2.10.12"
bin: bin:
baseline-browser-mapping: dist/cli.cjs baseline-browser-mapping: dist/cli.cjs
checksum: 10c0/6ae44b653c26de8ea7b75a109c0771c95c9676c32a11aff25f7c10b2ddeb864d2c387243a0ec083dffe3b76a7aacf2d777fa7e5a6df16e3a2624c1573e116285 checksum: 10c0/391d354240160546c8248317698b61f21f287cc6444766414c2d299a8880045e605ed97e8d8cd198a0b9dfaa4e73c2fa765bbef089474533a904733b1dc9a363
languageName: node languageName: node
linkType: hard linkType: hard
@ -2782,25 +2782,25 @@ __metadata:
linkType: hard linkType: hard
"brace-expansion@npm:^1.1.7": "brace-expansion@npm:^1.1.7":
version: 1.1.12 version: 1.1.13
resolution: "brace-expansion@npm:1.1.12" resolution: "brace-expansion@npm:1.1.13"
dependencies: dependencies:
balanced-match: "npm:^1.0.0" balanced-match: "npm:^1.0.0"
concat-map: "npm:0.0.1" concat-map: "npm:0.0.1"
checksum: 10c0/975fecac2bb7758c062c20d0b3b6288c7cc895219ee25f0a64a9de662dbac981ff0b6e89909c3897c1f84fa353113a721923afdec5f8b2350255b097f12b1f73 checksum: 10c0/384c61bb329b6adfdcc0cbbdd108dc19fb5f3e84ae15a02a74f94c6c791b5a9b035aae73b2a51929a8a478e2f0f212a771eb6a8b5b514cccfb8d0c9f2ce8cbd8
languageName: node languageName: node
linkType: hard linkType: hard
"brace-expansion@npm:^2.0.2": "brace-expansion@npm:^2.0.2":
version: 2.0.2 version: 2.0.3
resolution: "brace-expansion@npm:2.0.2" resolution: "brace-expansion@npm:2.0.3"
dependencies: dependencies:
balanced-match: "npm:^1.0.0" balanced-match: "npm:^1.0.0"
checksum: 10c0/6d117a4c793488af86b83172deb6af143e94c17bc53b0b3cec259733923b4ca84679d506ac261f4ba3c7ed37c46018e2ff442f9ce453af8643ecd64f4a54e6cf checksum: 10c0/468436c9b2fa6f9e64d0cff8784b21300677571a7196e258593e95e7c3db9973a80fbafdb0f01404d5d298a04dc666eae1fc3c9052e2edbb9f2510541deeddfe
languageName: node languageName: node
linkType: hard linkType: hard
"brace-expansion@npm:^5.0.2": "brace-expansion@npm:^5.0.5":
version: 5.0.5 version: 5.0.5
resolution: "brace-expansion@npm:5.0.5" resolution: "brace-expansion@npm:5.0.5"
dependencies: dependencies:
@ -2914,9 +2914,9 @@ __metadata:
linkType: hard linkType: hard
"caniuse-lite@npm:^1.0.30001759": "caniuse-lite@npm:^1.0.30001759":
version: 1.0.30001781 version: 1.0.30001782
resolution: "caniuse-lite@npm:1.0.30001781" resolution: "caniuse-lite@npm:1.0.30001782"
checksum: 10c0/79e77d8759a55e90f0f5db96ab9e7925c7b2e3021f77852e647e45f64f7dc701954174188438e84b810824afc16d706c64a38f20f9c1ed9ac174b6362d33325f checksum: 10c0/f11685de4ce1f0bc16d385fc0a07b0877da0b14af8bf510cee6a3cdfe9da1602360e1f11320e92d4f5d63cd6bec8b43539de25ee78ff94bdb7ec0fa3cce5200c
languageName: node languageName: node
linkType: hard linkType: hard
@ -3208,14 +3208,7 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"discord-api-types@npm:^0.38.1, discord-api-types@npm:^0.38.33, discord-api-types@npm:^0.38.40": "discord-api-types@npm:^0.38.1, discord-api-types@npm:^0.38.33, discord-api-types@npm:^0.38.40, discord-api-types@npm:^0.38.41":
version: 0.38.42
resolution: "discord-api-types@npm:0.38.42"
checksum: 10c0/e902e50b7d10f788a7f993429fffce1fbb104b0ccd4ef1506c4c3680cb551b7562e1345d1bb4764e70a2a5325b1c092a4fad89e2dd58dab1cdeebac461b66e55
languageName: node
linkType: hard
"discord-api-types@npm:^0.38.41":
version: 0.38.43 version: 0.38.43
resolution: "discord-api-types@npm:0.38.43" resolution: "discord-api-types@npm:0.38.43"
checksum: 10c0/8d617f63e415f0a238fddd8ae3cb9f88164b884ce321e4456f4d1c8329e5c8ff2132a2423c78d1fced773d845ece9570b586f49a383f095b0ca3734844cfcaf0 checksum: 10c0/8d617f63e415f0a238fddd8ae3cb9f88164b884ce321e4456f4d1c8329e5c8ff2132a2423c78d1fced773d845ece9570b586f49a383f095b0ca3734844cfcaf0
@ -3275,9 +3268,9 @@ __metadata:
linkType: hard linkType: hard
"electron-to-chromium@npm:^1.5.263": "electron-to-chromium@npm:^1.5.263":
version: 1.5.326 version: 1.5.329
resolution: "electron-to-chromium@npm:1.5.326" resolution: "electron-to-chromium@npm:1.5.329"
checksum: 10c0/8a773ac36b36c5e62c3704a27adbe4d2a72961a8d46782c96e2515a13fbcc6f9e850b1e478482775ee3874ac4b42cd5c32870b2faf4c8e1c7569e172e310c314 checksum: 10c0/a275d7dd7ef26b98d304d37831684614b575d91d5186d3764e7c10114677ba84f4b9ee54a7ef326f63f2dbb2ca883582e3ef9925d9aee8562e1982fa42c94c43
languageName: node languageName: node
linkType: hard linkType: hard
@ -4798,7 +4791,8 @@ __metadata:
"@discordjs/opus": "npm:^0.10.0" "@discordjs/opus": "npm:^0.10.0"
"@discordjs/voice": "npm:^0.19.2" "@discordjs/voice": "npm:^0.19.2"
"@prisma/adapter-pg": "npm:^7.6.0" "@prisma/adapter-pg": "npm:^7.6.0"
"@prisma/client": "npm:7.6.0" "@prisma/client": "npm:^7.6.0"
"@prisma/config": "npm:^7.6.0"
"@types/jest": "npm:^30.0.0" "@types/jest": "npm:^30.0.0"
"@types/node": "npm:^25.5.0" "@types/node": "npm:^25.5.0"
"@types/pg": "npm:^8.20.0" "@types/pg": "npm:^8.20.0"
@ -4813,7 +4807,7 @@ __metadata:
pg: "npm:^8.20.0" pg: "npm:^8.20.0"
prettier: "npm:^3.8.1" prettier: "npm:^3.8.1"
prism-media: "npm:^1.3.5" prism-media: "npm:^1.3.5"
prisma: "npm:7.6.0" prisma: "npm:^7.6.0"
sharp: "npm:^0.34.5" sharp: "npm:^0.34.5"
ts-jest: "npm:^29.4.6" ts-jest: "npm:^29.4.6"
tsx: "npm:^4.21.0" tsx: "npm:^4.21.0"
@ -4914,9 +4908,9 @@ __metadata:
linkType: hard linkType: hard
"lru-cache@npm:^11.0.0, lru-cache@npm:^11.1.0, lru-cache@npm:^11.2.1": "lru-cache@npm:^11.0.0, lru-cache@npm:^11.1.0, lru-cache@npm:^11.2.1":
version: 11.2.7 version: 11.3.2
resolution: "lru-cache@npm:11.2.7" resolution: "lru-cache@npm:11.3.2"
checksum: 10c0/549cdb59488baa617135fc12159cafb1a97f91079f35093bb3bcad72e849fc64ace636d244212c181dfdf1a99bbfa90757ff303f98561958ee4d0f885d9bd5f7 checksum: 10c0/1981baec397c1e7875ac7456ea123e1f8016b878bf3a88b083bbe3f791ba77212d0507751a069f3d4c12441d70ca255d150c2a1c489d3cbe5ed6b8c625bb1be2
languageName: node languageName: node
linkType: hard linkType: hard
@ -5019,11 +5013,11 @@ __metadata:
linkType: hard linkType: hard
"minimatch@npm:^10.2.2, minimatch@npm:^10.2.4": "minimatch@npm:^10.2.2, minimatch@npm:^10.2.4":
version: 10.2.4 version: 10.2.5
resolution: "minimatch@npm:10.2.4" resolution: "minimatch@npm:10.2.5"
dependencies: dependencies:
brace-expansion: "npm:^5.0.2" brace-expansion: "npm:^5.0.5"
checksum: 10c0/35f3dfb7b99b51efd46afd378486889f590e7efb10e0f6a10ba6800428cf65c9a8dedb74427d0570b318d749b543dc4e85f06d46d2858bc8cac7e1eb49a95945 checksum: 10c0/6bb058bd6324104b9ec2f763476a35386d05079c1f5fe4fbf1f324a25237cd4534d6813ecd71f48208f4e635c1221899bef94c3c89f7df55698fe373aaae20fd
languageName: node languageName: node
linkType: hard linkType: hard
@ -5742,7 +5736,7 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"prisma@npm:7.6.0": "prisma@npm:^7.6.0":
version: 7.6.0 version: 7.6.0
resolution: "prisma@npm:7.6.0" resolution: "prisma@npm:7.6.0"
dependencies: dependencies:
@ -6373,7 +6367,7 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"ts-api-utils@npm:^2.4.0": "ts-api-utils@npm:^2.5.0":
version: 2.5.0 version: 2.5.0
resolution: "ts-api-utils@npm:2.5.0" resolution: "ts-api-utils@npm:2.5.0"
peerDependencies: peerDependencies: