Compare commits
4 Commits
9876a331c1
...
41a97c474e
| Author | SHA1 | Date |
|---|---|---|
|
|
41a97c474e | |
|
|
80e104a9f4 | |
|
|
bcaf378111 | |
|
|
e7d435d7ea |
|
|
@ -182,7 +182,9 @@
|
||||||
후속 버전에서는 아래 요소를 추가할 수 있습니다.
|
후속 버전에서는 아래 요소를 추가할 수 있습니다.
|
||||||
|
|
||||||
- 물고기 희귀도
|
- 물고기 희귀도
|
||||||
|
- 물고기 크기(cm) 시스템
|
||||||
- 개별 물고기 인벤토리
|
- 개별 물고기 인벤토리
|
||||||
|
- 물고기 도감 / 컬렉션
|
||||||
- 미끼 종류
|
- 미끼 종류
|
||||||
- 낚싯대 및 업그레이드
|
- 낚싯대 및 업그레이드
|
||||||
- 물고기 판매 시스템
|
- 물고기 판매 시스템
|
||||||
|
|
@ -253,6 +255,41 @@ Phase 1에서는 직접 `gold`를 주는 방식이 가장 단순하고 호환성
|
||||||
|
|
||||||
이 정도면 초반 통계 추적에 충분하고, 너무 이르게 인벤토리를 과설계하지 않을 수 있습니다.
|
이 정도면 초반 통계 추적에 충분하고, 너무 이르게 인벤토리를 과설계하지 않을 수 있습니다.
|
||||||
|
|
||||||
|
도감/크기 확장 시에는 아래 모델을 추가합니다.
|
||||||
|
|
||||||
|
- `FishingCollectionEntry`
|
||||||
|
- `userId`
|
||||||
|
- `guildId`
|
||||||
|
- `fishId`
|
||||||
|
- `catchCount`
|
||||||
|
- `bestRarity`
|
||||||
|
- `bestSizeCm`
|
||||||
|
- `lastCaughtAt`
|
||||||
|
- `createdAt`
|
||||||
|
- `updatedAt`
|
||||||
|
|
||||||
|
이 모델은 유저가 어떤 물고기를 몇 번 잡았는지, 최고 레어도와 최고 크기가 무엇인지 추적하는 데 사용합니다.
|
||||||
|
|
||||||
|
### 물고기 크기 시스템
|
||||||
|
|
||||||
|
- 낚시 성공 시 물고기마다 `cm` 단위의 크기를 부여합니다.
|
||||||
|
- 기본 크기 범위는 물고기별 데이터에서 관리합니다.
|
||||||
|
- 최종 크기는 `물고기 기본 크기 범위 × 레어도 보정치`로 계산합니다.
|
||||||
|
- 즉, 같은 물고기라도 레어도가 높을수록 더 큰 개체가 등장할 수 있습니다.
|
||||||
|
- 성공 결과 메시지에는 잡은 물고기의 크기를 함께 표시합니다.
|
||||||
|
- 도감에는 해당 물고기의 최고 크기를 기록합니다.
|
||||||
|
|
||||||
|
### 도감 (Dex / Collection)
|
||||||
|
|
||||||
|
- `/fishing dex` 명령을 통해 개인 도감을 조회할 수 있어야 합니다.
|
||||||
|
- 도감에는 아래 정보를 보여줍니다.
|
||||||
|
- 잡아본 물고기 목록
|
||||||
|
- 각 물고기의 포획 횟수
|
||||||
|
- 최고 레어도
|
||||||
|
- 최고 크기(cm)
|
||||||
|
- 마지막 포획 시각
|
||||||
|
- 아직 잡지 못한 물고기는 후속 버전에서 실루엣/잠금 상태로 표시할 수 있습니다.
|
||||||
|
|
||||||
### 세션 모델
|
### 세션 모델
|
||||||
|
|
||||||
낚시 플레이 자체는 즉각적인 반응을 위해 메모리 세션 기반이 적합합니다.
|
낚시 플레이 자체는 즉각적인 반응을 위해 메모리 세션 기반이 적합합니다.
|
||||||
|
|
@ -318,17 +355,23 @@ Phase 1에서는 직접 `gold`를 주는 방식이 가장 단순하고 호환성
|
||||||
### Phase 2
|
### Phase 2
|
||||||
|
|
||||||
- `/fishing status` 추가
|
- `/fishing status` 추가
|
||||||
|
- `/fishing ranking` 추가
|
||||||
- 사용자별 낚시 통계 추가
|
- 사용자별 낚시 통계 추가
|
||||||
- 낚시 프로필 영속화
|
- 낚시 프로필 영속화
|
||||||
- 물고기 이동 패턴 개선
|
- 물고기 이동 패턴 개선
|
||||||
- 보상 밸런스 조정
|
- 보상 밸런스 조정
|
||||||
|
- `/fishing dex` 추가
|
||||||
|
- 물고기 크기(cm) 시스템 추가
|
||||||
|
- 도감용 포획 기록 저장
|
||||||
|
- 길드 내 최고 크기 기준 Top 10 랭킹 표시
|
||||||
|
|
||||||
### Phase 3
|
### Phase 3
|
||||||
|
|
||||||
- 희귀도 체계 추가
|
- 희귀도 체계 추가
|
||||||
- 인벤토리 / 도감 추가
|
- 인벤토리 / 도감 고도화
|
||||||
- 미끼 / 낚싯대 보정치 추가
|
- 미끼 / 낚싯대 보정치 추가
|
||||||
- 리더보드 지원
|
- 리더보드 지원
|
||||||
|
- 미포획 물고기 잠금/실루엣 UI 추가
|
||||||
|
|
||||||
## 검증 / 테스트
|
## 검증 / 테스트
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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개 기록이 표시되는지
|
||||||
|
- 각 항목에 유저, 물고기, 레어도, 크기가 함께 보이는지
|
||||||
|
- 기록이 없는 서버에서는 빈 상태 안내가 표시되는지
|
||||||
|
|
@ -58,3 +58,6 @@
|
||||||
- [2026-03-30: 미니게임 시스템 및 재련 게임 구현 (Mini-Game System & Refinement Implementation)](WorkDone/2026-03-30_RefinementImplementation.md)
|
- [2026-03-30: 미니게임 시스템 및 재련 게임 구현 (Mini-Game System & Refinement Implementation)](WorkDone/2026-03-30_RefinementImplementation.md)
|
||||||
- [2026-03-31: 낚시 미니게임 Phase 1 구현 (Fishing Mini-Game Phase 1 Implementation)](WorkDone/2026-03-31_Fishing_MiniGame_Phase1_Implementation.md)
|
- [2026-03-31: 낚시 미니게임 Phase 1 구현 (Fishing Mini-Game Phase 1 Implementation)](WorkDone/2026-03-31_Fishing_MiniGame_Phase1_Implementation.md)
|
||||||
- [2026-03-31: 낚시 미니게임 Phase 1 완료 (Fishing Mini-Game Phase 1 Completion)](WorkDone/2026-03-31_Fishing_MiniGame_Phase1_Completion.md)
|
- [2026-03-31: 낚시 미니게임 Phase 1 완료 (Fishing Mini-Game Phase 1 Completion)](WorkDone/2026-03-31_Fishing_MiniGame_Phase1_Completion.md)
|
||||||
|
- [2026-04-07: 낚시 미니게임 Phase 2 구현 (Fishing Mini-Game Phase 2 Implementation)](WorkDone/2026-04-07_Fishing_MiniGame_Phase2_Implementation.md)
|
||||||
|
- [2026-04-07: 낚시 도감 및 크기 시스템 구현 (Fishing Dex and Size Implementation)](WorkDone/2026-04-07_Fishing_Dex_And_Size_Implementation.md)
|
||||||
|
- [2026-04-07: 낚시 크기 랭킹 구현 (Fishing Size Ranking Implementation)](WorkDone/2026-04-07_Fishing_Size_Ranking_Implementation.md)
|
||||||
|
|
|
||||||
|
|
@ -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,4 +1,4 @@
|
||||||
generator client {
|
generator client {
|
||||||
provider = "prisma-client-js"
|
provider = "prisma-client-js"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -229,3 +229,40 @@ model FeverState {
|
||||||
expiresAt DateTime?
|
expiresAt DateTime?
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model FishingProfile {
|
||||||
|
userId String
|
||||||
|
guildId String
|
||||||
|
totalCastCount Int @default(0)
|
||||||
|
successCount Int @default(0)
|
||||||
|
failCount Int @default(0)
|
||||||
|
totalGoldEarned Int @default(0)
|
||||||
|
bestCatchReward Int @default(0)
|
||||||
|
commonCatchCount Int @default(0)
|
||||||
|
uncommonCatchCount Int @default(0)
|
||||||
|
rareCatchCount Int @default(0)
|
||||||
|
epicCatchCount Int @default(0)
|
||||||
|
legendaryCatchCount Int @default(0)
|
||||||
|
lastCastAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@id([userId, guildId])
|
||||||
|
@@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,
|
"min": 120,
|
||||||
"max": 180
|
"max": 180
|
||||||
},
|
},
|
||||||
|
"sizeCm": {
|
||||||
|
"min": 9,
|
||||||
|
"max": 16
|
||||||
|
},
|
||||||
"reactionWindowSec": 2.0,
|
"reactionWindowSec": 2.0,
|
||||||
"distanceReductionByPosition": {
|
"distanceReductionByPosition": {
|
||||||
"left": {
|
"left": {
|
||||||
|
|
@ -38,6 +42,10 @@
|
||||||
"min": 150,
|
"min": 150,
|
||||||
"max": 220
|
"max": 220
|
||||||
},
|
},
|
||||||
|
"sizeCm": {
|
||||||
|
"min": 18,
|
||||||
|
"max": 32
|
||||||
|
},
|
||||||
"reactionWindowSec": 1.8,
|
"reactionWindowSec": 1.8,
|
||||||
"distanceReductionByPosition": {
|
"distanceReductionByPosition": {
|
||||||
"left": {
|
"left": {
|
||||||
|
|
@ -65,6 +73,10 @@
|
||||||
"min": 180,
|
"min": 180,
|
||||||
"max": 260
|
"max": 260
|
||||||
},
|
},
|
||||||
|
"sizeCm": {
|
||||||
|
"min": 14,
|
||||||
|
"max": 26
|
||||||
|
},
|
||||||
"reactionWindowSec": 1.6,
|
"reactionWindowSec": 1.6,
|
||||||
"distanceReductionByPosition": {
|
"distanceReductionByPosition": {
|
||||||
"left": {
|
"left": {
|
||||||
|
|
@ -92,6 +104,10 @@
|
||||||
"min": 200,
|
"min": 200,
|
||||||
"max": 300
|
"max": 300
|
||||||
},
|
},
|
||||||
|
"sizeCm": {
|
||||||
|
"min": 22,
|
||||||
|
"max": 38
|
||||||
|
},
|
||||||
"reactionWindowSec": 1.5,
|
"reactionWindowSec": 1.5,
|
||||||
"distanceReductionByPosition": {
|
"distanceReductionByPosition": {
|
||||||
"left": {
|
"left": {
|
||||||
|
|
@ -119,6 +135,10 @@
|
||||||
"min": 230,
|
"min": 230,
|
||||||
"max": 340
|
"max": 340
|
||||||
},
|
},
|
||||||
|
"sizeCm": {
|
||||||
|
"min": 35,
|
||||||
|
"max": 70
|
||||||
|
},
|
||||||
"reactionWindowSec": 1.4,
|
"reactionWindowSec": 1.4,
|
||||||
"distanceReductionByPosition": {
|
"distanceReductionByPosition": {
|
||||||
"left": {
|
"left": {
|
||||||
|
|
@ -146,6 +166,10 @@
|
||||||
"min": 260,
|
"min": 260,
|
||||||
"max": 380
|
"max": 380
|
||||||
},
|
},
|
||||||
|
"sizeCm": {
|
||||||
|
"min": 12,
|
||||||
|
"max": 24
|
||||||
|
},
|
||||||
"reactionWindowSec": 1.3,
|
"reactionWindowSec": 1.3,
|
||||||
"distanceReductionByPosition": {
|
"distanceReductionByPosition": {
|
||||||
"left": {
|
"left": {
|
||||||
|
|
@ -173,6 +197,10 @@
|
||||||
"min": 320,
|
"min": 320,
|
||||||
"max": 460
|
"max": 460
|
||||||
},
|
},
|
||||||
|
"sizeCm": {
|
||||||
|
"min": 28,
|
||||||
|
"max": 55
|
||||||
|
},
|
||||||
"reactionWindowSec": 1.25,
|
"reactionWindowSec": 1.25,
|
||||||
"distanceReductionByPosition": {
|
"distanceReductionByPosition": {
|
||||||
"left": {
|
"left": {
|
||||||
|
|
@ -200,6 +228,10 @@
|
||||||
"min": 380,
|
"min": 380,
|
||||||
"max": 540
|
"max": 540
|
||||||
},
|
},
|
||||||
|
"sizeCm": {
|
||||||
|
"min": 20,
|
||||||
|
"max": 42
|
||||||
|
},
|
||||||
"reactionWindowSec": 1.2,
|
"reactionWindowSec": 1.2,
|
||||||
"distanceReductionByPosition": {
|
"distanceReductionByPosition": {
|
||||||
"left": {
|
"left": {
|
||||||
|
|
@ -227,6 +259,10 @@
|
||||||
"min": 520,
|
"min": 520,
|
||||||
"max": 720
|
"max": 720
|
||||||
},
|
},
|
||||||
|
"sizeCm": {
|
||||||
|
"min": 90,
|
||||||
|
"max": 160
|
||||||
|
},
|
||||||
"reactionWindowSec": 1.2,
|
"reactionWindowSec": 1.2,
|
||||||
"distanceReductionByPosition": {
|
"distanceReductionByPosition": {
|
||||||
"left": {
|
"left": {
|
||||||
|
|
@ -254,6 +290,10 @@
|
||||||
"min": 800,
|
"min": 800,
|
||||||
"max": 1200
|
"max": 1200
|
||||||
},
|
},
|
||||||
|
"sizeCm": {
|
||||||
|
"min": 140,
|
||||||
|
"max": 260
|
||||||
|
},
|
||||||
"reactionWindowSec": 1.2,
|
"reactionWindowSec": 1.2,
|
||||||
"distanceReductionByPosition": {
|
"distanceReductionByPosition": {
|
||||||
"left": {
|
"left": {
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,10 @@
|
||||||
"rewardMultiplier": 1.0,
|
"rewardMultiplier": 1.0,
|
||||||
"reactionWindowMultiplier": 1.0,
|
"reactionWindowMultiplier": 1.0,
|
||||||
"tensionMultiplier": 1.0,
|
"tensionMultiplier": 1.0,
|
||||||
|
"sizeMultiplier": {
|
||||||
|
"min": 1.0,
|
||||||
|
"max": 1.05
|
||||||
|
},
|
||||||
"backgroundColor": "#6B7280"
|
"backgroundColor": "#6B7280"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -20,6 +24,10 @@
|
||||||
"rewardMultiplier": 1.2,
|
"rewardMultiplier": 1.2,
|
||||||
"reactionWindowMultiplier": 1.08,
|
"reactionWindowMultiplier": 1.08,
|
||||||
"tensionMultiplier": 1.08,
|
"tensionMultiplier": 1.08,
|
||||||
|
"sizeMultiplier": {
|
||||||
|
"min": 1.02,
|
||||||
|
"max": 1.12
|
||||||
|
},
|
||||||
"backgroundColor": "#22C55E"
|
"backgroundColor": "#22C55E"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -30,6 +38,10 @@
|
||||||
"rewardMultiplier": 1.55,
|
"rewardMultiplier": 1.55,
|
||||||
"reactionWindowMultiplier": 1.16,
|
"reactionWindowMultiplier": 1.16,
|
||||||
"tensionMultiplier": 1.14,
|
"tensionMultiplier": 1.14,
|
||||||
|
"sizeMultiplier": {
|
||||||
|
"min": 1.1,
|
||||||
|
"max": 1.25
|
||||||
|
},
|
||||||
"backgroundColor": "#3B82F6"
|
"backgroundColor": "#3B82F6"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -40,6 +52,10 @@
|
||||||
"rewardMultiplier": 2.1,
|
"rewardMultiplier": 2.1,
|
||||||
"reactionWindowMultiplier": 1.28,
|
"reactionWindowMultiplier": 1.28,
|
||||||
"tensionMultiplier": 1.24,
|
"tensionMultiplier": 1.24,
|
||||||
|
"sizeMultiplier": {
|
||||||
|
"min": 1.22,
|
||||||
|
"max": 1.45
|
||||||
|
},
|
||||||
"backgroundColor": "#A855F7"
|
"backgroundColor": "#A855F7"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -50,6 +66,10 @@
|
||||||
"rewardMultiplier": 3.0,
|
"rewardMultiplier": 3.0,
|
||||||
"reactionWindowMultiplier": 1.42,
|
"reactionWindowMultiplier": 1.42,
|
||||||
"tensionMultiplier": 1.36,
|
"tensionMultiplier": 1.36,
|
||||||
|
"sizeMultiplier": {
|
||||||
|
"min": 1.4,
|
||||||
|
"max": 1.8
|
||||||
|
},
|
||||||
"backgroundColor": "#F59E0B"
|
"backgroundColor": "#F59E0B"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import {
|
import {
|
||||||
ChannelType,
|
ChannelType,
|
||||||
ChatInputCommandInteraction,
|
ChatInputCommandInteraction,
|
||||||
|
EmbedBuilder,
|
||||||
SlashCommandBuilder,
|
SlashCommandBuilder,
|
||||||
} from 'discord.js';
|
} from 'discord.js';
|
||||||
import { prisma } from '../database';
|
import { prisma } from '../database';
|
||||||
|
|
@ -37,6 +38,46 @@ export default {
|
||||||
.setDescriptionLocalizations({
|
.setDescriptionLocalizations({
|
||||||
ko: '진행 중인 낚시 세션을 종료하고 스레드를 삭제합니다.',
|
ko: '진행 중인 낚시 세션을 종료하고 스레드를 삭제합니다.',
|
||||||
}),
|
}),
|
||||||
|
)
|
||||||
|
.addSubcommand((subcommand) =>
|
||||||
|
subcommand
|
||||||
|
.setName('status')
|
||||||
|
.setDescription('View fishing statistics.')
|
||||||
|
.setDescriptionLocalizations({
|
||||||
|
ko: '낚시 통계를 확인합니다.',
|
||||||
|
})
|
||||||
|
.addUserOption((option) =>
|
||||||
|
option
|
||||||
|
.setName('user')
|
||||||
|
.setDescription('User to view')
|
||||||
|
.setDescriptionLocalizations({
|
||||||
|
ko: '조회할 유저',
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.addSubcommand((subcommand) =>
|
||||||
|
subcommand
|
||||||
|
.setName('dex')
|
||||||
|
.setDescription('View your fishing collection book.')
|
||||||
|
.setDescriptionLocalizations({
|
||||||
|
ko: '낚시 도감을 확인합니다.',
|
||||||
|
})
|
||||||
|
.addUserOption((option) =>
|
||||||
|
option
|
||||||
|
.setName('user')
|
||||||
|
.setDescription('User to view')
|
||||||
|
.setDescriptionLocalizations({
|
||||||
|
ko: '조회할 유저',
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.addSubcommand((subcommand) =>
|
||||||
|
subcommand
|
||||||
|
.setName('ranking')
|
||||||
|
.setDescription('View the biggest fish size ranking in this server.')
|
||||||
|
.setDescriptionLocalizations({
|
||||||
|
ko: '이 서버의 물고기 크기 랭킹을 확인합니다.',
|
||||||
|
}),
|
||||||
),
|
),
|
||||||
|
|
||||||
async execute(interaction: ChatInputCommandInteraction, locale: SupportedLocale) {
|
async execute(interaction: ChatInputCommandInteraction, locale: SupportedLocale) {
|
||||||
|
|
@ -122,6 +163,135 @@ export default {
|
||||||
await interaction.editReply({
|
await interaction.editReply({
|
||||||
content: t(locale, 'commands.fishing.endDeleted'),
|
content: t(locale, 'commands.fishing.endDeleted'),
|
||||||
});
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subcommand === 'status') {
|
||||||
|
const targetUser = interaction.options.getUser('user') ?? interaction.user;
|
||||||
|
const profile = await FishingService.getProfile(targetUser.id, interaction.guildId);
|
||||||
|
|
||||||
|
const totalCasts = profile?.totalCastCount ?? 0;
|
||||||
|
const successCount = profile?.successCount ?? 0;
|
||||||
|
const failCount = profile?.failCount ?? 0;
|
||||||
|
const totalGoldEarned = profile?.totalGoldEarned ?? 0;
|
||||||
|
const bestCatchReward = profile?.bestCatchReward ?? 0;
|
||||||
|
const successRate = totalCasts > 0 ? ((successCount / totalCasts) * 100).toFixed(1) : '0.0';
|
||||||
|
const rarityBreakdown = [
|
||||||
|
`⚪ ${profile?.commonCatchCount ?? 0}`,
|
||||||
|
`🟢 ${profile?.uncommonCatchCount ?? 0}`,
|
||||||
|
`🔵 ${profile?.rareCatchCount ?? 0}`,
|
||||||
|
`🟣 ${profile?.epicCatchCount ?? 0}`,
|
||||||
|
`🟠 ${profile?.legendaryCatchCount ?? 0}`,
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const embed = new EmbedBuilder()
|
||||||
|
.setColor(0x3b82f6)
|
||||||
|
.setTitle(t(locale, 'commands.fishing.profileTitle', { user: targetUser.username }))
|
||||||
|
.setThumbnail(targetUser.displayAvatarURL())
|
||||||
|
.addFields(
|
||||||
|
{
|
||||||
|
name: t(locale, 'commands.fishing.totalCasts'),
|
||||||
|
value: String(totalCasts),
|
||||||
|
inline: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: t(locale, 'commands.fishing.successRate'),
|
||||||
|
value: `${successRate}% (${successCount}/${successCount + failCount})`,
|
||||||
|
inline: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: t(locale, 'commands.fishing.totalGoldEarned'),
|
||||||
|
value: `${totalGoldEarned} G`,
|
||||||
|
inline: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: t(locale, 'commands.fishing.bestCatchReward'),
|
||||||
|
value: `${bestCatchReward} G`,
|
||||||
|
inline: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: t(locale, 'commands.fishing.lastCastAt'),
|
||||||
|
value: profile?.lastCastAt
|
||||||
|
? `<t:${Math.floor(profile.lastCastAt.getTime() / 1000)}:R>`
|
||||||
|
: t(locale, 'commands.fishing.noRecord'),
|
||||||
|
inline: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: t(locale, 'commands.fishing.rarityBreakdown'),
|
||||||
|
value: rarityBreakdown,
|
||||||
|
inline: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!profile) {
|
||||||
|
embed.setDescription(t(locale, 'commands.fishing.profileEmpty'));
|
||||||
|
}
|
||||||
|
|
||||||
|
await interaction.reply({ embeds: [embed], ephemeral: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subcommand === 'dex') {
|
||||||
|
const targetUser = interaction.options.getUser('user') ?? interaction.user;
|
||||||
|
const collection = await FishingService.getCollection(targetUser.id, interaction.guildId);
|
||||||
|
|
||||||
|
const embed = new EmbedBuilder()
|
||||||
|
.setColor(0x14b8a6)
|
||||||
|
.setTitle(t(locale, 'commands.fishing.dexTitle', { user: targetUser.username }))
|
||||||
|
.setThumbnail(targetUser.displayAvatarURL());
|
||||||
|
|
||||||
|
if (!collection.length) {
|
||||||
|
embed.setDescription(t(locale, 'commands.fishing.dexEmpty'));
|
||||||
|
await interaction.reply({ embeds: [embed], ephemeral: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entry of collection.slice(0, 10)) {
|
||||||
|
const fishName = FishingService.getFishDisplayName(entry.fishId);
|
||||||
|
const rarityName = FishingService.getRarityDisplayNameById(entry.bestRarityId, locale);
|
||||||
|
embed.addFields({
|
||||||
|
name: fishName,
|
||||||
|
value: [
|
||||||
|
`${t(locale, 'commands.fishing.catchCount')}: ${entry.catchCount}`,
|
||||||
|
`${t(locale, 'commands.fishing.bestRarity')}: ${rarityName}`,
|
||||||
|
`${t(locale, 'commands.fishing.bestSize')}: ${entry.bestSizeCm.toFixed(1)} cm`,
|
||||||
|
].join('\n'),
|
||||||
|
inline: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await interaction.reply({ embeds: [embed], ephemeral: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subcommand === 'ranking') {
|
||||||
|
const ranking = await FishingService.getSizeRanking(interaction.guildId);
|
||||||
|
|
||||||
|
const embed = new EmbedBuilder()
|
||||||
|
.setColor(0xf59e0b)
|
||||||
|
.setTitle(t(locale, 'commands.fishing.rankingTitle'));
|
||||||
|
|
||||||
|
if (!ranking.length) {
|
||||||
|
embed.setDescription(t(locale, 'commands.fishing.rankingEmpty'));
|
||||||
|
await interaction.reply({ embeds: [embed], ephemeral: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [index, entry] of ranking.entries()) {
|
||||||
|
const fishName = FishingService.getFishDisplayName(entry.fishId);
|
||||||
|
const rarityName = FishingService.getRarityDisplayNameById(entry.bestRarityId, locale);
|
||||||
|
embed.addFields({
|
||||||
|
name: `#${index + 1} <@${entry.userId}>`,
|
||||||
|
value: [
|
||||||
|
`${t(locale, 'commands.fishing.targetFish')}: ${fishName}`,
|
||||||
|
`${t(locale, 'commands.fishing.rarity')}: ${rarityName}`,
|
||||||
|
`${t(locale, 'commands.fishing.size')}: ${entry.bestSizeCm.toFixed(1)} cm`,
|
||||||
|
].join('\n'),
|
||||||
|
inline: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await interaction.reply({ embeds: [embed], ephemeral: true });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -263,6 +263,9 @@ export const en: TranslationSchema = {
|
||||||
enterDescription: 'Create or reopen your fishing thread.',
|
enterDescription: 'Create or reopen your fishing thread.',
|
||||||
castDescription: 'Start a fishing session inside your fishing thread.',
|
castDescription: 'Start a fishing session inside your fishing thread.',
|
||||||
endDescription: 'End your fishing thread and delete it.',
|
endDescription: 'End your fishing thread and delete it.',
|
||||||
|
statusDescription: 'View fishing statistics.',
|
||||||
|
dexDescription: 'View your fishing collection book.',
|
||||||
|
rankingDescription: 'View the biggest fish size ranking in this server.',
|
||||||
disabled: 'The fishing mini-game is disabled in this server.',
|
disabled: 'The fishing mini-game is disabled in this server.',
|
||||||
restrictedChannel: 'Fishing can only be started in {{channel}}.',
|
restrictedChannel: 'Fishing can only be started in {{channel}}.',
|
||||||
enterTextChannelOnly: 'Fishing threads can only be opened from a regular text channel.',
|
enterTextChannelOnly: 'Fishing threads can only be opened from a regular text channel.',
|
||||||
|
|
@ -275,17 +278,34 @@ export const en: TranslationSchema = {
|
||||||
ownerOnly: 'Only the owner of this fishing session can use these controls.',
|
ownerOnly: 'Only the owner of this fishing session can use these controls.',
|
||||||
wrongThread: 'This fishing control can only be used inside your fishing thread.',
|
wrongThread: 'This fishing control can only be used inside your fishing thread.',
|
||||||
endDeleted: 'Your fishing thread has been closed and is being deleted.',
|
endDeleted: 'Your fishing thread has been closed and is being deleted.',
|
||||||
|
profileTitle: '{{user}} Fishing Profile',
|
||||||
|
profileEmpty: 'There is no fishing record yet.',
|
||||||
|
dexTitle: '{{user}} Fishing Dex',
|
||||||
|
dexEmpty: 'There are no discovered fish yet.',
|
||||||
|
rankingTitle: 'Fishing Size Ranking',
|
||||||
|
rankingEmpty: 'There are no fishing records in this server yet.',
|
||||||
titleActive: 'Fishing Session',
|
titleActive: 'Fishing Session',
|
||||||
titleEnded: 'Fishing Session Ended',
|
titleEnded: 'Fishing Session Ended',
|
||||||
status: 'Status',
|
status: 'Status',
|
||||||
rarity: 'Rarity',
|
rarity: 'Rarity',
|
||||||
|
size: 'Size',
|
||||||
|
catchCount: 'Catch Count',
|
||||||
|
bestRarity: 'Best Rarity',
|
||||||
|
bestSize: 'Best Size',
|
||||||
targetFish: 'Target Fish',
|
targetFish: 'Target Fish',
|
||||||
distance: 'Distance',
|
distance: 'Distance',
|
||||||
tension: 'Line Tension',
|
tension: 'Line Tension',
|
||||||
reward: 'Reward',
|
reward: 'Reward',
|
||||||
|
successRate: 'Success Rate',
|
||||||
|
totalCasts: 'Total Casts',
|
||||||
|
totalGoldEarned: 'Total Gold',
|
||||||
|
bestCatchReward: 'Best Reward',
|
||||||
|
rarityBreakdown: 'Rarity Breakdown',
|
||||||
|
lastCastAt: 'Last Cast',
|
||||||
|
noRecord: 'No record',
|
||||||
threadHint: 'Use /fishing cast to play again, or /fishing end to delete the thread.',
|
threadHint: 'Use /fishing cast to play again, or /fishing end to delete the thread.',
|
||||||
catchResultTitle: 'Big Catch!',
|
catchResultTitle: 'Big Catch!',
|
||||||
catchResultBody: 'You caught a **{{rarity}} {{fish}}** and earned **{{reward}} G**.',
|
catchResultBody: 'You caught a **{{rarity}} {{fish}}** measuring **{{sizeCm}} cm** and earned **{{reward}} G**.',
|
||||||
states: {
|
states: {
|
||||||
hooked: 'Hooked',
|
hooked: 'Hooked',
|
||||||
resting: 'Resting',
|
resting: 'Resting',
|
||||||
|
|
|
||||||
|
|
@ -263,6 +263,9 @@ export const ko: TranslationSchema = {
|
||||||
enterDescription: '낚시 전용 스레드를 생성하거나 다시 엽니다.',
|
enterDescription: '낚시 전용 스레드를 생성하거나 다시 엽니다.',
|
||||||
castDescription: '자신의 낚시 스레드 안에서 낚시 세션을 시작합니다.',
|
castDescription: '자신의 낚시 스레드 안에서 낚시 세션을 시작합니다.',
|
||||||
endDescription: '낚시 스레드를 종료하고 삭제합니다.',
|
endDescription: '낚시 스레드를 종료하고 삭제합니다.',
|
||||||
|
statusDescription: '낚시 통계를 확인합니다.',
|
||||||
|
dexDescription: '낚시 도감을 확인합니다.',
|
||||||
|
rankingDescription: '이 서버의 물고기 크기 랭킹을 확인합니다.',
|
||||||
disabled: '이 서버에서는 낚시 미니게임이 비활성화되어 있습니다.',
|
disabled: '이 서버에서는 낚시 미니게임이 비활성화되어 있습니다.',
|
||||||
restrictedChannel: '낚시는 {{channel}} 채널에서만 시작할 수 있습니다.',
|
restrictedChannel: '낚시는 {{channel}} 채널에서만 시작할 수 있습니다.',
|
||||||
enterTextChannelOnly: '낚시 스레드는 일반 텍스트 채널에서만 열 수 있습니다.',
|
enterTextChannelOnly: '낚시 스레드는 일반 텍스트 채널에서만 열 수 있습니다.',
|
||||||
|
|
@ -275,17 +278,34 @@ export const ko: TranslationSchema = {
|
||||||
ownerOnly: '이 낚시 세션의 소유자만 조작할 수 있습니다.',
|
ownerOnly: '이 낚시 세션의 소유자만 조작할 수 있습니다.',
|
||||||
wrongThread: '이 조작은 자신의 낚시 스레드 안에서만 사용할 수 있습니다.',
|
wrongThread: '이 조작은 자신의 낚시 스레드 안에서만 사용할 수 있습니다.',
|
||||||
endDeleted: '낚시 스레드를 종료했습니다. 스레드를 삭제합니다.',
|
endDeleted: '낚시 스레드를 종료했습니다. 스레드를 삭제합니다.',
|
||||||
|
profileTitle: '{{user}}의 낚시 프로필',
|
||||||
|
profileEmpty: '아직 낚시 기록이 없습니다.',
|
||||||
|
dexTitle: '{{user}}의 낚시 도감',
|
||||||
|
dexEmpty: '아직 발견한 물고기가 없습니다.',
|
||||||
|
rankingTitle: '낚시 크기 랭킹',
|
||||||
|
rankingEmpty: '아직 이 서버에 낚시 기록이 없습니다.',
|
||||||
titleActive: '낚시 세션',
|
titleActive: '낚시 세션',
|
||||||
titleEnded: '낚시 세션 종료',
|
titleEnded: '낚시 세션 종료',
|
||||||
status: '상태',
|
status: '상태',
|
||||||
rarity: '레어도',
|
rarity: '레어도',
|
||||||
|
size: '크기',
|
||||||
|
catchCount: '포획 수',
|
||||||
|
bestRarity: '최고 레어도',
|
||||||
|
bestSize: '최고 크기',
|
||||||
targetFish: '대상 물고기',
|
targetFish: '대상 물고기',
|
||||||
distance: '거리',
|
distance: '거리',
|
||||||
tension: '끊어짐 게이지',
|
tension: '끊어짐 게이지',
|
||||||
reward: '보상',
|
reward: '보상',
|
||||||
|
successRate: '성공률',
|
||||||
|
totalCasts: '총 시도',
|
||||||
|
totalGoldEarned: '누적 골드',
|
||||||
|
bestCatchReward: '최고 보상',
|
||||||
|
rarityBreakdown: '레어도별 포획',
|
||||||
|
lastCastAt: '최근 낚시',
|
||||||
|
noRecord: '기록 없음',
|
||||||
threadHint: '/fishing cast로 다시 시작하거나 /fishing end로 스레드를 삭제할 수 있습니다.',
|
threadHint: '/fishing cast로 다시 시작하거나 /fishing end로 스레드를 삭제할 수 있습니다.',
|
||||||
catchResultTitle: '낚시 성공!',
|
catchResultTitle: '낚시 성공!',
|
||||||
catchResultBody: '**{{rarity}} {{fish}}**를 낚았습니다. **{{reward}} G**를 획득했습니다.',
|
catchResultBody: '**{{rarity}} {{fish}}**를 낚았습니다. 크기는 **{{sizeCm}} cm**, 보상은 **{{reward}} G**입니다.',
|
||||||
states: {
|
states: {
|
||||||
hooked: '입질 중',
|
hooked: '입질 중',
|
||||||
resting: '휴식 중',
|
resting: '휴식 중',
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
/**
|
/**
|
||||||
* i18n Type Definitions & Interfaces for Kord Bot.
|
* i18n Type Definitions & Interfaces for Kord Bot.
|
||||||
*
|
*
|
||||||
* Designed with provider interface pattern for future infrastructure swap
|
* Designed with provider interface pattern for future infrastructure swap
|
||||||
|
|
@ -217,6 +217,9 @@ export interface TranslationSchema {
|
||||||
enterDescription: string;
|
enterDescription: string;
|
||||||
castDescription: string;
|
castDescription: string;
|
||||||
endDescription: string;
|
endDescription: string;
|
||||||
|
statusDescription: string;
|
||||||
|
dexDescription: string;
|
||||||
|
rankingDescription: string;
|
||||||
disabled: string;
|
disabled: string;
|
||||||
restrictedChannel: string;
|
restrictedChannel: string;
|
||||||
enterTextChannelOnly: string;
|
enterTextChannelOnly: string;
|
||||||
|
|
@ -229,14 +232,31 @@ export interface TranslationSchema {
|
||||||
ownerOnly: string;
|
ownerOnly: string;
|
||||||
wrongThread: string;
|
wrongThread: string;
|
||||||
endDeleted: string;
|
endDeleted: string;
|
||||||
|
profileTitle: string;
|
||||||
|
profileEmpty: string;
|
||||||
|
dexTitle: string;
|
||||||
|
dexEmpty: string;
|
||||||
|
rankingTitle: string;
|
||||||
|
rankingEmpty: string;
|
||||||
titleActive: string;
|
titleActive: string;
|
||||||
titleEnded: string;
|
titleEnded: string;
|
||||||
status: string;
|
status: string;
|
||||||
rarity: string;
|
rarity: string;
|
||||||
|
size: string;
|
||||||
|
catchCount: string;
|
||||||
|
bestRarity: string;
|
||||||
|
bestSize: string;
|
||||||
targetFish: string;
|
targetFish: string;
|
||||||
distance: string;
|
distance: string;
|
||||||
tension: string;
|
tension: string;
|
||||||
reward: string;
|
reward: string;
|
||||||
|
successRate: string;
|
||||||
|
totalCasts: string;
|
||||||
|
totalGoldEarned: string;
|
||||||
|
bestCatchReward: string;
|
||||||
|
rarityBreakdown: string;
|
||||||
|
lastCastAt: string;
|
||||||
|
noRecord: string;
|
||||||
threadHint: string;
|
threadHint: string;
|
||||||
catchResultTitle: string;
|
catchResultTitle: string;
|
||||||
catchResultBody: string;
|
catchResultBody: string;
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import sharp from 'sharp';
|
import sharp from 'sharp';
|
||||||
|
import { prisma } from '../database';
|
||||||
import { SupportedLocale, t } from '../i18n';
|
import { SupportedLocale, t } from '../i18n';
|
||||||
import { RefinementService } from './RefinementService';
|
import { RefinementService } from './RefinementService';
|
||||||
import { logger } from '../utils/logger';
|
import { logger } from '../utils/logger';
|
||||||
|
|
@ -33,6 +34,7 @@ interface FishingCatalogEntry {
|
||||||
displayName: string;
|
displayName: string;
|
||||||
spawnRate: number;
|
spawnRate: number;
|
||||||
rewardGold: FishingRange;
|
rewardGold: FishingRange;
|
||||||
|
sizeCm: FishingRange;
|
||||||
reactionWindowSec: number;
|
reactionWindowSec: number;
|
||||||
distanceReductionByPosition: Record<FishingDirection, FishingRange>;
|
distanceReductionByPosition: Record<FishingDirection, FishingRange>;
|
||||||
artResourcePaths: string[];
|
artResourcePaths: string[];
|
||||||
|
|
@ -50,6 +52,10 @@ interface FishingRarityEntry {
|
||||||
rewardMultiplier: number;
|
rewardMultiplier: number;
|
||||||
reactionWindowMultiplier: number;
|
reactionWindowMultiplier: number;
|
||||||
tensionMultiplier: number;
|
tensionMultiplier: number;
|
||||||
|
sizeMultiplier: {
|
||||||
|
min: number;
|
||||||
|
max: number;
|
||||||
|
};
|
||||||
backgroundColor: string;
|
backgroundColor: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -74,6 +80,7 @@ interface FishingSession {
|
||||||
lineTension: number;
|
lineTension: number;
|
||||||
status: FishingState;
|
status: FishingState;
|
||||||
reward: number | null;
|
reward: number | null;
|
||||||
|
catchSizeCm: number | null;
|
||||||
tickInterval: NodeJS.Timeout | null;
|
tickInterval: NodeJS.Timeout | null;
|
||||||
isRendering: boolean;
|
isRendering: boolean;
|
||||||
needsRender: boolean;
|
needsRender: boolean;
|
||||||
|
|
@ -217,6 +224,55 @@ export class FishingService {
|
||||||
await this.queueAction(session, action as FishingAction);
|
await this.queueAction(session, action as FishingAction);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static async getProfile(userId: string, guildId: string) {
|
||||||
|
return (prisma as any).fishingProfile.findUnique({
|
||||||
|
where: {
|
||||||
|
userId_guildId: { userId, guildId },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getCollection(userId: string, guildId: string) {
|
||||||
|
return (prisma as any).fishingCollectionEntry.findMany({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
guildId,
|
||||||
|
},
|
||||||
|
orderBy: [
|
||||||
|
{ bestRarityRank: 'desc' },
|
||||||
|
{ bestSizeCm: 'desc' },
|
||||||
|
{ catchCount: 'desc' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getSizeRanking(guildId: string) {
|
||||||
|
return (prisma as any).fishingCollectionEntry.findMany({
|
||||||
|
where: {
|
||||||
|
guildId,
|
||||||
|
},
|
||||||
|
orderBy: [
|
||||||
|
{ bestSizeCm: 'desc' },
|
||||||
|
{ bestRarityRank: 'desc' },
|
||||||
|
{ catchCount: 'desc' },
|
||||||
|
{ lastCaughtAt: 'asc' },
|
||||||
|
],
|
||||||
|
take: 10,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static getFishDisplayName(fishId: string) {
|
||||||
|
return this.fishingCatalog.find((fish) => fish.id === fishId)?.displayName ?? fishId;
|
||||||
|
}
|
||||||
|
|
||||||
|
static getRarityDisplayNameById(rarityId: string, locale: SupportedLocale) {
|
||||||
|
const rarity = this.fishingRarities.find((entry) => entry.id === rarityId);
|
||||||
|
if (!rarity) {
|
||||||
|
return rarityId;
|
||||||
|
}
|
||||||
|
return this.getRarityDisplayName(rarity, locale);
|
||||||
|
}
|
||||||
|
|
||||||
private static async tickSession(session: FishingSession) {
|
private static async tickSession(session: FishingSession) {
|
||||||
if (!this.sessionsByUser.has(this.getUserKey(session.guildId, session.userId))) {
|
if (!this.sessionsByUser.has(this.getUserKey(session.guildId, session.userId))) {
|
||||||
this.clearTick(session);
|
this.clearTick(session);
|
||||||
|
|
@ -272,6 +328,7 @@ export class FishingService {
|
||||||
Math.round(this.rollRange(session.currentFish.rewardGold) * session.currentRarity.rewardMultiplier),
|
Math.round(this.rollRange(session.currentFish.rewardGold) * session.currentRarity.rewardMultiplier),
|
||||||
);
|
);
|
||||||
session.reward = reward;
|
session.reward = reward;
|
||||||
|
session.catchSizeCm = this.rollCatchSizeCm(session.currentFish, session.currentRarity);
|
||||||
await RefinementService.addGold(session.userId, session.guildId, reward);
|
await RefinementService.addGold(session.userId, session.guildId, reward);
|
||||||
await this.finishSession(session, 'success', false);
|
await this.finishSession(session, 'success', false);
|
||||||
return;
|
return;
|
||||||
|
|
@ -316,6 +373,8 @@ export class FishingService {
|
||||||
|
|
||||||
logger.info(`[Fishing] Finished session for ${session.userId} with state ${finalState}.`);
|
logger.info(`[Fishing] Finished session for ${session.userId} with state ${finalState}.`);
|
||||||
|
|
||||||
|
await this.recordProfileResult(session, finalState);
|
||||||
|
|
||||||
await this.renderSession(session, true);
|
await this.renderSession(session, true);
|
||||||
|
|
||||||
if (finalState === 'success') {
|
if (finalState === 'success') {
|
||||||
|
|
@ -480,6 +539,7 @@ export class FishingService {
|
||||||
lineTension: 0,
|
lineTension: 0,
|
||||||
status: 'hooked' as FishingState,
|
status: 'hooked' as FishingState,
|
||||||
reward: null,
|
reward: null,
|
||||||
|
catchSizeCm: null,
|
||||||
tickInterval: null,
|
tickInterval: null,
|
||||||
isRendering: false,
|
isRendering: false,
|
||||||
needsRender: false,
|
needsRender: false,
|
||||||
|
|
@ -553,6 +613,7 @@ export class FishingService {
|
||||||
t(session.locale, 'commands.fishing.catchResultBody', {
|
t(session.locale, 'commands.fishing.catchResultBody', {
|
||||||
rarity: rarityName,
|
rarity: rarityName,
|
||||||
fish: session.currentFish.displayName,
|
fish: session.currentFish.displayName,
|
||||||
|
sizeCm: (session.catchSizeCm ?? 0).toFixed(1),
|
||||||
reward: String(session.reward ?? 0),
|
reward: String(session.reward ?? 0),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
@ -560,6 +621,11 @@ export class FishingService {
|
||||||
name: t(session.locale, 'commands.fishing.rarity'),
|
name: t(session.locale, 'commands.fishing.rarity'),
|
||||||
value: rarityName,
|
value: rarityName,
|
||||||
inline: true,
|
inline: true,
|
||||||
|
})
|
||||||
|
.addFields({
|
||||||
|
name: t(session.locale, 'commands.fishing.size'),
|
||||||
|
value: `${(session.catchSizeCm ?? 0).toFixed(1)} cm`,
|
||||||
|
inline: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (artPath && fs.existsSync(artPath)) {
|
if (artPath && fs.existsSync(artPath)) {
|
||||||
|
|
@ -699,6 +765,111 @@ export class FishingService {
|
||||||
return locale === 'ko' && rarity.displayNameKo ? rarity.displayNameKo : rarity.displayName;
|
return locale === 'ko' && rarity.displayNameKo ? rarity.displayNameKo : rarity.displayName;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static async recordProfileResult(session: FishingSession, finalState: 'success' | 'failed') {
|
||||||
|
const reward = session.reward ?? 0;
|
||||||
|
const rarityField = this.getRarityCountField(session.currentRarity.id);
|
||||||
|
const existingProfile = await this.getProfile(session.userId, session.guildId);
|
||||||
|
|
||||||
|
await (prisma as any).fishingProfile.upsert({
|
||||||
|
where: {
|
||||||
|
userId_guildId: {
|
||||||
|
userId: session.userId,
|
||||||
|
guildId: session.guildId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
userId: session.userId,
|
||||||
|
guildId: session.guildId,
|
||||||
|
totalCastCount: 1,
|
||||||
|
successCount: finalState === 'success' ? 1 : 0,
|
||||||
|
failCount: finalState === 'failed' ? 1 : 0,
|
||||||
|
totalGoldEarned: reward,
|
||||||
|
bestCatchReward: reward,
|
||||||
|
commonCatchCount: rarityField === 'commonCatchCount' && finalState === 'success' ? 1 : 0,
|
||||||
|
uncommonCatchCount: rarityField === 'uncommonCatchCount' && finalState === 'success' ? 1 : 0,
|
||||||
|
rareCatchCount: rarityField === 'rareCatchCount' && finalState === 'success' ? 1 : 0,
|
||||||
|
epicCatchCount: rarityField === 'epicCatchCount' && finalState === 'success' ? 1 : 0,
|
||||||
|
legendaryCatchCount: rarityField === 'legendaryCatchCount' && finalState === 'success' ? 1 : 0,
|
||||||
|
lastCastAt: new Date(),
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
totalCastCount: { increment: 1 },
|
||||||
|
successCount: finalState === 'success' ? { increment: 1 } : undefined,
|
||||||
|
failCount: finalState === 'failed' ? { increment: 1 } : undefined,
|
||||||
|
totalGoldEarned: reward > 0 ? { increment: reward } : undefined,
|
||||||
|
bestCatchReward: reward > 0 ? Math.max(reward, existingProfile?.bestCatchReward ?? 0) : undefined,
|
||||||
|
[rarityField]: finalState === 'success' ? { increment: 1 } : undefined,
|
||||||
|
lastCastAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (finalState === 'success') {
|
||||||
|
await this.recordCollectionCatch(session);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async recordCollectionCatch(session: FishingSession) {
|
||||||
|
const rarityRank = this.getRarityRank(session.currentRarity.id);
|
||||||
|
const existingEntry = await (prisma as any).fishingCollectionEntry.findUnique({
|
||||||
|
where: {
|
||||||
|
userId_guildId_fishId: {
|
||||||
|
userId: session.userId,
|
||||||
|
guildId: session.guildId,
|
||||||
|
fishId: session.currentFish.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const bestRarityRank = Math.max(existingEntry?.bestRarityRank ?? 0, rarityRank);
|
||||||
|
const bestRarityId = bestRarityRank === rarityRank
|
||||||
|
? session.currentRarity.id
|
||||||
|
: existingEntry?.bestRarityId ?? session.currentRarity.id;
|
||||||
|
const bestSizeCm = Math.max(existingEntry?.bestSizeCm ?? 0, session.catchSizeCm ?? 0);
|
||||||
|
|
||||||
|
await (prisma as any).fishingCollectionEntry.upsert({
|
||||||
|
where: {
|
||||||
|
userId_guildId_fishId: {
|
||||||
|
userId: session.userId,
|
||||||
|
guildId: session.guildId,
|
||||||
|
fishId: session.currentFish.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
userId: session.userId,
|
||||||
|
guildId: session.guildId,
|
||||||
|
fishId: session.currentFish.id,
|
||||||
|
catchCount: 1,
|
||||||
|
bestRarityId: session.currentRarity.id,
|
||||||
|
bestRarityRank: rarityRank,
|
||||||
|
bestSizeCm: session.catchSizeCm ?? 0,
|
||||||
|
lastCaughtAt: new Date(),
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
catchCount: { increment: 1 },
|
||||||
|
bestRarityId,
|
||||||
|
bestRarityRank,
|
||||||
|
bestSizeCm,
|
||||||
|
lastCaughtAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static getRarityCountField(rarityId: string) {
|
||||||
|
if (rarityId === 'legendary') return 'legendaryCatchCount';
|
||||||
|
if (rarityId === 'epic') return 'epicCatchCount';
|
||||||
|
if (rarityId === 'rare') return 'rareCatchCount';
|
||||||
|
if (rarityId === 'uncommon') return 'uncommonCatchCount';
|
||||||
|
return 'commonCatchCount';
|
||||||
|
}
|
||||||
|
|
||||||
|
private static getRarityRank(rarityId: string) {
|
||||||
|
if (rarityId === 'legendary') return 5;
|
||||||
|
if (rarityId === 'epic') return 4;
|
||||||
|
if (rarityId === 'rare') return 3;
|
||||||
|
if (rarityId === 'uncommon') return 2;
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
private static getRarityBadge(rarityId: string) {
|
private static getRarityBadge(rarityId: string) {
|
||||||
if (rarityId === 'legendary') return '🟠';
|
if (rarityId === 'legendary') return '🟠';
|
||||||
if (rarityId === 'epic') return '🟣';
|
if (rarityId === 'epic') return '🟣';
|
||||||
|
|
@ -735,6 +906,12 @@ export class FishingService {
|
||||||
return Number.parseInt(value.replace('#', ''), 16);
|
return Number.parseInt(value.replace('#', ''), 16);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static rollCatchSizeCm(fish: FishingCatalogEntry, rarity: FishingRarityEntry) {
|
||||||
|
const base = this.rollDecimalRange(fish.sizeCm);
|
||||||
|
const multiplier = this.rollDecimalRange(rarity.sizeMultiplier);
|
||||||
|
return Math.round(base * multiplier * 10) / 10;
|
||||||
|
}
|
||||||
|
|
||||||
private static formatSelectedAction(action: FishingAction | null) {
|
private static formatSelectedAction(action: FishingAction | null) {
|
||||||
if (action === 'left') return '⬅️';
|
if (action === 'left') return '⬅️';
|
||||||
if (action === 'center') return '⏺️';
|
if (action === 'center') return '⏺️';
|
||||||
|
|
@ -753,6 +930,14 @@ export class FishingService {
|
||||||
private static getUserKey(guildId: string, userId: string) {
|
private static getUserKey(guildId: string, userId: string) {
|
||||||
return `${guildId}:${userId}`;
|
return `${guildId}:${userId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static rollDecimalRange(range: { min: number; max: number }) {
|
||||||
|
if (range.min === range.max) {
|
||||||
|
return range.min;
|
||||||
|
}
|
||||||
|
|
||||||
|
return range.min + Math.random() * (range.max - range.min);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildFishingGauge(current: number, max: number, width: number) {
|
export function buildFishingGauge(current: number, max: number, width: number) {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue