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