diff --git a/Docs/Plans/Fishing_MiniGame_Plan.md b/Docs/Plans/Fishing_MiniGame_Plan.md index b05a963..720f692 100644 --- a/Docs/Plans/Fishing_MiniGame_Plan.md +++ b/Docs/Plans/Fishing_MiniGame_Plan.md @@ -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 추가 ## 검증 / 테스트 diff --git a/Docs/WorkDone/2026-04-07_Fishing_Dex_And_Size_Implementation.md b/Docs/WorkDone/2026-04-07_Fishing_Dex_And_Size_Implementation.md new file mode 100644 index 0000000..858c542 --- /dev/null +++ b/Docs/WorkDone/2026-04-07_Fishing_Dex_And_Size_Implementation.md @@ -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` + +모든 단계가 정상 통과했다. diff --git a/Docs/WorkDone/2026-04-07_Fishing_MiniGame_Phase2_Implementation.md b/Docs/WorkDone/2026-04-07_Fishing_MiniGame_Phase2_Implementation.md new file mode 100644 index 0000000..2131c20 --- /dev/null +++ b/Docs/WorkDone/2026-04-07_Fishing_MiniGame_Phase2_Implementation.md @@ -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`은 현재 낚시 진행 통계 전용 모델이다. +- 이후 랭킹, 도감, 업적, 장비 시스템은 이 프로필을 기반으로 확장할 수 있다. diff --git a/Docs/WorkDone/2026-04-07_Fishing_Size_Ranking_Implementation.md b/Docs/WorkDone/2026-04-07_Fishing_Size_Ranking_Implementation.md new file mode 100644 index 0000000..267a19e --- /dev/null +++ b/Docs/WorkDone/2026-04-07_Fishing_Size_Ranking_Implementation.md @@ -0,0 +1,26 @@ +# 2026-04-07: 낚시 크기 랭킹 구현 + +## 개요 + +낚시 시스템에 `/fishing ranking` 명령을 추가해, 서버 내 최고 물고기 크기 기록 상위 10개를 조회할 수 있도록 구현했습니다. + +랭킹은 `FishingCollectionEntry`에 저장된 각 유저의 물고기별 최고 크기 기록을 기준으로 정렬하며, 각 항목에는 아래 정보가 포함됩니다. + +- 유저 +- 물고기 종류 +- 최고 레어도 +- 최고 크기(cm) + +## 구현 내용 + +- `/fishing ranking` 서브커맨드 추가 +- 길드 기준 최고 크기 기록 Top 10 조회 서비스 추가 +- 같은 크기일 경우 최고 레어도, 포획 횟수 순으로 정렬 +- 랭킹이 비어 있을 때의 안내 메시지 추가 +- 낚시 기획서에 랭킹 항목 반영 + +## 사용자 확인 포인트 + +- `/fishing ranking` 실행 시 서버 기준 상위 10개 기록이 표시되는지 +- 각 항목에 유저, 물고기, 레어도, 크기가 함께 보이는지 +- 기록이 없는 서버에서는 빈 상태 안내가 표시되는지 diff --git a/Docs/index.md b/Docs/index.md index ffe098a..9589b81 100644 --- a/Docs/index.md +++ b/Docs/index.md @@ -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) diff --git a/prisma/migrations/20260407093500_add_fishing_profile/migration.sql b/prisma/migrations/20260407093500_add_fishing_profile/migration.sql new file mode 100644 index 0000000..8d03473 --- /dev/null +++ b/prisma/migrations/20260407093500_add_fishing_profile/migration.sql @@ -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); diff --git a/prisma/migrations/20260407101000_add_fishing_collection_and_size/migration.sql b/prisma/migrations/20260407101000_add_fishing_collection_and_size/migration.sql new file mode 100644 index 0000000..f870609 --- /dev/null +++ b/prisma/migrations/20260407101000_add_fishing_collection_and_size/migration.sql @@ -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"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 467debc..486d7f9 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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]) +} diff --git a/resource/data/fishing/fish_catalog.json b/resource/data/fishing/fish_catalog.json index 05929f2..6390acf 100644 --- a/resource/data/fishing/fish_catalog.json +++ b/resource/data/fishing/fish_catalog.json @@ -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": { diff --git a/resource/data/fishing/fish_rarities.json b/resource/data/fishing/fish_rarities.json index 9584006..799fdb7 100644 --- a/resource/data/fishing/fish_rarities.json +++ b/resource/data/fishing/fish_rarities.json @@ -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" } ] diff --git a/src/commands/fishing.ts b/src/commands/fishing.ts index e31bc17..db8134b 100644 --- a/src/commands/fishing.ts +++ b/src/commands/fishing.ts @@ -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(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 }); } }, }; diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index b5c7c63..1d567fd 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -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', diff --git a/src/i18n/locales/ko.ts b/src/i18n/locales/ko.ts index 855a598..bb79774 100644 --- a/src/i18n/locales/ko.ts +++ b/src/i18n/locales/ko.ts @@ -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: '휴식 중', diff --git a/src/i18n/types.ts b/src/i18n/types.ts index a18d9ae..967a84e 100644 --- a/src/i18n/types.ts +++ b/src/i18n/types.ts @@ -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; diff --git a/src/services/FishingService.ts b/src/services/FishingService.ts index 6edfaf3..7ed583b 100644 --- a/src/services/FishingService.ts +++ b/src/services/FishingService.ts @@ -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; 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) {