Compare commits
11 Commits
1432e0d090
...
c864be267f
| Author | SHA1 | Date |
|---|---|---|
|
|
c864be267f | |
|
|
41a97c474e | |
|
|
e4bcf53308 | |
|
|
2a8b6d41dc | |
|
|
c10822ea87 | |
|
|
57d38a4e09 | |
|
|
57805644fb | |
|
|
aad37ef855 | |
|
|
c1d18d7d8f | |
|
|
1f91bfb9bf | |
|
|
e43af4f944 |
|
|
@ -6,6 +6,5 @@ DISCORD_CLIENT_ID=your_client_id_here
|
|||
# User/pass from docker-compose.yml
|
||||
DATABASE_URL="postgresql://kord:password@localhost:5432/kord_db?schema=public"
|
||||
|
||||
# Redis Configuration
|
||||
REDIS_HOST="localhost"
|
||||
REDIS_PORT=6379
|
||||
## NOTE
|
||||
## This project does not use Redis.
|
||||
|
|
@ -2,7 +2,7 @@ FROM node:20-alpine AS builder
|
|||
WORKDIR /app
|
||||
COPY package.json yarn.lock .yarnrc.yml ./
|
||||
COPY .yarn ./.yarn
|
||||
RUN corepack enable && yarn install
|
||||
RUN corepack enable && yarn install --immutable
|
||||
|
||||
# Generate Prisma Client
|
||||
COPY prisma ./prisma/
|
||||
|
|
@ -17,9 +17,8 @@ FROM node:20-alpine AS runner
|
|||
WORKDIR /app
|
||||
COPY package.json yarn.lock .yarnrc.yml ./
|
||||
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
|
||||
|
||||
CMD ["yarn", "node", "dist/index.js"]
|
||||
CMD ["node", "dist/index.js"]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
- `/fishing status` 추가
|
||||
- `/fishing ranking` 추가
|
||||
- 사용자별 낚시 통계 추가
|
||||
- 낚시 프로필 영속화
|
||||
- 물고기 이동 패턴 개선
|
||||
- 보상 밸런스 조정
|
||||
- `/fishing dex` 추가
|
||||
- 물고기 크기(cm) 시스템 추가
|
||||
- 도감용 포획 기록 저장
|
||||
- 길드 내 최고 크기 기준 Top 10 랭킹 표시
|
||||
|
||||
### Phase 3
|
||||
|
||||
- 희귀도 체계 추가
|
||||
- 인벤토리 / 도감 추가
|
||||
- 인벤토리 / 도감 고도화
|
||||
- 미끼 / 낚싯대 보정치 추가
|
||||
- 리더보드 지원
|
||||
- 미포획 물고기 잠금/실루엣 UI 추가
|
||||
|
||||
## 검증 / 테스트
|
||||
|
||||
|
|
|
|||
|
|
@ -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)입니다.
|
||||
|
|
@ -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`
|
||||
|
||||
모든 단계가 정상 통과했다.
|
||||
|
|
@ -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`은 현재 낚시 진행 통계 전용 모델이다.
|
||||
- 이후 랭킹, 도감, 업적, 장비 시스템은 이 프로필을 기반으로 확장할 수 있다.
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
# 2026-04-07: 낚시 크기 랭킹 구현
|
||||
|
||||
## 개요
|
||||
|
||||
낚시 시스템에 `/fishing ranking` 명령을 추가해, 서버 내 최고 물고기 크기 기록 상위 10개를 조회할 수 있도록 구현했습니다.
|
||||
|
||||
랭킹은 `FishingCollectionEntry`에 저장된 각 유저의 물고기별 최고 크기 기록을 기준으로 정렬하며, 각 항목에는 아래 정보가 포함됩니다.
|
||||
|
||||
- 유저
|
||||
- 물고기 종류
|
||||
- 최고 레어도
|
||||
- 최고 크기(cm)
|
||||
|
||||
## 구현 내용
|
||||
|
||||
- `/fishing ranking` 서브커맨드 추가
|
||||
- 길드 기준 최고 크기 기록 Top 10 조회 서비스 추가
|
||||
- 같은 크기일 경우 최고 레어도, 포획 횟수 순으로 정렬
|
||||
- 랭킹이 비어 있을 때의 안내 메시지 추가
|
||||
- 낚시 기획서에 랭킹 항목 반영
|
||||
|
||||
## 사용자 확인 포인트
|
||||
|
||||
- `/fishing ranking` 실행 시 서버 기준 상위 10개 기록이 표시되는지
|
||||
- 각 항목에 유저, 물고기, 레어도, 크기가 함께 보이는지
|
||||
- 기록이 없는 서버에서는 빈 상태 안내가 표시되는지
|
||||
|
|
@ -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)를 함께 맞추는 것이 좋습니다.
|
||||
|
|
@ -1,7 +1,12 @@
|
|||
# Kord Documentation Index
|
||||
# Kord Documentation Index
|
||||
|
||||
이 루트 색인 문서는 프로젝트 내의 모든 구조화된 문서를 카테고리별로 모아 탐색을 돕기 위해 작성되었습니다.
|
||||
|
||||
## 아키텍처 및 시스템 (Architecture & System)
|
||||
|
||||
- [프로젝트 구조 (Project Structure)](Project_Structure.md)
|
||||
- [데이터베이스 스키마 구조 (Database Schema)](database-schema.md)
|
||||
|
||||
## 정책 및 규칙 (Rules)
|
||||
|
||||
- [보안 가이드라인 (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-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-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)
|
||||
|
|
|
|||
|
|
@ -4,14 +4,13 @@ Kord는 Discord 서버 관리를 돕는 강력하고 유연한 다기능 봇입
|
|||
|
||||
## 1. 개요 (Overview)
|
||||
|
||||
**Kord**는 효율적인 서버 운영을 위해 설계된 Discord 봇입니다. TypeScript와 Discord.js를 기반으로 구축되었으며, Prisma(PostgreSQL)와 Redis를 활용하여 안정적이고 확장 가능한 아키텍처를 제공합니다. 임시 음성 채널 관리, 상세 감사 로그, 권한 진단 등의 핵심 기능을 통해 서버 관리자의 부담을 줄여줍니다.
|
||||
**Kord**는 효율적인 서버 운영을 위해 설계된 Discord 봇입니다. TypeScript와 Discord.js를 기반으로 구축되었으며, Prisma(PostgreSQL)를 활용하여 안정적이고 확장 가능한 아키텍처를 제공합니다. 임시 음성 채널 관리, 상세 감사 로그, 권한 진단 등의 핵심 기능을 통해 서버 관리자의 부담을 줄여줍니다.
|
||||
|
||||
## 2. 요구사항 (Requirements)
|
||||
|
||||
- **Runtime**: Node.js v20 이상
|
||||
- **Package Manager**: Yarn v4 (Berry)
|
||||
- **Database**: PostgreSQL (Prisma 사용)
|
||||
- **Cache**: Redis (다중 인스턴스 동기화 및 캐싱)
|
||||
- **Discord**: Bot Token 및 Client ID (Slash Command 등록용)
|
||||
|
||||
## 3. 테스트 방법 (Test Methods)
|
||||
|
|
@ -59,7 +58,7 @@ Kord는 Discord 서버 관리를 돕는 강력하고 유연한 다기능 봇입
|
|||
|
||||
1. **빌드**: `yarn build`
|
||||
2. **실행**: `yarn start`
|
||||
3. **Docker**: `docker-compose up -d`를 통해 전체 스택(Bot, DB, Redis)을 실행할 수 있습니다.
|
||||
3. **Docker**: `docker-compose up -d`를 통해 전체 스택(Bot, DB)을 실행할 수 있습니다.
|
||||
|
||||
## 5. 기능 목록 (Feature List)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,15 @@
|
|||
version: '3.8'
|
||||
|
||||
services:
|
||||
kord:
|
||||
build: .
|
||||
container_name: kord-bot
|
||||
env_file:
|
||||
- .env
|
||||
depends_on:
|
||||
- postgres
|
||||
restart: unless-stopped
|
||||
|
||||
postgres:
|
||||
image: postgres:15-alpine
|
||||
container_name: kord-postgres
|
||||
|
|
@ -14,15 +23,5 @@ services:
|
|||
- postgres_data:/var/lib/postgresql/data
|
||||
restart: unless-stopped
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: kord-redis
|
||||
ports:
|
||||
- "6379:6379"
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
redis_data:
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -5,7 +5,8 @@
|
|||
"@discordjs/opus": "^0.10.0",
|
||||
"@discordjs/voice": "^0.19.2",
|
||||
"@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",
|
||||
"discord.js": "^14.25.1",
|
||||
"dotenv": "^17.3.1",
|
||||
|
|
@ -24,7 +25,7 @@
|
|||
"eslint": "^10.1.0",
|
||||
"jest": "^30.3.0",
|
||||
"prettier": "^3.8.1",
|
||||
"prisma": "7.6.0",
|
||||
"prisma": "^7.6.0",
|
||||
"ts-jest": "^29.4.6",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^6.0.2"
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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");
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
previewFeatures = ["driverAdapters"]
|
||||
}
|
||||
|
||||
datasource db {
|
||||
|
|
@ -140,9 +141,16 @@ enum EventStatus {
|
|||
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 {
|
||||
id String @id @default(uuid())
|
||||
guildId String
|
||||
|
|
@ -151,11 +159,10 @@ model MiniGameConfig {
|
|||
channelId String?
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([guildId, gameKey])
|
||||
@@index([guildId])
|
||||
@@unique([guildId, gameKey])
|
||||
}
|
||||
|
||||
// 재련 - 유저 상태
|
||||
model RefinementProfile {
|
||||
userId String
|
||||
guildId String
|
||||
|
|
@ -170,9 +177,9 @@ model RefinementProfile {
|
|||
battleWin Int @default(0)
|
||||
battleLoss Int @default(0)
|
||||
dailyBattleCount Int @default(0)
|
||||
lastBattleReset DateTime @default(now())
|
||||
isDisabled Boolean @default(false)
|
||||
lastCheckIn DateTime?
|
||||
lastBattleReset DateTime @default(now())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
|
|
@ -180,7 +187,27 @@ model RefinementProfile {
|
|||
@@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 {
|
||||
level Int @id
|
||||
successRate Float
|
||||
|
|
@ -191,41 +218,52 @@ model RefinementLevelConfig {
|
|||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
// 재련 - 레벨 차이별 승률 설정 (승률 곡선 관리)
|
||||
model RefinementBattleConfig {
|
||||
levelGap Int @id // (공격자 레벨 - 방어자 레벨)
|
||||
levelGap Int @id
|
||||
winRate Float
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
// 재련 - 전역 시스템 설정 (일일 제한, 초기 자본 등)
|
||||
model RefinementSystemConfig {
|
||||
key String @id
|
||||
value String
|
||||
description String?
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
// 서버 활동 추이 (시간대별 메시지 수)
|
||||
model ActivityLog {
|
||||
id String @id @default(uuid())
|
||||
model FishingProfile {
|
||||
userId String
|
||||
guildId String
|
||||
hour Int
|
||||
dayOfWeek Int
|
||||
count Int @default(0)
|
||||
weekStart DateTime
|
||||
|
||||
@@unique([guildId, hour, dayOfWeek, weekStart])
|
||||
@@index([guildId, weekStart])
|
||||
}
|
||||
|
||||
// 피버 상태
|
||||
model FeverState {
|
||||
guildId String @id
|
||||
isActive Boolean @default(false)
|
||||
peakHour Int?
|
||||
bonusRate Float @default(0.1)
|
||||
expiresAt DateTime?
|
||||
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])
|
||||
@@index([guildId, successCount(sort: Desc)])
|
||||
}
|
||||
|
||||
model FishingCollectionEntry {
|
||||
userId String
|
||||
guildId String
|
||||
fishId String
|
||||
catchCount Int @default(0)
|
||||
bestRarityId String
|
||||
bestRarityRank Int @default(0)
|
||||
bestSizeCm Float @default(0)
|
||||
lastCaughtAt DateTime
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@id([userId, guildId, fishId])
|
||||
@@index([guildId, userId])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,10 @@
|
|||
"min": 120,
|
||||
"max": 180
|
||||
},
|
||||
"sizeCm": {
|
||||
"min": 9,
|
||||
"max": 16
|
||||
},
|
||||
"reactionWindowSec": 2.0,
|
||||
"distanceReductionByPosition": {
|
||||
"left": {
|
||||
|
|
@ -38,6 +42,10 @@
|
|||
"min": 150,
|
||||
"max": 220
|
||||
},
|
||||
"sizeCm": {
|
||||
"min": 18,
|
||||
"max": 32
|
||||
},
|
||||
"reactionWindowSec": 1.8,
|
||||
"distanceReductionByPosition": {
|
||||
"left": {
|
||||
|
|
@ -65,6 +73,10 @@
|
|||
"min": 180,
|
||||
"max": 260
|
||||
},
|
||||
"sizeCm": {
|
||||
"min": 14,
|
||||
"max": 26
|
||||
},
|
||||
"reactionWindowSec": 1.6,
|
||||
"distanceReductionByPosition": {
|
||||
"left": {
|
||||
|
|
@ -92,6 +104,10 @@
|
|||
"min": 200,
|
||||
"max": 300
|
||||
},
|
||||
"sizeCm": {
|
||||
"min": 22,
|
||||
"max": 38
|
||||
},
|
||||
"reactionWindowSec": 1.5,
|
||||
"distanceReductionByPosition": {
|
||||
"left": {
|
||||
|
|
@ -119,6 +135,10 @@
|
|||
"min": 230,
|
||||
"max": 340
|
||||
},
|
||||
"sizeCm": {
|
||||
"min": 35,
|
||||
"max": 70
|
||||
},
|
||||
"reactionWindowSec": 1.4,
|
||||
"distanceReductionByPosition": {
|
||||
"left": {
|
||||
|
|
@ -146,6 +166,10 @@
|
|||
"min": 260,
|
||||
"max": 380
|
||||
},
|
||||
"sizeCm": {
|
||||
"min": 12,
|
||||
"max": 24
|
||||
},
|
||||
"reactionWindowSec": 1.3,
|
||||
"distanceReductionByPosition": {
|
||||
"left": {
|
||||
|
|
@ -173,6 +197,10 @@
|
|||
"min": 320,
|
||||
"max": 460
|
||||
},
|
||||
"sizeCm": {
|
||||
"min": 28,
|
||||
"max": 55
|
||||
},
|
||||
"reactionWindowSec": 1.25,
|
||||
"distanceReductionByPosition": {
|
||||
"left": {
|
||||
|
|
@ -200,6 +228,10 @@
|
|||
"min": 380,
|
||||
"max": 540
|
||||
},
|
||||
"sizeCm": {
|
||||
"min": 20,
|
||||
"max": 42
|
||||
},
|
||||
"reactionWindowSec": 1.2,
|
||||
"distanceReductionByPosition": {
|
||||
"left": {
|
||||
|
|
@ -227,6 +259,10 @@
|
|||
"min": 520,
|
||||
"max": 720
|
||||
},
|
||||
"sizeCm": {
|
||||
"min": 90,
|
||||
"max": 160
|
||||
},
|
||||
"reactionWindowSec": 1.2,
|
||||
"distanceReductionByPosition": {
|
||||
"left": {
|
||||
|
|
@ -254,6 +290,10 @@
|
|||
"min": 800,
|
||||
"max": 1200
|
||||
},
|
||||
"sizeCm": {
|
||||
"min": 140,
|
||||
"max": 260
|
||||
},
|
||||
"reactionWindowSec": 1.2,
|
||||
"distanceReductionByPosition": {
|
||||
"left": {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,10 @@
|
|||
"rewardMultiplier": 1.0,
|
||||
"reactionWindowMultiplier": 1.0,
|
||||
"tensionMultiplier": 1.0,
|
||||
"sizeMultiplier": {
|
||||
"min": 1.0,
|
||||
"max": 1.05
|
||||
},
|
||||
"backgroundColor": "#6B7280"
|
||||
},
|
||||
{
|
||||
|
|
@ -20,6 +24,10 @@
|
|||
"rewardMultiplier": 1.2,
|
||||
"reactionWindowMultiplier": 1.08,
|
||||
"tensionMultiplier": 1.08,
|
||||
"sizeMultiplier": {
|
||||
"min": 1.02,
|
||||
"max": 1.12
|
||||
},
|
||||
"backgroundColor": "#22C55E"
|
||||
},
|
||||
{
|
||||
|
|
@ -30,6 +38,10 @@
|
|||
"rewardMultiplier": 1.55,
|
||||
"reactionWindowMultiplier": 1.16,
|
||||
"tensionMultiplier": 1.14,
|
||||
"sizeMultiplier": {
|
||||
"min": 1.1,
|
||||
"max": 1.25
|
||||
},
|
||||
"backgroundColor": "#3B82F6"
|
||||
},
|
||||
{
|
||||
|
|
@ -40,6 +52,10 @@
|
|||
"rewardMultiplier": 2.1,
|
||||
"reactionWindowMultiplier": 1.28,
|
||||
"tensionMultiplier": 1.24,
|
||||
"sizeMultiplier": {
|
||||
"min": 1.22,
|
||||
"max": 1.45
|
||||
},
|
||||
"backgroundColor": "#A855F7"
|
||||
},
|
||||
{
|
||||
|
|
@ -50,6 +66,10 @@
|
|||
"rewardMultiplier": 3.0,
|
||||
"reactionWindowMultiplier": 1.42,
|
||||
"tensionMultiplier": 1.36,
|
||||
"sizeMultiplier": {
|
||||
"min": 1.4,
|
||||
"max": 1.8
|
||||
},
|
||||
"backgroundColor": "#F59E0B"
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,23 +1,52 @@
|
|||
import Redis from 'ioredis';
|
||||
import { env } from '../config/env';
|
||||
import { logger } from '../utils/logger';
|
||||
type CacheEntry = { value: string; expiresAtMs?: number };
|
||||
|
||||
export const redis = new Redis({
|
||||
host: env.REDIS_HOST,
|
||||
port: env.REDIS_PORT,
|
||||
lazyConnect: true,
|
||||
});
|
||||
const nowMs = () => Date.now();
|
||||
|
||||
redis.on('error', (err) => {
|
||||
logger.error('Redis Error:', err);
|
||||
});
|
||||
class LocalCache {
|
||||
private store = new Map<string, CacheEntry>();
|
||||
|
||||
export const connectRedis = async () => {
|
||||
try {
|
||||
await redis.connect();
|
||||
logger.info('Connected to Redis successfully.');
|
||||
} catch (error) {
|
||||
logger.error('Failed to connect to Redis:', error);
|
||||
process.exit(1);
|
||||
private isExpired(entry: CacheEntry | undefined) {
|
||||
return entry?.expiresAtMs !== undefined && entry.expiresAtMs <= nowMs();
|
||||
}
|
||||
|
||||
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 });
|
||||
};
|
||||
|
|
|
|||
|
|
@ -5,8 +5,6 @@ import { loadCommands } from '../handlers/CommandLoader';
|
|||
import { loadEvents } from '../handlers/EventLoader';
|
||||
import { handleGlobalExceptions } from '../utils/errorHandler';
|
||||
import { connectDB } from '../database';
|
||||
import { connectRedis } from '../cache';
|
||||
import { FeverService } from '../services/FeverService';
|
||||
|
||||
export class KordClient extends Client {
|
||||
public commands: Collection<string, any> = new Collection();
|
||||
|
|
@ -29,7 +27,6 @@ export class KordClient extends Client {
|
|||
|
||||
// Connect to external services
|
||||
await connectDB();
|
||||
await connectRedis();
|
||||
|
||||
// Load Handlers
|
||||
await loadCommands(this);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import {
|
||||
ChannelType,
|
||||
ChatInputCommandInteraction,
|
||||
EmbedBuilder,
|
||||
SlashCommandBuilder,
|
||||
} from 'discord.js';
|
||||
import { prisma } from '../database';
|
||||
|
|
@ -37,6 +38,46 @@ export default {
|
|||
.setDescriptionLocalizations({
|
||||
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) {
|
||||
|
|
@ -122,6 +163,135 @@ export default {
|
|||
await interaction.editReply({
|
||||
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 });
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -11,8 +11,6 @@ export const env = {
|
|||
DISCORD_TOKEN: process.env.DISCORD_TOKEN || '',
|
||||
DISCORD_CLIENT_ID: process.env.DISCORD_CLIENT_ID || '',
|
||||
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_CATEGORY_ID: process.env.VOICE_CATEGORY_ID || '',
|
||||
INSTANCE_ID: generateInstanceId(),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
@ -6,7 +6,7 @@ import { VoiceService } from '../services/VoiceService';
|
|||
import { PresenceService } from '../services/PresenceService';
|
||||
import { EventService } from '../services/EventService';
|
||||
import { auditLogService } from '../services/AuditLogService';
|
||||
import { redis } from '../cache';
|
||||
import { tryAcquireLock } from '../cache';
|
||||
import { env } from '../config/env';
|
||||
|
||||
export default {
|
||||
|
|
@ -22,7 +22,7 @@ export default {
|
|||
try {
|
||||
const lockKey = 'commands:sync:lock';
|
||||
// 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) {
|
||||
const commandsData = Array.from(client.commands.values()).map(c => c.data.toJSON());
|
||||
|
|
|
|||
|
|
@ -263,6 +263,9 @@ export const en: TranslationSchema = {
|
|||
enterDescription: 'Create or reopen your fishing thread.',
|
||||
castDescription: 'Start a fishing session inside your fishing thread.',
|
||||
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.',
|
||||
restrictedChannel: 'Fishing can only be started in {{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.',
|
||||
wrongThread: 'This fishing control can only be used inside your fishing thread.',
|
||||
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',
|
||||
titleEnded: 'Fishing Session Ended',
|
||||
status: 'Status',
|
||||
rarity: 'Rarity',
|
||||
size: 'Size',
|
||||
catchCount: 'Catch Count',
|
||||
bestRarity: 'Best Rarity',
|
||||
bestSize: 'Best Size',
|
||||
targetFish: 'Target Fish',
|
||||
distance: 'Distance',
|
||||
tension: 'Line Tension',
|
||||
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.',
|
||||
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: {
|
||||
hooked: 'Hooked',
|
||||
resting: 'Resting',
|
||||
|
|
|
|||
|
|
@ -263,6 +263,9 @@ export const ko: TranslationSchema = {
|
|||
enterDescription: '낚시 전용 스레드를 생성하거나 다시 엽니다.',
|
||||
castDescription: '자신의 낚시 스레드 안에서 낚시 세션을 시작합니다.',
|
||||
endDescription: '낚시 스레드를 종료하고 삭제합니다.',
|
||||
statusDescription: '낚시 통계를 확인합니다.',
|
||||
dexDescription: '낚시 도감을 확인합니다.',
|
||||
rankingDescription: '이 서버의 물고기 크기 랭킹을 확인합니다.',
|
||||
disabled: '이 서버에서는 낚시 미니게임이 비활성화되어 있습니다.',
|
||||
restrictedChannel: '낚시는 {{channel}} 채널에서만 시작할 수 있습니다.',
|
||||
enterTextChannelOnly: '낚시 스레드는 일반 텍스트 채널에서만 열 수 있습니다.',
|
||||
|
|
@ -275,17 +278,34 @@ export const ko: TranslationSchema = {
|
|||
ownerOnly: '이 낚시 세션의 소유자만 조작할 수 있습니다.',
|
||||
wrongThread: '이 조작은 자신의 낚시 스레드 안에서만 사용할 수 있습니다.',
|
||||
endDeleted: '낚시 스레드를 종료했습니다. 스레드를 삭제합니다.',
|
||||
profileTitle: '{{user}}의 낚시 프로필',
|
||||
profileEmpty: '아직 낚시 기록이 없습니다.',
|
||||
dexTitle: '{{user}}의 낚시 도감',
|
||||
dexEmpty: '아직 발견한 물고기가 없습니다.',
|
||||
rankingTitle: '낚시 크기 랭킹',
|
||||
rankingEmpty: '아직 이 서버에 낚시 기록이 없습니다.',
|
||||
titleActive: '낚시 세션',
|
||||
titleEnded: '낚시 세션 종료',
|
||||
status: '상태',
|
||||
rarity: '레어도',
|
||||
size: '크기',
|
||||
catchCount: '포획 수',
|
||||
bestRarity: '최고 레어도',
|
||||
bestSize: '최고 크기',
|
||||
targetFish: '대상 물고기',
|
||||
distance: '거리',
|
||||
tension: '끊어짐 게이지',
|
||||
reward: '보상',
|
||||
successRate: '성공률',
|
||||
totalCasts: '총 시도',
|
||||
totalGoldEarned: '누적 골드',
|
||||
bestCatchReward: '최고 보상',
|
||||
rarityBreakdown: '레어도별 포획',
|
||||
lastCastAt: '최근 낚시',
|
||||
noRecord: '기록 없음',
|
||||
threadHint: '/fishing cast로 다시 시작하거나 /fishing end로 스레드를 삭제할 수 있습니다.',
|
||||
catchResultTitle: '낚시 성공!',
|
||||
catchResultBody: '**{{rarity}} {{fish}}**를 낚았습니다. **{{reward}} G**를 획득했습니다.',
|
||||
catchResultBody: '**{{rarity}} {{fish}}**를 낚았습니다. 크기는 **{{sizeCm}} cm**, 보상은 **{{reward}} G**입니다.',
|
||||
states: {
|
||||
hooked: '입질 중',
|
||||
resting: '휴식 중',
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
/**
|
||||
/**
|
||||
* i18n Type Definitions & Interfaces for Kord Bot.
|
||||
*
|
||||
* Designed with provider interface pattern for future infrastructure swap
|
||||
|
|
@ -217,6 +217,9 @@ export interface TranslationSchema {
|
|||
enterDescription: string;
|
||||
castDescription: string;
|
||||
endDescription: string;
|
||||
statusDescription: string;
|
||||
dexDescription: string;
|
||||
rankingDescription: string;
|
||||
disabled: string;
|
||||
restrictedChannel: string;
|
||||
enterTextChannelOnly: string;
|
||||
|
|
@ -229,14 +232,31 @@ export interface TranslationSchema {
|
|||
ownerOnly: string;
|
||||
wrongThread: string;
|
||||
endDeleted: string;
|
||||
profileTitle: string;
|
||||
profileEmpty: string;
|
||||
dexTitle: string;
|
||||
dexEmpty: string;
|
||||
rankingTitle: string;
|
||||
rankingEmpty: string;
|
||||
titleActive: string;
|
||||
titleEnded: string;
|
||||
status: string;
|
||||
rarity: string;
|
||||
size: string;
|
||||
catchCount: string;
|
||||
bestRarity: string;
|
||||
bestSize: string;
|
||||
targetFish: string;
|
||||
distance: string;
|
||||
tension: string;
|
||||
reward: string;
|
||||
successRate: string;
|
||||
totalCasts: string;
|
||||
totalGoldEarned: string;
|
||||
bestCatchReward: string;
|
||||
rarityBreakdown: string;
|
||||
lastCastAt: string;
|
||||
noRecord: string;
|
||||
threadHint: string;
|
||||
catchResultTitle: string;
|
||||
catchResultBody: string;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
@ -161,7 +161,7 @@ export class EventService {
|
|||
|
||||
const diff = event.startsAt.getTime() - now.getTime();
|
||||
|
||||
const dueOffsets = event.reminderOffsets.filter(offset =>
|
||||
const dueOffsets = event.reminderOffsets.filter((offset: number) =>
|
||||
offset > 0 &&
|
||||
!event.sentReminderOffsets.includes(offset) &&
|
||||
diff <= offset * 60 * 1000 &&
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@
|
|||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import sharp from 'sharp';
|
||||
import { prisma } from '../database';
|
||||
import { SupportedLocale, t } from '../i18n';
|
||||
import { RefinementService } from './RefinementService';
|
||||
import { logger } from '../utils/logger';
|
||||
|
|
@ -33,6 +34,7 @@ interface FishingCatalogEntry {
|
|||
displayName: string;
|
||||
spawnRate: number;
|
||||
rewardGold: FishingRange;
|
||||
sizeCm: FishingRange;
|
||||
reactionWindowSec: number;
|
||||
distanceReductionByPosition: Record<FishingDirection, FishingRange>;
|
||||
artResourcePaths: string[];
|
||||
|
|
@ -50,6 +52,10 @@ interface FishingRarityEntry {
|
|||
rewardMultiplier: number;
|
||||
reactionWindowMultiplier: number;
|
||||
tensionMultiplier: number;
|
||||
sizeMultiplier: {
|
||||
min: number;
|
||||
max: number;
|
||||
};
|
||||
backgroundColor: string;
|
||||
}
|
||||
|
||||
|
|
@ -74,6 +80,7 @@ interface FishingSession {
|
|||
lineTension: number;
|
||||
status: FishingState;
|
||||
reward: number | null;
|
||||
catchSizeCm: number | null;
|
||||
tickInterval: NodeJS.Timeout | null;
|
||||
isRendering: boolean;
|
||||
needsRender: boolean;
|
||||
|
|
@ -217,6 +224,55 @@ export class FishingService {
|
|||
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) {
|
||||
if (!this.sessionsByUser.has(this.getUserKey(session.guildId, session.userId))) {
|
||||
this.clearTick(session);
|
||||
|
|
@ -272,6 +328,7 @@ export class FishingService {
|
|||
Math.round(this.rollRange(session.currentFish.rewardGold) * session.currentRarity.rewardMultiplier),
|
||||
);
|
||||
session.reward = reward;
|
||||
session.catchSizeCm = this.rollCatchSizeCm(session.currentFish, session.currentRarity);
|
||||
await RefinementService.addGold(session.userId, session.guildId, reward);
|
||||
await this.finishSession(session, 'success', false);
|
||||
return;
|
||||
|
|
@ -316,6 +373,8 @@ export class FishingService {
|
|||
|
||||
logger.info(`[Fishing] Finished session for ${session.userId} with state ${finalState}.`);
|
||||
|
||||
await this.recordProfileResult(session, finalState);
|
||||
|
||||
await this.renderSession(session, true);
|
||||
|
||||
if (finalState === 'success') {
|
||||
|
|
@ -480,6 +539,7 @@ export class FishingService {
|
|||
lineTension: 0,
|
||||
status: 'hooked' as FishingState,
|
||||
reward: null,
|
||||
catchSizeCm: null,
|
||||
tickInterval: null,
|
||||
isRendering: false,
|
||||
needsRender: false,
|
||||
|
|
@ -553,6 +613,7 @@ export class FishingService {
|
|||
t(session.locale, 'commands.fishing.catchResultBody', {
|
||||
rarity: rarityName,
|
||||
fish: session.currentFish.displayName,
|
||||
sizeCm: (session.catchSizeCm ?? 0).toFixed(1),
|
||||
reward: String(session.reward ?? 0),
|
||||
}),
|
||||
)
|
||||
|
|
@ -560,6 +621,11 @@ export class FishingService {
|
|||
name: t(session.locale, 'commands.fishing.rarity'),
|
||||
value: rarityName,
|
||||
inline: true,
|
||||
})
|
||||
.addFields({
|
||||
name: t(session.locale, 'commands.fishing.size'),
|
||||
value: `${(session.catchSizeCm ?? 0).toFixed(1)} cm`,
|
||||
inline: true,
|
||||
});
|
||||
|
||||
if (artPath && fs.existsSync(artPath)) {
|
||||
|
|
@ -699,6 +765,111 @@ export class FishingService {
|
|||
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) {
|
||||
if (rarityId === 'legendary') return '🟠';
|
||||
if (rarityId === 'epic') return '🟣';
|
||||
|
|
@ -735,6 +906,12 @@ export class FishingService {
|
|||
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) {
|
||||
if (action === 'left') return '⬅️';
|
||||
if (action === 'center') return '⏺️';
|
||||
|
|
@ -753,6 +930,14 @@ export class FishingService {
|
|||
private static getUserKey(guildId: string, userId: string) {
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { Client, Guild, Invite, GuildMember } from 'discord.js';
|
||||
import { redis } from '../cache';
|
||||
import { cache } from '../cache';
|
||||
import { prisma } from '../database';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
|
|
@ -18,7 +18,7 @@ export class InviteService {
|
|||
code: inv.code,
|
||||
uses: inv.uses || 0
|
||||
}));
|
||||
await redis.set(`invites:${guild.id}`, JSON.stringify(inviteData));
|
||||
await cache.set(`invites:${guild.id}`, JSON.stringify(inviteData));
|
||||
} catch (error) {
|
||||
logger.error(`InviteMaster: Failed to cache invites for guild ${guild.id}:`, error);
|
||||
}
|
||||
|
|
@ -41,7 +41,7 @@ export class InviteService {
|
|||
try {
|
||||
// Fetch current active invites
|
||||
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;
|
||||
|
||||
|
|
|
|||
|
|
@ -88,7 +88,7 @@ const FEATURE_DEFINITIONS: FeatureDefinition[] = [
|
|||
],
|
||||
resolveChannelIds: async (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) => {
|
||||
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',
|
||||
resolveTargetRoleIds: async (guildId) => {
|
||||
const inviteRoles = await prisma.inviteRole.findMany({ where: { guildId } });
|
||||
return inviteRoles.map((ir) => ir.roleId);
|
||||
return inviteRoles.map((ir: { roleId: string }) => ir.roleId);
|
||||
},
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { VoiceState, ChannelType, PermissionFlagsBits, VoiceChannel, ActionRowBuilder, StringSelectMenuBuilder, StringSelectMenuOptionBuilder, Client, GuildMember } from 'discord.js';
|
||||
import { prisma } from '../database';
|
||||
import { logger } from '../utils/logger';
|
||||
import { redis } from '../cache';
|
||||
import { tryAcquireLock } from '../cache';
|
||||
import { ErrorDefs } from '../errors/ErrorCodes';
|
||||
import { t, SupportedLocale, DEFAULT_LOCALE } from '../i18n';
|
||||
import { getContextLocale } from '../i18n/localeHelper';
|
||||
|
|
@ -10,8 +10,7 @@ import { auditLogService } from './AuditLogService';
|
|||
export class VoiceService {
|
||||
public static async syncChannels(client: Client) {
|
||||
const lockKey = 'voice:sync:lock';
|
||||
// NX = only set if not exists, EX = expire in 60s
|
||||
const acquired = await redis.set(lockKey, '1', 'EX', 60, 'NX');
|
||||
const acquired = await tryAcquireLock(lockKey, 60);
|
||||
if (!acquired) {
|
||||
logger.info('VoiceService: Another instance is already syncing channels. Skipping.');
|
||||
return;
|
||||
|
|
@ -55,9 +54,8 @@ export class VoiceService {
|
|||
}
|
||||
}
|
||||
logger.info('VoiceService: Channel synchronization complete.');
|
||||
} finally {
|
||||
// Free the lock just in case, though EX ensures it doesn't hang forever
|
||||
await redis.del(lockKey).catch(() => {});
|
||||
} catch (error) {
|
||||
logger.error('VoiceService: Failed during channel synchronization', error);
|
||||
}
|
||||
}
|
||||
public static async handleVoiceStateUpdate(oldState: VoiceState, newState: VoiceState) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { TextChannel, WebhookClient } from 'discord.js';
|
||||
import { logger } from '../utils/logger';
|
||||
import { redis } from '../cache';
|
||||
import { cache } from '../cache';
|
||||
|
||||
export class WebhookService {
|
||||
private static readonly MAX_WEBHOOKS = 10;
|
||||
|
|
@ -9,7 +9,7 @@ export class WebhookService {
|
|||
public static async getWebhookClient(channel: TextChannel): Promise<WebhookClient | null> {
|
||||
try {
|
||||
// 1. Check cache
|
||||
const cachedData = await redis.get(`webhook:${channel.id}`);
|
||||
const cachedData = await cache.get(`webhook:${channel.id}`);
|
||||
if (cachedData) {
|
||||
const { id, token } = JSON.parse(cachedData);
|
||||
return new WebhookClient({ id, token });
|
||||
|
|
@ -40,14 +40,11 @@ export class WebhookService {
|
|||
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) {
|
||||
await redis.set(
|
||||
`webhook:${channel.id}`,
|
||||
JSON.stringify({ id: kordWebhook.id, token: kordWebhook.token }),
|
||||
'EX',
|
||||
86400
|
||||
);
|
||||
await cache.set(`webhook:${channel.id}`, JSON.stringify({ id: kordWebhook.id, token: kordWebhook.token }), {
|
||||
exSeconds: 86400,
|
||||
});
|
||||
return new WebhookClient({ id: kordWebhook.id, token: kordWebhook.token });
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
240
yarn.lock
|
|
@ -573,30 +573,30 @@ __metadata:
|
|||
linkType: hard
|
||||
|
||||
"@emnapi/core@npm:^1.4.3":
|
||||
version: 1.9.1
|
||||
resolution: "@emnapi/core@npm:1.9.1"
|
||||
version: 1.9.2
|
||||
resolution: "@emnapi/core@npm:1.9.2"
|
||||
dependencies:
|
||||
"@emnapi/wasi-threads": "npm:1.2.0"
|
||||
"@emnapi/wasi-threads": "npm:1.2.1"
|
||||
tslib: "npm:^2.4.0"
|
||||
checksum: 10c0/00e7a99a2bc3ad908ca8272ba861a934da87dffa8797a41316c4a3b571a1e4d2743e2fa14b1a0f131fa4a3c2018ddb601cd2a8cb7f574fa940af696df3c2fe8d
|
||||
checksum: 10c0/5500393f953951bad0768fafaa9191f2d938956b20c6d6a79e5ab696a613a25ce6ad23422bc18e86e6ce8deb147619d8d0d7d413a69f84adc01a6633cc353cd9
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@emnapi/runtime@npm:^1.4.3, @emnapi/runtime@npm:^1.7.0":
|
||||
version: 1.9.1
|
||||
resolution: "@emnapi/runtime@npm:1.9.1"
|
||||
version: 1.9.2
|
||||
resolution: "@emnapi/runtime@npm:1.9.2"
|
||||
dependencies:
|
||||
tslib: "npm:^2.4.0"
|
||||
checksum: 10c0/750edca117e0363ab2de10622f8ee60e57d8690c2f29c49704813da5cd627c641798d7f3cb0d953c62fdc71688e02e333ddbf2c1204f38b47e3e40657332a6f5
|
||||
checksum: 10c0/61c3a59e0c36784558b8d58eb02bd04815aa5fb0dbfbaf84d1b3050a78aa0cc63ea129ae806bd1e48062bfeb7fc36eb0e5431740d62f64ea51bdf426404b8caa
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@emnapi/wasi-threads@npm:1.2.0":
|
||||
version: 1.2.0
|
||||
resolution: "@emnapi/wasi-threads@npm:1.2.0"
|
||||
"@emnapi/wasi-threads@npm:1.2.1":
|
||||
version: 1.2.1
|
||||
resolution: "@emnapi/wasi-threads@npm:1.2.1"
|
||||
dependencies:
|
||||
tslib: "npm:^2.4.0"
|
||||
checksum: 10c0/1e3724b5814b06c14782fda87eee9b9aa68af01576c81ffeaefdf621ddb74386e419d5b3b1027b6a8172397729d95a92f814fc4b8d3c224376428faa07a6a01a
|
||||
checksum: 10c0/32fcfa81ab396533b2ec1f4082b1ff779a05d9c836bbbd3f4398405b0e6814c0d9503b7993130e37bc6941dbc1ded49f55e9700ae9ca4e803bab2b5bc5deb331
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
|
@ -1570,7 +1570,7 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@prisma/client@npm:7.6.0":
|
||||
"@prisma/client@npm:^7.6.0":
|
||||
version: 7.6.0
|
||||
resolution: "@prisma/client@npm:7.6.0"
|
||||
dependencies:
|
||||
|
|
@ -1587,7 +1587,7 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@prisma/config@npm:7.6.0":
|
||||
"@prisma/config@npm:7.6.0, @prisma/config@npm:^7.6.0":
|
||||
version: 7.6.0
|
||||
resolution: "@prisma/config@npm:7.6.0"
|
||||
dependencies:
|
||||
|
|
@ -1879,9 +1879,9 @@ __metadata:
|
|||
linkType: hard
|
||||
|
||||
"@sinclair/typebox@npm:^0.34.0":
|
||||
version: 0.34.48
|
||||
resolution: "@sinclair/typebox@npm:0.34.48"
|
||||
checksum: 10c0/e09f26d8ad471a07ee64004eea7c4ec185349a1f61c03e87e71ea33cbe98e97959940076c2e52968a955ffd4c215bf5ba7035e77079511aac7935f25e989e29d
|
||||
version: 0.34.49
|
||||
resolution: "@sinclair/typebox@npm:0.34.49"
|
||||
checksum: 10c0/16b7d87f039a49b68c10bb4cdcae2ce5242b2472228851fd6483731616aba4ef977690aa517b230a8d20da8185bb416eb34e326f30568b3963c1cf26b05d1ad8
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
|
@ -2227,137 +2227,137 @@ __metadata:
|
|||
linkType: hard
|
||||
|
||||
"@typescript-eslint/eslint-plugin@npm:^8.57.2":
|
||||
version: 8.57.2
|
||||
resolution: "@typescript-eslint/eslint-plugin@npm:8.57.2"
|
||||
version: 8.58.0
|
||||
resolution: "@typescript-eslint/eslint-plugin@npm:8.58.0"
|
||||
dependencies:
|
||||
"@eslint-community/regexpp": "npm:^4.12.2"
|
||||
"@typescript-eslint/scope-manager": "npm:8.57.2"
|
||||
"@typescript-eslint/type-utils": "npm:8.57.2"
|
||||
"@typescript-eslint/utils": "npm:8.57.2"
|
||||
"@typescript-eslint/visitor-keys": "npm:8.57.2"
|
||||
"@typescript-eslint/scope-manager": "npm:8.58.0"
|
||||
"@typescript-eslint/type-utils": "npm:8.58.0"
|
||||
"@typescript-eslint/utils": "npm:8.58.0"
|
||||
"@typescript-eslint/visitor-keys": "npm:8.58.0"
|
||||
ignore: "npm:^7.0.5"
|
||||
natural-compare: "npm:^1.4.0"
|
||||
ts-api-utils: "npm:^2.4.0"
|
||||
ts-api-utils: "npm:^2.5.0"
|
||||
peerDependencies:
|
||||
"@typescript-eslint/parser": ^8.57.2
|
||||
"@typescript-eslint/parser": ^8.58.0
|
||||
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
|
||||
typescript: ">=4.8.4 <6.0.0"
|
||||
checksum: 10c0/92f3a45f6c2104cef5294bfba972c475b1d3fafb6070efa1178b38cb951e7dfbaf89eae50bfd95f4a476fe51783e218b115bd7cbc09fc9bc7c0ca6c5233861d2
|
||||
typescript: ">=4.8.4 <6.1.0"
|
||||
checksum: 10c0/ac45c30f6ba9e188a01144708aa845e7ee8bb8a4d4f9aa6d2dce7784852d0821d42b031fee6832069935c3b885feff6d4014e30145b99693d25d7f563266a9f8
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@typescript-eslint/parser@npm:^8.57.2":
|
||||
version: 8.57.2
|
||||
resolution: "@typescript-eslint/parser@npm:8.57.2"
|
||||
version: 8.58.0
|
||||
resolution: "@typescript-eslint/parser@npm:8.58.0"
|
||||
dependencies:
|
||||
"@typescript-eslint/scope-manager": "npm:8.57.2"
|
||||
"@typescript-eslint/types": "npm:8.57.2"
|
||||
"@typescript-eslint/typescript-estree": "npm:8.57.2"
|
||||
"@typescript-eslint/visitor-keys": "npm:8.57.2"
|
||||
"@typescript-eslint/scope-manager": "npm:8.58.0"
|
||||
"@typescript-eslint/types": "npm:8.58.0"
|
||||
"@typescript-eslint/typescript-estree": "npm:8.58.0"
|
||||
"@typescript-eslint/visitor-keys": "npm:8.58.0"
|
||||
debug: "npm:^4.4.3"
|
||||
peerDependencies:
|
||||
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
|
||||
typescript: ">=4.8.4 <6.0.0"
|
||||
checksum: 10c0/afd8a30bd42ac56b212f3182d1b60e4556542eb22147b5b7a9a606d3c79ee35e596baf0bd7672d7e236472d246efc86e06265a46be26150ac12b05e4c45d16a6
|
||||
typescript: ">=4.8.4 <6.1.0"
|
||||
checksum: 10c0/56c7ec21675cec4730760bfa37c29e42e80b4d6444e2beca55fad9ef53731392270d142797482ea798405be0d7e28ec6c9c16a1ee2ee1c94f73d3bf0ed29763c
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@typescript-eslint/project-service@npm:8.57.2":
|
||||
version: 8.57.2
|
||||
resolution: "@typescript-eslint/project-service@npm:8.57.2"
|
||||
"@typescript-eslint/project-service@npm:8.58.0":
|
||||
version: 8.58.0
|
||||
resolution: "@typescript-eslint/project-service@npm:8.58.0"
|
||||
dependencies:
|
||||
"@typescript-eslint/tsconfig-utils": "npm:^8.57.2"
|
||||
"@typescript-eslint/types": "npm:^8.57.2"
|
||||
"@typescript-eslint/tsconfig-utils": "npm:^8.58.0"
|
||||
"@typescript-eslint/types": "npm:^8.58.0"
|
||||
debug: "npm:^4.4.3"
|
||||
peerDependencies:
|
||||
typescript: ">=4.8.4 <6.0.0"
|
||||
checksum: 10c0/f84e3165b0a214318d4bc119018b87c044170d7638945e84bd4cee2d752b62c1797ce722ca1161cd06f48512d0115ef75500e6c8fc01005ad4bb39fb48dd77bf
|
||||
typescript: ">=4.8.4 <6.1.0"
|
||||
checksum: 10c0/e6d0cb2f7708ccb31a2ff9eb35817d4999c26e1f1cd3c607539e21d0c73a234daa77c73ee1163bc4e8b139252d619823c444759f1ddabdd138cab4885e9c9794
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@typescript-eslint/scope-manager@npm:8.57.2":
|
||||
version: 8.57.2
|
||||
resolution: "@typescript-eslint/scope-manager@npm:8.57.2"
|
||||
"@typescript-eslint/scope-manager@npm:8.58.0":
|
||||
version: 8.58.0
|
||||
resolution: "@typescript-eslint/scope-manager@npm:8.58.0"
|
||||
dependencies:
|
||||
"@typescript-eslint/types": "npm:8.57.2"
|
||||
"@typescript-eslint/visitor-keys": "npm:8.57.2"
|
||||
checksum: 10c0/532b1a97a5c2fce51400fa1a94e09615b4df84ce1f2d107206a3f3935074cada396a3e30f155582a698981832868e1afea1641ff779ad9456fdc94169b7def64
|
||||
"@typescript-eslint/types": "npm:8.58.0"
|
||||
"@typescript-eslint/visitor-keys": "npm:8.58.0"
|
||||
checksum: 10c0/bd5c16780f22d62359af0f69909f38a15fa3c55e609124a7cd5c2a04322fe41e586d81066f3ad1dcc3c1eff24dbcb48b78d099626d611fbd680c20c005d48f1d
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@typescript-eslint/tsconfig-utils@npm:8.57.2, @typescript-eslint/tsconfig-utils@npm:^8.57.2":
|
||||
version: 8.57.2
|
||||
resolution: "@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.58.0
|
||||
resolution: "@typescript-eslint/tsconfig-utils@npm:8.58.0"
|
||||
peerDependencies:
|
||||
typescript: ">=4.8.4 <6.0.0"
|
||||
checksum: 10c0/199dad2d96efc88ce94f5f3e12e97205537bf7a7152e56ef1d84dfbe7bd1babebea9b9f396c01b6c447505a4eb02c1cbbd2c28828c587b51b41b15d017a11d2f
|
||||
typescript: ">=4.8.4 <6.1.0"
|
||||
checksum: 10c0/0a07fe1a28b2513e625882bc8d4c4e0c5a105cdbcb987beae12fc66dbe71dc9638013e4d1fa8ad10d828a2acd5e3fed987c189c00d41fed0e880009f99adf1b2
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@typescript-eslint/type-utils@npm:8.57.2":
|
||||
version: 8.57.2
|
||||
resolution: "@typescript-eslint/type-utils@npm:8.57.2"
|
||||
"@typescript-eslint/type-utils@npm:8.58.0":
|
||||
version: 8.58.0
|
||||
resolution: "@typescript-eslint/type-utils@npm:8.58.0"
|
||||
dependencies:
|
||||
"@typescript-eslint/types": "npm:8.57.2"
|
||||
"@typescript-eslint/typescript-estree": "npm:8.57.2"
|
||||
"@typescript-eslint/utils": "npm:8.57.2"
|
||||
"@typescript-eslint/types": "npm:8.58.0"
|
||||
"@typescript-eslint/typescript-estree": "npm:8.58.0"
|
||||
"@typescript-eslint/utils": "npm:8.58.0"
|
||||
debug: "npm:^4.4.3"
|
||||
ts-api-utils: "npm:^2.4.0"
|
||||
ts-api-utils: "npm:^2.5.0"
|
||||
peerDependencies:
|
||||
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
|
||||
typescript: ">=4.8.4 <6.0.0"
|
||||
checksum: 10c0/9c479cd0e809d26b7da7b31e830520bc016aaf528bc10a8b8279374808cb76a27f1b4adc77c84156417dc70f6a9e8604f47717b555a27293da2b9b5cfda70411
|
||||
typescript: ">=4.8.4 <6.1.0"
|
||||
checksum: 10c0/1223733d41f8463be92ef1ad048d546f9663152212b22dc968abbd9f8e4486bd4082e16baa51d2d281e0d4815563bc4b1ecf01684e2940b7897ba17aa26d1196
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@typescript-eslint/types@npm:8.57.2, @typescript-eslint/types@npm:^8.57.2":
|
||||
version: 8.57.2
|
||||
resolution: "@typescript-eslint/types@npm:8.57.2"
|
||||
checksum: 10c0/3cd87dd77d28b3ac2fed56a17909b0d11633628d4d733aa148dfd7af72e2cc3ec0e6114b72fac0ff538e8a47e907b4b10dab4095170ae1bd73719ef0b8eaf2e7
|
||||
"@typescript-eslint/types@npm:8.58.0, @typescript-eslint/types@npm:^8.58.0":
|
||||
version: 8.58.0
|
||||
resolution: "@typescript-eslint/types@npm:8.58.0"
|
||||
checksum: 10c0/f2fe1321758a04591c20d77caba956ae76b77cff0b976a0224b37077d80b1ebd826874d15ec79c3a3b7d57ee5679e5d10756db1b082bde3d51addbd3a8431d38
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@typescript-eslint/typescript-estree@npm:8.57.2":
|
||||
version: 8.57.2
|
||||
resolution: "@typescript-eslint/typescript-estree@npm:8.57.2"
|
||||
"@typescript-eslint/typescript-estree@npm:8.58.0":
|
||||
version: 8.58.0
|
||||
resolution: "@typescript-eslint/typescript-estree@npm:8.58.0"
|
||||
dependencies:
|
||||
"@typescript-eslint/project-service": "npm:8.57.2"
|
||||
"@typescript-eslint/tsconfig-utils": "npm:8.57.2"
|
||||
"@typescript-eslint/types": "npm:8.57.2"
|
||||
"@typescript-eslint/visitor-keys": "npm:8.57.2"
|
||||
"@typescript-eslint/project-service": "npm:8.58.0"
|
||||
"@typescript-eslint/tsconfig-utils": "npm:8.58.0"
|
||||
"@typescript-eslint/types": "npm:8.58.0"
|
||||
"@typescript-eslint/visitor-keys": "npm:8.58.0"
|
||||
debug: "npm:^4.4.3"
|
||||
minimatch: "npm:^10.2.2"
|
||||
semver: "npm:^7.7.3"
|
||||
tinyglobby: "npm:^0.2.15"
|
||||
ts-api-utils: "npm:^2.4.0"
|
||||
ts-api-utils: "npm:^2.5.0"
|
||||
peerDependencies:
|
||||
typescript: ">=4.8.4 <6.0.0"
|
||||
checksum: 10c0/2c5d143f0abbafd07a45f0b956aab5d6487b27f74fe93bee93e0a3f8edc8913f1522faf8d7d5215f3809a8d12f5729910ea522156552f2481b66e6d05ab311ae
|
||||
typescript: ">=4.8.4 <6.1.0"
|
||||
checksum: 10c0/a8cb94cb765b27740a54f9b5378bd8f0dc49e301ceed99a0791dc9d1f61c2a54e3212f7ed9120c8c2df80104ad3117150cf5e7fe8a0b7eec3ed04969a79b103e
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@typescript-eslint/utils@npm:8.57.2":
|
||||
version: 8.57.2
|
||||
resolution: "@typescript-eslint/utils@npm:8.57.2"
|
||||
"@typescript-eslint/utils@npm:8.58.0":
|
||||
version: 8.58.0
|
||||
resolution: "@typescript-eslint/utils@npm:8.58.0"
|
||||
dependencies:
|
||||
"@eslint-community/eslint-utils": "npm:^4.9.1"
|
||||
"@typescript-eslint/scope-manager": "npm:8.57.2"
|
||||
"@typescript-eslint/types": "npm:8.57.2"
|
||||
"@typescript-eslint/typescript-estree": "npm:8.57.2"
|
||||
"@typescript-eslint/scope-manager": "npm:8.58.0"
|
||||
"@typescript-eslint/types": "npm:8.58.0"
|
||||
"@typescript-eslint/typescript-estree": "npm:8.58.0"
|
||||
peerDependencies:
|
||||
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
|
||||
typescript: ">=4.8.4 <6.0.0"
|
||||
checksum: 10c0/5771f3d4206004cc817a6556a472926b4c1c885dc448049c10ffab1d5aac7bd59450a391fb57ce8ef31a8367e9c8ddb3bc9370c4e83fc8b61f50fd5189390e8f
|
||||
typescript: ">=4.8.4 <6.1.0"
|
||||
checksum: 10c0/457e01a6e6d954dbfe13c49ece3cf8a55e5d8cf19ea9ae7086c0e205d89e3cdbb91153062ab440d2e78ad3f077b174adc42bfb1b6fc24299020a0733e7f9c11c
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@typescript-eslint/visitor-keys@npm:8.57.2":
|
||||
version: 8.57.2
|
||||
resolution: "@typescript-eslint/visitor-keys@npm:8.57.2"
|
||||
"@typescript-eslint/visitor-keys@npm:8.58.0":
|
||||
version: 8.58.0
|
||||
resolution: "@typescript-eslint/visitor-keys@npm:8.58.0"
|
||||
dependencies:
|
||||
"@typescript-eslint/types": "npm:8.57.2"
|
||||
"@typescript-eslint/types": "npm:8.58.0"
|
||||
eslint-visitor-keys: "npm:^5.0.0"
|
||||
checksum: 10c0/8ceb8c228bf97b3e4b343bf6e42a91998d2522f459eb6b53c6bfad4898a9df74295660893dee6b698bdbbda537e968bfc13a3c56fc341089ebfba13db766a574
|
||||
checksum: 10c0/75f3c9c097a308cc6450822a0f81d44c8b79b524e99dd2c41ded347b12f148ab3bd459ce9cc6bd00f8f0725c5831baab6d2561596ead3394ab76dddbeb32cce1
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
|
@ -2762,11 +2762,11 @@ __metadata:
|
|||
linkType: hard
|
||||
|
||||
"baseline-browser-mapping@npm:^2.9.0":
|
||||
version: 2.10.11
|
||||
resolution: "baseline-browser-mapping@npm:2.10.11"
|
||||
version: 2.10.12
|
||||
resolution: "baseline-browser-mapping@npm:2.10.12"
|
||||
bin:
|
||||
baseline-browser-mapping: dist/cli.cjs
|
||||
checksum: 10c0/6ae44b653c26de8ea7b75a109c0771c95c9676c32a11aff25f7c10b2ddeb864d2c387243a0ec083dffe3b76a7aacf2d777fa7e5a6df16e3a2624c1573e116285
|
||||
checksum: 10c0/391d354240160546c8248317698b61f21f287cc6444766414c2d299a8880045e605ed97e8d8cd198a0b9dfaa4e73c2fa765bbef089474533a904733b1dc9a363
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
|
@ -2782,25 +2782,25 @@ __metadata:
|
|||
linkType: hard
|
||||
|
||||
"brace-expansion@npm:^1.1.7":
|
||||
version: 1.1.12
|
||||
resolution: "brace-expansion@npm:1.1.12"
|
||||
version: 1.1.13
|
||||
resolution: "brace-expansion@npm:1.1.13"
|
||||
dependencies:
|
||||
balanced-match: "npm:^1.0.0"
|
||||
concat-map: "npm:0.0.1"
|
||||
checksum: 10c0/975fecac2bb7758c062c20d0b3b6288c7cc895219ee25f0a64a9de662dbac981ff0b6e89909c3897c1f84fa353113a721923afdec5f8b2350255b097f12b1f73
|
||||
checksum: 10c0/384c61bb329b6adfdcc0cbbdd108dc19fb5f3e84ae15a02a74f94c6c791b5a9b035aae73b2a51929a8a478e2f0f212a771eb6a8b5b514cccfb8d0c9f2ce8cbd8
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"brace-expansion@npm:^2.0.2":
|
||||
version: 2.0.2
|
||||
resolution: "brace-expansion@npm:2.0.2"
|
||||
version: 2.0.3
|
||||
resolution: "brace-expansion@npm:2.0.3"
|
||||
dependencies:
|
||||
balanced-match: "npm:^1.0.0"
|
||||
checksum: 10c0/6d117a4c793488af86b83172deb6af143e94c17bc53b0b3cec259733923b4ca84679d506ac261f4ba3c7ed37c46018e2ff442f9ce453af8643ecd64f4a54e6cf
|
||||
checksum: 10c0/468436c9b2fa6f9e64d0cff8784b21300677571a7196e258593e95e7c3db9973a80fbafdb0f01404d5d298a04dc666eae1fc3c9052e2edbb9f2510541deeddfe
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"brace-expansion@npm:^5.0.2":
|
||||
"brace-expansion@npm:^5.0.5":
|
||||
version: 5.0.5
|
||||
resolution: "brace-expansion@npm:5.0.5"
|
||||
dependencies:
|
||||
|
|
@ -2914,9 +2914,9 @@ __metadata:
|
|||
linkType: hard
|
||||
|
||||
"caniuse-lite@npm:^1.0.30001759":
|
||||
version: 1.0.30001781
|
||||
resolution: "caniuse-lite@npm:1.0.30001781"
|
||||
checksum: 10c0/79e77d8759a55e90f0f5db96ab9e7925c7b2e3021f77852e647e45f64f7dc701954174188438e84b810824afc16d706c64a38f20f9c1ed9ac174b6362d33325f
|
||||
version: 1.0.30001782
|
||||
resolution: "caniuse-lite@npm:1.0.30001782"
|
||||
checksum: 10c0/f11685de4ce1f0bc16d385fc0a07b0877da0b14af8bf510cee6a3cdfe9da1602360e1f11320e92d4f5d63cd6bec8b43539de25ee78ff94bdb7ec0fa3cce5200c
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
|
@ -3208,14 +3208,7 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"discord-api-types@npm:^0.38.1, discord-api-types@npm:^0.38.33, discord-api-types@npm:^0.38.40":
|
||||
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":
|
||||
"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.43
|
||||
resolution: "discord-api-types@npm:0.38.43"
|
||||
checksum: 10c0/8d617f63e415f0a238fddd8ae3cb9f88164b884ce321e4456f4d1c8329e5c8ff2132a2423c78d1fced773d845ece9570b586f49a383f095b0ca3734844cfcaf0
|
||||
|
|
@ -3275,9 +3268,9 @@ __metadata:
|
|||
linkType: hard
|
||||
|
||||
"electron-to-chromium@npm:^1.5.263":
|
||||
version: 1.5.326
|
||||
resolution: "electron-to-chromium@npm:1.5.326"
|
||||
checksum: 10c0/8a773ac36b36c5e62c3704a27adbe4d2a72961a8d46782c96e2515a13fbcc6f9e850b1e478482775ee3874ac4b42cd5c32870b2faf4c8e1c7569e172e310c314
|
||||
version: 1.5.329
|
||||
resolution: "electron-to-chromium@npm:1.5.329"
|
||||
checksum: 10c0/a275d7dd7ef26b98d304d37831684614b575d91d5186d3764e7c10114677ba84f4b9ee54a7ef326f63f2dbb2ca883582e3ef9925d9aee8562e1982fa42c94c43
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
|
@ -4798,7 +4791,8 @@ __metadata:
|
|||
"@discordjs/opus": "npm:^0.10.0"
|
||||
"@discordjs/voice": "npm:^0.19.2"
|
||||
"@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/node": "npm:^25.5.0"
|
||||
"@types/pg": "npm:^8.20.0"
|
||||
|
|
@ -4813,7 +4807,7 @@ __metadata:
|
|||
pg: "npm:^8.20.0"
|
||||
prettier: "npm:^3.8.1"
|
||||
prism-media: "npm:^1.3.5"
|
||||
prisma: "npm:7.6.0"
|
||||
prisma: "npm:^7.6.0"
|
||||
sharp: "npm:^0.34.5"
|
||||
ts-jest: "npm:^29.4.6"
|
||||
tsx: "npm:^4.21.0"
|
||||
|
|
@ -4914,9 +4908,9 @@ __metadata:
|
|||
linkType: hard
|
||||
|
||||
"lru-cache@npm:^11.0.0, lru-cache@npm:^11.1.0, lru-cache@npm:^11.2.1":
|
||||
version: 11.2.7
|
||||
resolution: "lru-cache@npm:11.2.7"
|
||||
checksum: 10c0/549cdb59488baa617135fc12159cafb1a97f91079f35093bb3bcad72e849fc64ace636d244212c181dfdf1a99bbfa90757ff303f98561958ee4d0f885d9bd5f7
|
||||
version: 11.3.2
|
||||
resolution: "lru-cache@npm:11.3.2"
|
||||
checksum: 10c0/1981baec397c1e7875ac7456ea123e1f8016b878bf3a88b083bbe3f791ba77212d0507751a069f3d4c12441d70ca255d150c2a1c489d3cbe5ed6b8c625bb1be2
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
|
@ -5019,11 +5013,11 @@ __metadata:
|
|||
linkType: hard
|
||||
|
||||
"minimatch@npm:^10.2.2, minimatch@npm:^10.2.4":
|
||||
version: 10.2.4
|
||||
resolution: "minimatch@npm:10.2.4"
|
||||
version: 10.2.5
|
||||
resolution: "minimatch@npm:10.2.5"
|
||||
dependencies:
|
||||
brace-expansion: "npm:^5.0.2"
|
||||
checksum: 10c0/35f3dfb7b99b51efd46afd378486889f590e7efb10e0f6a10ba6800428cf65c9a8dedb74427d0570b318d749b543dc4e85f06d46d2858bc8cac7e1eb49a95945
|
||||
brace-expansion: "npm:^5.0.5"
|
||||
checksum: 10c0/6bb058bd6324104b9ec2f763476a35386d05079c1f5fe4fbf1f324a25237cd4534d6813ecd71f48208f4e635c1221899bef94c3c89f7df55698fe373aaae20fd
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
|
@ -5742,7 +5736,7 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"prisma@npm:7.6.0":
|
||||
"prisma@npm:^7.6.0":
|
||||
version: 7.6.0
|
||||
resolution: "prisma@npm:7.6.0"
|
||||
dependencies:
|
||||
|
|
@ -6373,7 +6367,7 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"ts-api-utils@npm:^2.4.0":
|
||||
"ts-api-utils@npm:^2.5.0":
|
||||
version: 2.5.0
|
||||
resolution: "ts-api-utils@npm:2.5.0"
|
||||
peerDependencies:
|
||||
|
|
|
|||
Loading…
Reference in New Issue