Compare commits
No commits in common. "41a97c474eb9d8e1094696e9cb4411f6060f9990" and "9876a331c14a69bc258f4fac0e1a878fe4ff3647" have entirely different histories.
41a97c474e
...
9876a331c1
|
|
@ -182,9 +182,7 @@
|
||||||
후속 버전에서는 아래 요소를 추가할 수 있습니다.
|
후속 버전에서는 아래 요소를 추가할 수 있습니다.
|
||||||
|
|
||||||
- 물고기 희귀도
|
- 물고기 희귀도
|
||||||
- 물고기 크기(cm) 시스템
|
|
||||||
- 개별 물고기 인벤토리
|
- 개별 물고기 인벤토리
|
||||||
- 물고기 도감 / 컬렉션
|
|
||||||
- 미끼 종류
|
- 미끼 종류
|
||||||
- 낚싯대 및 업그레이드
|
- 낚싯대 및 업그레이드
|
||||||
- 물고기 판매 시스템
|
- 물고기 판매 시스템
|
||||||
|
|
@ -255,41 +253,6 @@ Phase 1에서는 직접 `gold`를 주는 방식이 가장 단순하고 호환성
|
||||||
|
|
||||||
이 정도면 초반 통계 추적에 충분하고, 너무 이르게 인벤토리를 과설계하지 않을 수 있습니다.
|
이 정도면 초반 통계 추적에 충분하고, 너무 이르게 인벤토리를 과설계하지 않을 수 있습니다.
|
||||||
|
|
||||||
도감/크기 확장 시에는 아래 모델을 추가합니다.
|
|
||||||
|
|
||||||
- `FishingCollectionEntry`
|
|
||||||
- `userId`
|
|
||||||
- `guildId`
|
|
||||||
- `fishId`
|
|
||||||
- `catchCount`
|
|
||||||
- `bestRarity`
|
|
||||||
- `bestSizeCm`
|
|
||||||
- `lastCaughtAt`
|
|
||||||
- `createdAt`
|
|
||||||
- `updatedAt`
|
|
||||||
|
|
||||||
이 모델은 유저가 어떤 물고기를 몇 번 잡았는지, 최고 레어도와 최고 크기가 무엇인지 추적하는 데 사용합니다.
|
|
||||||
|
|
||||||
### 물고기 크기 시스템
|
|
||||||
|
|
||||||
- 낚시 성공 시 물고기마다 `cm` 단위의 크기를 부여합니다.
|
|
||||||
- 기본 크기 범위는 물고기별 데이터에서 관리합니다.
|
|
||||||
- 최종 크기는 `물고기 기본 크기 범위 × 레어도 보정치`로 계산합니다.
|
|
||||||
- 즉, 같은 물고기라도 레어도가 높을수록 더 큰 개체가 등장할 수 있습니다.
|
|
||||||
- 성공 결과 메시지에는 잡은 물고기의 크기를 함께 표시합니다.
|
|
||||||
- 도감에는 해당 물고기의 최고 크기를 기록합니다.
|
|
||||||
|
|
||||||
### 도감 (Dex / Collection)
|
|
||||||
|
|
||||||
- `/fishing dex` 명령을 통해 개인 도감을 조회할 수 있어야 합니다.
|
|
||||||
- 도감에는 아래 정보를 보여줍니다.
|
|
||||||
- 잡아본 물고기 목록
|
|
||||||
- 각 물고기의 포획 횟수
|
|
||||||
- 최고 레어도
|
|
||||||
- 최고 크기(cm)
|
|
||||||
- 마지막 포획 시각
|
|
||||||
- 아직 잡지 못한 물고기는 후속 버전에서 실루엣/잠금 상태로 표시할 수 있습니다.
|
|
||||||
|
|
||||||
### 세션 모델
|
### 세션 모델
|
||||||
|
|
||||||
낚시 플레이 자체는 즉각적인 반응을 위해 메모리 세션 기반이 적합합니다.
|
낚시 플레이 자체는 즉각적인 반응을 위해 메모리 세션 기반이 적합합니다.
|
||||||
|
|
@ -355,23 +318,17 @@ Phase 1에서는 직접 `gold`를 주는 방식이 가장 단순하고 호환성
|
||||||
### Phase 2
|
### Phase 2
|
||||||
|
|
||||||
- `/fishing status` 추가
|
- `/fishing status` 추가
|
||||||
- `/fishing ranking` 추가
|
|
||||||
- 사용자별 낚시 통계 추가
|
- 사용자별 낚시 통계 추가
|
||||||
- 낚시 프로필 영속화
|
- 낚시 프로필 영속화
|
||||||
- 물고기 이동 패턴 개선
|
- 물고기 이동 패턴 개선
|
||||||
- 보상 밸런스 조정
|
- 보상 밸런스 조정
|
||||||
- `/fishing dex` 추가
|
|
||||||
- 물고기 크기(cm) 시스템 추가
|
|
||||||
- 도감용 포획 기록 저장
|
|
||||||
- 길드 내 최고 크기 기준 Top 10 랭킹 표시
|
|
||||||
|
|
||||||
### Phase 3
|
### Phase 3
|
||||||
|
|
||||||
- 희귀도 체계 추가
|
- 희귀도 체계 추가
|
||||||
- 인벤토리 / 도감 고도화
|
- 인벤토리 / 도감 추가
|
||||||
- 미끼 / 낚싯대 보정치 추가
|
- 미끼 / 낚싯대 보정치 추가
|
||||||
- 리더보드 지원
|
- 리더보드 지원
|
||||||
- 미포획 물고기 잠금/실루엣 UI 추가
|
|
||||||
|
|
||||||
## 검증 / 테스트
|
## 검증 / 테스트
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
# 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`
|
|
||||||
|
|
||||||
모든 단계가 정상 통과했다.
|
|
||||||
|
|
@ -1,39 +0,0 @@
|
||||||
# 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`은 현재 낚시 진행 통계 전용 모델이다.
|
|
||||||
- 이후 랭킹, 도감, 업적, 장비 시스템은 이 프로필을 기반으로 확장할 수 있다.
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
# 2026-04-07: 낚시 크기 랭킹 구현
|
|
||||||
|
|
||||||
## 개요
|
|
||||||
|
|
||||||
낚시 시스템에 `/fishing ranking` 명령을 추가해, 서버 내 최고 물고기 크기 기록 상위 10개를 조회할 수 있도록 구현했습니다.
|
|
||||||
|
|
||||||
랭킹은 `FishingCollectionEntry`에 저장된 각 유저의 물고기별 최고 크기 기록을 기준으로 정렬하며, 각 항목에는 아래 정보가 포함됩니다.
|
|
||||||
|
|
||||||
- 유저
|
|
||||||
- 물고기 종류
|
|
||||||
- 최고 레어도
|
|
||||||
- 최고 크기(cm)
|
|
||||||
|
|
||||||
## 구현 내용
|
|
||||||
|
|
||||||
- `/fishing ranking` 서브커맨드 추가
|
|
||||||
- 길드 기준 최고 크기 기록 Top 10 조회 서비스 추가
|
|
||||||
- 같은 크기일 경우 최고 레어도, 포획 횟수 순으로 정렬
|
|
||||||
- 랭킹이 비어 있을 때의 안내 메시지 추가
|
|
||||||
- 낚시 기획서에 랭킹 항목 반영
|
|
||||||
|
|
||||||
## 사용자 확인 포인트
|
|
||||||
|
|
||||||
- `/fishing ranking` 실행 시 서버 기준 상위 10개 기록이 표시되는지
|
|
||||||
- 각 항목에 유저, 물고기, 레어도, 크기가 함께 보이는지
|
|
||||||
- 기록이 없는 서버에서는 빈 상태 안내가 표시되는지
|
|
||||||
|
|
@ -58,6 +58,3 @@
|
||||||
- [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)
|
|
||||||
|
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
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);
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
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,40 +229,3 @@ 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,10 +11,6 @@
|
||||||
"min": 120,
|
"min": 120,
|
||||||
"max": 180
|
"max": 180
|
||||||
},
|
},
|
||||||
"sizeCm": {
|
|
||||||
"min": 9,
|
|
||||||
"max": 16
|
|
||||||
},
|
|
||||||
"reactionWindowSec": 2.0,
|
"reactionWindowSec": 2.0,
|
||||||
"distanceReductionByPosition": {
|
"distanceReductionByPosition": {
|
||||||
"left": {
|
"left": {
|
||||||
|
|
@ -42,10 +38,6 @@
|
||||||
"min": 150,
|
"min": 150,
|
||||||
"max": 220
|
"max": 220
|
||||||
},
|
},
|
||||||
"sizeCm": {
|
|
||||||
"min": 18,
|
|
||||||
"max": 32
|
|
||||||
},
|
|
||||||
"reactionWindowSec": 1.8,
|
"reactionWindowSec": 1.8,
|
||||||
"distanceReductionByPosition": {
|
"distanceReductionByPosition": {
|
||||||
"left": {
|
"left": {
|
||||||
|
|
@ -73,10 +65,6 @@
|
||||||
"min": 180,
|
"min": 180,
|
||||||
"max": 260
|
"max": 260
|
||||||
},
|
},
|
||||||
"sizeCm": {
|
|
||||||
"min": 14,
|
|
||||||
"max": 26
|
|
||||||
},
|
|
||||||
"reactionWindowSec": 1.6,
|
"reactionWindowSec": 1.6,
|
||||||
"distanceReductionByPosition": {
|
"distanceReductionByPosition": {
|
||||||
"left": {
|
"left": {
|
||||||
|
|
@ -104,10 +92,6 @@
|
||||||
"min": 200,
|
"min": 200,
|
||||||
"max": 300
|
"max": 300
|
||||||
},
|
},
|
||||||
"sizeCm": {
|
|
||||||
"min": 22,
|
|
||||||
"max": 38
|
|
||||||
},
|
|
||||||
"reactionWindowSec": 1.5,
|
"reactionWindowSec": 1.5,
|
||||||
"distanceReductionByPosition": {
|
"distanceReductionByPosition": {
|
||||||
"left": {
|
"left": {
|
||||||
|
|
@ -135,10 +119,6 @@
|
||||||
"min": 230,
|
"min": 230,
|
||||||
"max": 340
|
"max": 340
|
||||||
},
|
},
|
||||||
"sizeCm": {
|
|
||||||
"min": 35,
|
|
||||||
"max": 70
|
|
||||||
},
|
|
||||||
"reactionWindowSec": 1.4,
|
"reactionWindowSec": 1.4,
|
||||||
"distanceReductionByPosition": {
|
"distanceReductionByPosition": {
|
||||||
"left": {
|
"left": {
|
||||||
|
|
@ -166,10 +146,6 @@
|
||||||
"min": 260,
|
"min": 260,
|
||||||
"max": 380
|
"max": 380
|
||||||
},
|
},
|
||||||
"sizeCm": {
|
|
||||||
"min": 12,
|
|
||||||
"max": 24
|
|
||||||
},
|
|
||||||
"reactionWindowSec": 1.3,
|
"reactionWindowSec": 1.3,
|
||||||
"distanceReductionByPosition": {
|
"distanceReductionByPosition": {
|
||||||
"left": {
|
"left": {
|
||||||
|
|
@ -197,10 +173,6 @@
|
||||||
"min": 320,
|
"min": 320,
|
||||||
"max": 460
|
"max": 460
|
||||||
},
|
},
|
||||||
"sizeCm": {
|
|
||||||
"min": 28,
|
|
||||||
"max": 55
|
|
||||||
},
|
|
||||||
"reactionWindowSec": 1.25,
|
"reactionWindowSec": 1.25,
|
||||||
"distanceReductionByPosition": {
|
"distanceReductionByPosition": {
|
||||||
"left": {
|
"left": {
|
||||||
|
|
@ -228,10 +200,6 @@
|
||||||
"min": 380,
|
"min": 380,
|
||||||
"max": 540
|
"max": 540
|
||||||
},
|
},
|
||||||
"sizeCm": {
|
|
||||||
"min": 20,
|
|
||||||
"max": 42
|
|
||||||
},
|
|
||||||
"reactionWindowSec": 1.2,
|
"reactionWindowSec": 1.2,
|
||||||
"distanceReductionByPosition": {
|
"distanceReductionByPosition": {
|
||||||
"left": {
|
"left": {
|
||||||
|
|
@ -259,10 +227,6 @@
|
||||||
"min": 520,
|
"min": 520,
|
||||||
"max": 720
|
"max": 720
|
||||||
},
|
},
|
||||||
"sizeCm": {
|
|
||||||
"min": 90,
|
|
||||||
"max": 160
|
|
||||||
},
|
|
||||||
"reactionWindowSec": 1.2,
|
"reactionWindowSec": 1.2,
|
||||||
"distanceReductionByPosition": {
|
"distanceReductionByPosition": {
|
||||||
"left": {
|
"left": {
|
||||||
|
|
@ -290,10 +254,6 @@
|
||||||
"min": 800,
|
"min": 800,
|
||||||
"max": 1200
|
"max": 1200
|
||||||
},
|
},
|
||||||
"sizeCm": {
|
|
||||||
"min": 140,
|
|
||||||
"max": 260
|
|
||||||
},
|
|
||||||
"reactionWindowSec": 1.2,
|
"reactionWindowSec": 1.2,
|
||||||
"distanceReductionByPosition": {
|
"distanceReductionByPosition": {
|
||||||
"left": {
|
"left": {
|
||||||
|
|
|
||||||
|
|
@ -10,10 +10,6 @@
|
||||||
"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"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -24,10 +20,6 @@
|
||||||
"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"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -38,10 +30,6 @@
|
||||||
"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"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -52,10 +40,6 @@
|
||||||
"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"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -66,10 +50,6 @@
|
||||||
"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,7 +1,6 @@
|
||||||
import {
|
import {
|
||||||
ChannelType,
|
ChannelType,
|
||||||
ChatInputCommandInteraction,
|
ChatInputCommandInteraction,
|
||||||
EmbedBuilder,
|
|
||||||
SlashCommandBuilder,
|
SlashCommandBuilder,
|
||||||
} from 'discord.js';
|
} from 'discord.js';
|
||||||
import { prisma } from '../database';
|
import { prisma } from '../database';
|
||||||
|
|
@ -38,46 +37,6 @@ 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) {
|
||||||
|
|
@ -163,135 +122,6 @@ 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,9 +263,6 @@ 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.',
|
||||||
|
|
@ -278,34 +275,17 @@ 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}}** measuring **{{sizeCm}} cm** and earned **{{reward}} G**.',
|
catchResultBody: 'You caught a **{{rarity}} {{fish}}** and earned **{{reward}} G**.',
|
||||||
states: {
|
states: {
|
||||||
hooked: 'Hooked',
|
hooked: 'Hooked',
|
||||||
resting: 'Resting',
|
resting: 'Resting',
|
||||||
|
|
|
||||||
|
|
@ -263,9 +263,6 @@ export const ko: TranslationSchema = {
|
||||||
enterDescription: '낚시 전용 스레드를 생성하거나 다시 엽니다.',
|
enterDescription: '낚시 전용 스레드를 생성하거나 다시 엽니다.',
|
||||||
castDescription: '자신의 낚시 스레드 안에서 낚시 세션을 시작합니다.',
|
castDescription: '자신의 낚시 스레드 안에서 낚시 세션을 시작합니다.',
|
||||||
endDescription: '낚시 스레드를 종료하고 삭제합니다.',
|
endDescription: '낚시 스레드를 종료하고 삭제합니다.',
|
||||||
statusDescription: '낚시 통계를 확인합니다.',
|
|
||||||
dexDescription: '낚시 도감을 확인합니다.',
|
|
||||||
rankingDescription: '이 서버의 물고기 크기 랭킹을 확인합니다.',
|
|
||||||
disabled: '이 서버에서는 낚시 미니게임이 비활성화되어 있습니다.',
|
disabled: '이 서버에서는 낚시 미니게임이 비활성화되어 있습니다.',
|
||||||
restrictedChannel: '낚시는 {{channel}} 채널에서만 시작할 수 있습니다.',
|
restrictedChannel: '낚시는 {{channel}} 채널에서만 시작할 수 있습니다.',
|
||||||
enterTextChannelOnly: '낚시 스레드는 일반 텍스트 채널에서만 열 수 있습니다.',
|
enterTextChannelOnly: '낚시 스레드는 일반 텍스트 채널에서만 열 수 있습니다.',
|
||||||
|
|
@ -278,34 +275,17 @@ 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}}**를 낚았습니다. 크기는 **{{sizeCm}} cm**, 보상은 **{{reward}} G**입니다.',
|
catchResultBody: '**{{rarity}} {{fish}}**를 낚았습니다. **{{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,9 +217,6 @@ 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;
|
||||||
|
|
@ -232,31 +229,14 @@ 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,7 +15,6 @@
|
||||||
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';
|
||||||
|
|
@ -34,7 +33,6 @@ 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[];
|
||||||
|
|
@ -52,10 +50,6 @@ interface FishingRarityEntry {
|
||||||
rewardMultiplier: number;
|
rewardMultiplier: number;
|
||||||
reactionWindowMultiplier: number;
|
reactionWindowMultiplier: number;
|
||||||
tensionMultiplier: number;
|
tensionMultiplier: number;
|
||||||
sizeMultiplier: {
|
|
||||||
min: number;
|
|
||||||
max: number;
|
|
||||||
};
|
|
||||||
backgroundColor: string;
|
backgroundColor: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -80,7 +74,6 @@ 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;
|
||||||
|
|
@ -224,55 +217,6 @@ 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);
|
||||||
|
|
@ -328,7 +272,6 @@ 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;
|
||||||
|
|
@ -373,8 +316,6 @@ 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') {
|
||||||
|
|
@ -539,7 +480,6 @@ 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,
|
||||||
|
|
@ -613,7 +553,6 @@ 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),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
@ -621,11 +560,6 @@ 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)) {
|
||||||
|
|
@ -765,111 +699,6 @@ 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 '🟣';
|
||||||
|
|
@ -906,12 +735,6 @@ 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 '⏺️';
|
||||||
|
|
@ -930,14 +753,6 @@ 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