Compare commits

..

6 Commits

15 changed files with 692 additions and 3 deletions

View File

@ -182,7 +182,9 @@
후속 버전에서는 아래 요소를 추가할 수 있습니다. 후속 버전에서는 아래 요소를 추가할 수 있습니다.
- 물고기 희귀도 - 물고기 희귀도
- 물고기 크기(cm) 시스템
- 개별 물고기 인벤토리 - 개별 물고기 인벤토리
- 물고기 도감 / 컬렉션
- 미끼 종류 - 미끼 종류
- 낚싯대 및 업그레이드 - 낚싯대 및 업그레이드
- 물고기 판매 시스템 - 물고기 판매 시스템
@ -253,6 +255,41 @@ Phase 1에서는 직접 `gold`를 주는 방식이 가장 단순하고 호환성
이 정도면 초반 통계 추적에 충분하고, 너무 이르게 인벤토리를 과설계하지 않을 수 있습니다. 이 정도면 초반 통계 추적에 충분하고, 너무 이르게 인벤토리를 과설계하지 않을 수 있습니다.
도감/크기 확장 시에는 아래 모델을 추가합니다.
- `FishingCollectionEntry`
- `userId`
- `guildId`
- `fishId`
- `catchCount`
- `bestRarity`
- `bestSizeCm`
- `lastCaughtAt`
- `createdAt`
- `updatedAt`
이 모델은 유저가 어떤 물고기를 몇 번 잡았는지, 최고 레어도와 최고 크기가 무엇인지 추적하는 데 사용합니다.
### 물고기 크기 시스템
- 낚시 성공 시 물고기마다 `cm` 단위의 크기를 부여합니다.
- 기본 크기 범위는 물고기별 데이터에서 관리합니다.
- 최종 크기는 `물고기 기본 크기 범위 × 레어도 보정치`로 계산합니다.
- 즉, 같은 물고기라도 레어도가 높을수록 더 큰 개체가 등장할 수 있습니다.
- 성공 결과 메시지에는 잡은 물고기의 크기를 함께 표시합니다.
- 도감에는 해당 물고기의 최고 크기를 기록합니다.
### 도감 (Dex / Collection)
- `/fishing dex` 명령을 통해 개인 도감을 조회할 수 있어야 합니다.
- 도감에는 아래 정보를 보여줍니다.
- 잡아본 물고기 목록
- 각 물고기의 포획 횟수
- 최고 레어도
- 최고 크기(cm)
- 마지막 포획 시각
- 아직 잡지 못한 물고기는 후속 버전에서 실루엣/잠금 상태로 표시할 수 있습니다.
### 세션 모델 ### 세션 모델
낚시 플레이 자체는 즉각적인 반응을 위해 메모리 세션 기반이 적합합니다. 낚시 플레이 자체는 즉각적인 반응을 위해 메모리 세션 기반이 적합합니다.
@ -318,17 +355,23 @@ Phase 1에서는 직접 `gold`를 주는 방식이 가장 단순하고 호환성
### Phase 2 ### Phase 2
- `/fishing status` 추가 - `/fishing status` 추가
- `/fishing ranking` 추가
- 사용자별 낚시 통계 추가 - 사용자별 낚시 통계 추가
- 낚시 프로필 영속화 - 낚시 프로필 영속화
- 물고기 이동 패턴 개선 - 물고기 이동 패턴 개선
- 보상 밸런스 조정 - 보상 밸런스 조정
- `/fishing dex` 추가
- 물고기 크기(cm) 시스템 추가
- 도감용 포획 기록 저장
- 길드 내 최고 크기 기준 Top 10 랭킹 표시
### Phase 3 ### Phase 3
- 희귀도 체계 추가 - 희귀도 체계 추가
- 인벤토리 / 도감 추가 - 인벤토리 / 도감 고도화
- 미끼 / 낚싯대 보정치 추가 - 미끼 / 낚싯대 보정치 추가
- 리더보드 지원 - 리더보드 지원
- 미포획 물고기 잠금/실루엣 UI 추가
## 검증 / 테스트 ## 검증 / 테스트

View File

@ -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`
모든 단계가 정상 통과했다.

View File

@ -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`은 현재 낚시 진행 통계 전용 모델이다.
- 이후 랭킹, 도감, 업적, 장비 시스템은 이 프로필을 기반으로 확장할 수 있다.

View File

@ -0,0 +1,26 @@
# 2026-04-07: 낚시 크기 랭킹 구현
## 개요
낚시 시스템에 `/fishing ranking` 명령을 추가해, 서버 내 최고 물고기 크기 기록 상위 10개를 조회할 수 있도록 구현했습니다.
랭킹은 `FishingCollectionEntry`에 저장된 각 유저의 물고기별 최고 크기 기록을 기준으로 정렬하며, 각 항목에는 아래 정보가 포함됩니다.
- 유저
- 물고기 종류
- 최고 레어도
- 최고 크기(cm)
## 구현 내용
- `/fishing ranking` 서브커맨드 추가
- 길드 기준 최고 크기 기록 Top 10 조회 서비스 추가
- 같은 크기일 경우 최고 레어도, 포획 횟수 순으로 정렬
- 랭킹이 비어 있을 때의 안내 메시지 추가
- 낚시 기획서에 랭킹 항목 반영
## 사용자 확인 포인트
- `/fishing ranking` 실행 시 서버 기준 상위 10개 기록이 표시되는지
- 각 항목에 유저, 물고기, 레어도, 크기가 함께 보이는지
- 기록이 없는 서버에서는 빈 상태 안내가 표시되는지

View File

@ -58,3 +58,6 @@
- [2026-03-30: 미니게임 시스템 및 재련 게임 구현 (Mini-Game System & Refinement Implementation)](WorkDone/2026-03-30_RefinementImplementation.md) - [2026-03-30: 미니게임 시스템 및 재련 게임 구현 (Mini-Game System & Refinement Implementation)](WorkDone/2026-03-30_RefinementImplementation.md)
- [2026-03-31: 낚시 미니게임 Phase 1 구현 (Fishing Mini-Game Phase 1 Implementation)](WorkDone/2026-03-31_Fishing_MiniGame_Phase1_Implementation.md) - [2026-03-31: 낚시 미니게임 Phase 1 구현 (Fishing Mini-Game Phase 1 Implementation)](WorkDone/2026-03-31_Fishing_MiniGame_Phase1_Implementation.md)
- [2026-03-31: 낚시 미니게임 Phase 1 완료 (Fishing Mini-Game Phase 1 Completion)](WorkDone/2026-03-31_Fishing_MiniGame_Phase1_Completion.md) - [2026-03-31: 낚시 미니게임 Phase 1 완료 (Fishing Mini-Game Phase 1 Completion)](WorkDone/2026-03-31_Fishing_MiniGame_Phase1_Completion.md)
- [2026-04-07: 낚시 미니게임 Phase 2 구현 (Fishing Mini-Game Phase 2 Implementation)](WorkDone/2026-04-07_Fishing_MiniGame_Phase2_Implementation.md)
- [2026-04-07: 낚시 도감 및 크기 시스템 구현 (Fishing Dex and Size Implementation)](WorkDone/2026-04-07_Fishing_Dex_And_Size_Implementation.md)
- [2026-04-07: 낚시 크기 랭킹 구현 (Fishing Size Ranking Implementation)](WorkDone/2026-04-07_Fishing_Size_Ranking_Implementation.md)

View File

@ -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);

View File

@ -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");

View File

@ -229,3 +229,40 @@ model FeverState {
expiresAt DateTime? expiresAt DateTime?
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
} }
model FishingProfile {
userId String
guildId String
totalCastCount Int @default(0)
successCount Int @default(0)
failCount Int @default(0)
totalGoldEarned Int @default(0)
bestCatchReward Int @default(0)
commonCatchCount Int @default(0)
uncommonCatchCount Int @default(0)
rareCatchCount Int @default(0)
epicCatchCount Int @default(0)
legendaryCatchCount Int @default(0)
lastCastAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@id([userId, guildId])
@@index([guildId, successCount(sort: Desc)])
}
model FishingCollectionEntry {
userId String
guildId String
fishId String
catchCount Int @default(0)
bestRarityId String
bestRarityRank Int @default(0)
bestSizeCm Float @default(0)
lastCaughtAt DateTime
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@id([userId, guildId, fishId])
@@index([guildId, userId])
}

View File

@ -11,6 +11,10 @@
"min": 120, "min": 120,
"max": 180 "max": 180
}, },
"sizeCm": {
"min": 9,
"max": 16
},
"reactionWindowSec": 2.0, "reactionWindowSec": 2.0,
"distanceReductionByPosition": { "distanceReductionByPosition": {
"left": { "left": {
@ -38,6 +42,10 @@
"min": 150, "min": 150,
"max": 220 "max": 220
}, },
"sizeCm": {
"min": 18,
"max": 32
},
"reactionWindowSec": 1.8, "reactionWindowSec": 1.8,
"distanceReductionByPosition": { "distanceReductionByPosition": {
"left": { "left": {
@ -65,6 +73,10 @@
"min": 180, "min": 180,
"max": 260 "max": 260
}, },
"sizeCm": {
"min": 14,
"max": 26
},
"reactionWindowSec": 1.6, "reactionWindowSec": 1.6,
"distanceReductionByPosition": { "distanceReductionByPosition": {
"left": { "left": {
@ -92,6 +104,10 @@
"min": 200, "min": 200,
"max": 300 "max": 300
}, },
"sizeCm": {
"min": 22,
"max": 38
},
"reactionWindowSec": 1.5, "reactionWindowSec": 1.5,
"distanceReductionByPosition": { "distanceReductionByPosition": {
"left": { "left": {
@ -119,6 +135,10 @@
"min": 230, "min": 230,
"max": 340 "max": 340
}, },
"sizeCm": {
"min": 35,
"max": 70
},
"reactionWindowSec": 1.4, "reactionWindowSec": 1.4,
"distanceReductionByPosition": { "distanceReductionByPosition": {
"left": { "left": {
@ -146,6 +166,10 @@
"min": 260, "min": 260,
"max": 380 "max": 380
}, },
"sizeCm": {
"min": 12,
"max": 24
},
"reactionWindowSec": 1.3, "reactionWindowSec": 1.3,
"distanceReductionByPosition": { "distanceReductionByPosition": {
"left": { "left": {
@ -173,6 +197,10 @@
"min": 320, "min": 320,
"max": 460 "max": 460
}, },
"sizeCm": {
"min": 28,
"max": 55
},
"reactionWindowSec": 1.25, "reactionWindowSec": 1.25,
"distanceReductionByPosition": { "distanceReductionByPosition": {
"left": { "left": {
@ -200,6 +228,10 @@
"min": 380, "min": 380,
"max": 540 "max": 540
}, },
"sizeCm": {
"min": 20,
"max": 42
},
"reactionWindowSec": 1.2, "reactionWindowSec": 1.2,
"distanceReductionByPosition": { "distanceReductionByPosition": {
"left": { "left": {
@ -227,6 +259,10 @@
"min": 520, "min": 520,
"max": 720 "max": 720
}, },
"sizeCm": {
"min": 90,
"max": 160
},
"reactionWindowSec": 1.2, "reactionWindowSec": 1.2,
"distanceReductionByPosition": { "distanceReductionByPosition": {
"left": { "left": {
@ -254,6 +290,10 @@
"min": 800, "min": 800,
"max": 1200 "max": 1200
}, },
"sizeCm": {
"min": 140,
"max": 260
},
"reactionWindowSec": 1.2, "reactionWindowSec": 1.2,
"distanceReductionByPosition": { "distanceReductionByPosition": {
"left": { "left": {

View File

@ -10,6 +10,10 @@
"rewardMultiplier": 1.0, "rewardMultiplier": 1.0,
"reactionWindowMultiplier": 1.0, "reactionWindowMultiplier": 1.0,
"tensionMultiplier": 1.0, "tensionMultiplier": 1.0,
"sizeMultiplier": {
"min": 1.0,
"max": 1.05
},
"backgroundColor": "#6B7280" "backgroundColor": "#6B7280"
}, },
{ {
@ -20,6 +24,10 @@
"rewardMultiplier": 1.2, "rewardMultiplier": 1.2,
"reactionWindowMultiplier": 1.08, "reactionWindowMultiplier": 1.08,
"tensionMultiplier": 1.08, "tensionMultiplier": 1.08,
"sizeMultiplier": {
"min": 1.02,
"max": 1.12
},
"backgroundColor": "#22C55E" "backgroundColor": "#22C55E"
}, },
{ {
@ -30,6 +38,10 @@
"rewardMultiplier": 1.55, "rewardMultiplier": 1.55,
"reactionWindowMultiplier": 1.16, "reactionWindowMultiplier": 1.16,
"tensionMultiplier": 1.14, "tensionMultiplier": 1.14,
"sizeMultiplier": {
"min": 1.1,
"max": 1.25
},
"backgroundColor": "#3B82F6" "backgroundColor": "#3B82F6"
}, },
{ {
@ -40,6 +52,10 @@
"rewardMultiplier": 2.1, "rewardMultiplier": 2.1,
"reactionWindowMultiplier": 1.28, "reactionWindowMultiplier": 1.28,
"tensionMultiplier": 1.24, "tensionMultiplier": 1.24,
"sizeMultiplier": {
"min": 1.22,
"max": 1.45
},
"backgroundColor": "#A855F7" "backgroundColor": "#A855F7"
}, },
{ {
@ -50,6 +66,10 @@
"rewardMultiplier": 3.0, "rewardMultiplier": 3.0,
"reactionWindowMultiplier": 1.42, "reactionWindowMultiplier": 1.42,
"tensionMultiplier": 1.36, "tensionMultiplier": 1.36,
"sizeMultiplier": {
"min": 1.4,
"max": 1.8
},
"backgroundColor": "#F59E0B" "backgroundColor": "#F59E0B"
} }
] ]

View File

@ -1,6 +1,7 @@
import { import {
ChannelType, ChannelType,
ChatInputCommandInteraction, ChatInputCommandInteraction,
EmbedBuilder,
SlashCommandBuilder, SlashCommandBuilder,
} from 'discord.js'; } from 'discord.js';
import { prisma } from '../database'; import { prisma } from '../database';
@ -37,6 +38,46 @@ export default {
.setDescriptionLocalizations({ .setDescriptionLocalizations({
ko: '진행 중인 낚시 세션을 종료하고 스레드를 삭제합니다.', ko: '진행 중인 낚시 세션을 종료하고 스레드를 삭제합니다.',
}), }),
)
.addSubcommand((subcommand) =>
subcommand
.setName('status')
.setDescription('View fishing statistics.')
.setDescriptionLocalizations({
ko: '낚시 통계를 확인합니다.',
})
.addUserOption((option) =>
option
.setName('user')
.setDescription('User to view')
.setDescriptionLocalizations({
ko: '조회할 유저',
}),
),
)
.addSubcommand((subcommand) =>
subcommand
.setName('dex')
.setDescription('View your fishing collection book.')
.setDescriptionLocalizations({
ko: '낚시 도감을 확인합니다.',
})
.addUserOption((option) =>
option
.setName('user')
.setDescription('User to view')
.setDescriptionLocalizations({
ko: '조회할 유저',
}),
),
)
.addSubcommand((subcommand) =>
subcommand
.setName('ranking')
.setDescription('View the biggest fish size ranking in this server.')
.setDescriptionLocalizations({
ko: '이 서버의 물고기 크기 랭킹을 확인합니다.',
}),
), ),
async execute(interaction: ChatInputCommandInteraction, locale: SupportedLocale) { async execute(interaction: ChatInputCommandInteraction, locale: SupportedLocale) {
@ -116,6 +157,135 @@ export default {
await interaction.editReply({ await interaction.editReply({
content: t(locale, 'commands.fishing.endDeleted'), content: t(locale, 'commands.fishing.endDeleted'),
}); });
return;
}
if (subcommand === 'status') {
const targetUser = interaction.options.getUser('user') ?? interaction.user;
const profile = await FishingService.getProfile(targetUser.id, interaction.guildId);
const totalCasts = profile?.totalCastCount ?? 0;
const successCount = profile?.successCount ?? 0;
const failCount = profile?.failCount ?? 0;
const totalGoldEarned = profile?.totalGoldEarned ?? 0;
const bestCatchReward = profile?.bestCatchReward ?? 0;
const successRate = totalCasts > 0 ? ((successCount / totalCasts) * 100).toFixed(1) : '0.0';
const rarityBreakdown = [
`${profile?.commonCatchCount ?? 0}`,
`🟢 ${profile?.uncommonCatchCount ?? 0}`,
`🔵 ${profile?.rareCatchCount ?? 0}`,
`🟣 ${profile?.epicCatchCount ?? 0}`,
`🟠 ${profile?.legendaryCatchCount ?? 0}`,
].join('\n');
const embed = new EmbedBuilder()
.setColor(0x3b82f6)
.setTitle(t(locale, 'commands.fishing.profileTitle', { user: targetUser.username }))
.setThumbnail(targetUser.displayAvatarURL())
.addFields(
{
name: t(locale, 'commands.fishing.totalCasts'),
value: String(totalCasts),
inline: true,
},
{
name: t(locale, 'commands.fishing.successRate'),
value: `${successRate}% (${successCount}/${successCount + failCount})`,
inline: true,
},
{
name: t(locale, 'commands.fishing.totalGoldEarned'),
value: `${totalGoldEarned} G`,
inline: true,
},
{
name: t(locale, 'commands.fishing.bestCatchReward'),
value: `${bestCatchReward} G`,
inline: true,
},
{
name: t(locale, 'commands.fishing.lastCastAt'),
value: profile?.lastCastAt
? `<t:${Math.floor(profile.lastCastAt.getTime() / 1000)}:R>`
: t(locale, 'commands.fishing.noRecord'),
inline: true,
},
{
name: t(locale, 'commands.fishing.rarityBreakdown'),
value: rarityBreakdown,
inline: false,
},
);
if (!profile) {
embed.setDescription(t(locale, 'commands.fishing.profileEmpty'));
}
await interaction.editReply({ embeds: [embed] });
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.editReply({ embeds: [embed] });
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.editReply({ embeds: [embed] });
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.editReply({ embeds: [embed] });
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.editReply({ embeds: [embed] });
} }
}, },
}; };

View File

@ -263,6 +263,9 @@ export const en: TranslationSchema = {
enterDescription: 'Create or reopen your fishing thread.', enterDescription: 'Create or reopen your fishing thread.',
castDescription: 'Start a fishing session inside your fishing thread.', castDescription: 'Start a fishing session inside your fishing thread.',
endDescription: 'End your fishing thread and delete it.', endDescription: 'End your fishing thread and delete it.',
statusDescription: 'View fishing statistics.',
dexDescription: 'View your fishing collection book.',
rankingDescription: 'View the biggest fish size ranking in this server.',
disabled: 'The fishing mini-game is disabled in this server.', disabled: 'The fishing mini-game is disabled in this server.',
restrictedChannel: 'Fishing can only be started in {{channel}}.', restrictedChannel: 'Fishing can only be started in {{channel}}.',
enterTextChannelOnly: 'Fishing threads can only be opened from a regular text channel.', enterTextChannelOnly: 'Fishing threads can only be opened from a regular text channel.',
@ -275,17 +278,34 @@ export const en: TranslationSchema = {
ownerOnly: 'Only the owner of this fishing session can use these controls.', ownerOnly: 'Only the owner of this fishing session can use these controls.',
wrongThread: 'This fishing control can only be used inside your fishing thread.', wrongThread: 'This fishing control can only be used inside your fishing thread.',
endDeleted: 'Your fishing thread has been closed and is being deleted.', endDeleted: 'Your fishing thread has been closed and is being deleted.',
profileTitle: '{{user}} Fishing Profile',
profileEmpty: 'There is no fishing record yet.',
dexTitle: '{{user}} Fishing Dex',
dexEmpty: 'There are no discovered fish yet.',
rankingTitle: 'Fishing Size Ranking',
rankingEmpty: 'There are no fishing records in this server yet.',
titleActive: 'Fishing Session', titleActive: 'Fishing Session',
titleEnded: 'Fishing Session Ended', titleEnded: 'Fishing Session Ended',
status: 'Status', status: 'Status',
rarity: 'Rarity', rarity: 'Rarity',
size: 'Size',
catchCount: 'Catch Count',
bestRarity: 'Best Rarity',
bestSize: 'Best Size',
targetFish: 'Target Fish', targetFish: 'Target Fish',
distance: 'Distance', distance: 'Distance',
tension: 'Line Tension', tension: 'Line Tension',
reward: 'Reward', reward: 'Reward',
successRate: 'Success Rate',
totalCasts: 'Total Casts',
totalGoldEarned: 'Total Gold',
bestCatchReward: 'Best Reward',
rarityBreakdown: 'Rarity Breakdown',
lastCastAt: 'Last Cast',
noRecord: 'No record',
threadHint: 'Use /fishing cast to play again, or /fishing end to delete the thread.', threadHint: 'Use /fishing cast to play again, or /fishing end to delete the thread.',
catchResultTitle: 'Big Catch!', catchResultTitle: 'Big Catch!',
catchResultBody: 'You caught a **{{rarity}} {{fish}}** and earned **{{reward}} G**.', catchResultBody: 'You caught a **{{rarity}} {{fish}}** measuring **{{sizeCm}} cm** and earned **{{reward}} G**.',
states: { states: {
hooked: 'Hooked', hooked: 'Hooked',
resting: 'Resting', resting: 'Resting',

View File

@ -261,6 +261,9 @@ export const ko: TranslationSchema = {
enterDescription: '낚시 전용 스레드를 생성하거나 다시 엽니다.', enterDescription: '낚시 전용 스레드를 생성하거나 다시 엽니다.',
castDescription: '자신의 낚시 스레드 안에서 낚시 세션을 시작합니다.', castDescription: '자신의 낚시 스레드 안에서 낚시 세션을 시작합니다.',
endDescription: '낚시 스레드를 종료하고 삭제합니다.', endDescription: '낚시 스레드를 종료하고 삭제합니다.',
statusDescription: '낚시 통계를 확인합니다.',
dexDescription: '낚시 도감을 확인합니다.',
rankingDescription: '이 서버의 물고기 크기 랭킹을 확인합니다.',
disabled: '이 서버에서는 낚시 미니게임이 비활성화되어 있습니다.', disabled: '이 서버에서는 낚시 미니게임이 비활성화되어 있습니다.',
restrictedChannel: '낚시는 {{channel}} 채널에서만 시작할 수 있습니다.', restrictedChannel: '낚시는 {{channel}} 채널에서만 시작할 수 있습니다.',
enterTextChannelOnly: '낚시 스레드는 일반 텍스트 채널에서만 열 수 있습니다.', enterTextChannelOnly: '낚시 스레드는 일반 텍스트 채널에서만 열 수 있습니다.',
@ -273,17 +276,34 @@ export const ko: TranslationSchema = {
ownerOnly: '이 낚시 세션의 소유자만 조작할 수 있습니다.', ownerOnly: '이 낚시 세션의 소유자만 조작할 수 있습니다.',
wrongThread: '이 조작은 자신의 낚시 스레드 안에서만 사용할 수 있습니다.', wrongThread: '이 조작은 자신의 낚시 스레드 안에서만 사용할 수 있습니다.',
endDeleted: '낚시 스레드를 종료했습니다. 스레드를 삭제합니다.', endDeleted: '낚시 스레드를 종료했습니다. 스레드를 삭제합니다.',
profileTitle: '{{user}}의 낚시 프로필',
profileEmpty: '아직 낚시 기록이 없습니다.',
dexTitle: '{{user}}의 낚시 도감',
dexEmpty: '아직 발견한 물고기가 없습니다.',
rankingTitle: '낚시 크기 랭킹',
rankingEmpty: '아직 이 서버에 낚시 기록이 없습니다.',
titleActive: '낚시 세션', titleActive: '낚시 세션',
titleEnded: '낚시 세션 종료', titleEnded: '낚시 세션 종료',
status: '상태', status: '상태',
rarity: '레어도', rarity: '레어도',
size: '크기',
catchCount: '포획 수',
bestRarity: '최고 레어도',
bestSize: '최고 크기',
targetFish: '대상 물고기', targetFish: '대상 물고기',
distance: '거리', distance: '거리',
tension: '끊어짐 게이지', tension: '끊어짐 게이지',
reward: '보상', reward: '보상',
successRate: '성공률',
totalCasts: '총 시도',
totalGoldEarned: '누적 골드',
bestCatchReward: '최고 보상',
rarityBreakdown: '레어도별 포획',
lastCastAt: '최근 낚시',
noRecord: '기록 없음',
threadHint: '/fishing cast로 다시 시작하거나 /fishing end로 스레드를 삭제할 수 있습니다.', threadHint: '/fishing cast로 다시 시작하거나 /fishing end로 스레드를 삭제할 수 있습니다.',
catchResultTitle: '낚시 성공!', catchResultTitle: '낚시 성공!',
catchResultBody: '**{{rarity}} {{fish}}**를 낚았습니다. **{{reward}} G**를 획득했습니다.', catchResultBody: '**{{rarity}} {{fish}}**를 낚았습니다. 크기는 **{{sizeCm}} cm**, 보상은 **{{reward}} G**입니다.',
states: { states: {
hooked: '입질 중', hooked: '입질 중',
resting: '휴식 중', resting: '휴식 중',

View File

@ -217,6 +217,9 @@ export interface TranslationSchema {
enterDescription: string; enterDescription: string;
castDescription: string; castDescription: string;
endDescription: string; endDescription: string;
statusDescription: string;
dexDescription: string;
rankingDescription: string;
disabled: string; disabled: string;
restrictedChannel: string; restrictedChannel: string;
enterTextChannelOnly: string; enterTextChannelOnly: string;
@ -229,14 +232,31 @@ export interface TranslationSchema {
ownerOnly: string; ownerOnly: string;
wrongThread: string; wrongThread: string;
endDeleted: string; endDeleted: string;
profileTitle: string;
profileEmpty: string;
dexTitle: string;
dexEmpty: string;
rankingTitle: string;
rankingEmpty: string;
titleActive: string; titleActive: string;
titleEnded: string; titleEnded: string;
status: string; status: string;
rarity: string; rarity: string;
size: string;
catchCount: string;
bestRarity: string;
bestSize: string;
targetFish: string; targetFish: string;
distance: string; distance: string;
tension: string; tension: string;
reward: string; reward: string;
successRate: string;
totalCasts: string;
totalGoldEarned: string;
bestCatchReward: string;
rarityBreakdown: string;
lastCastAt: string;
noRecord: string;
threadHint: string; threadHint: string;
catchResultTitle: string; catchResultTitle: string;
catchResultBody: string; catchResultBody: string;

View File

@ -15,6 +15,7 @@ import {
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import sharp from 'sharp'; import sharp from 'sharp';
import { prisma } from '../database';
import { SupportedLocale, t } from '../i18n'; import { SupportedLocale, t } from '../i18n';
import { RefinementService } from './RefinementService'; import { RefinementService } from './RefinementService';
import { logger } from '../utils/logger'; import { logger } from '../utils/logger';
@ -33,6 +34,7 @@ interface FishingCatalogEntry {
displayName: string; displayName: string;
spawnRate: number; spawnRate: number;
rewardGold: FishingRange; rewardGold: FishingRange;
sizeCm: FishingRange;
reactionWindowSec: number; reactionWindowSec: number;
distanceReductionByPosition: Record<FishingDirection, FishingRange>; distanceReductionByPosition: Record<FishingDirection, FishingRange>;
artResourcePaths: string[]; artResourcePaths: string[];
@ -50,6 +52,10 @@ interface FishingRarityEntry {
rewardMultiplier: number; rewardMultiplier: number;
reactionWindowMultiplier: number; reactionWindowMultiplier: number;
tensionMultiplier: number; tensionMultiplier: number;
sizeMultiplier: {
min: number;
max: number;
};
backgroundColor: string; backgroundColor: string;
} }
@ -74,6 +80,7 @@ interface FishingSession {
lineTension: number; lineTension: number;
status: FishingState; status: FishingState;
reward: number | null; reward: number | null;
catchSizeCm: number | null;
tickInterval: NodeJS.Timeout | null; tickInterval: NodeJS.Timeout | null;
isRendering: boolean; isRendering: boolean;
needsRender: boolean; needsRender: boolean;
@ -217,6 +224,55 @@ export class FishingService {
await this.queueAction(session, action as FishingAction); await this.queueAction(session, action as FishingAction);
} }
static async getProfile(userId: string, guildId: string) {
return (prisma as any).fishingProfile.findUnique({
where: {
userId_guildId: { userId, guildId },
},
});
}
static async getCollection(userId: string, guildId: string) {
return (prisma as any).fishingCollectionEntry.findMany({
where: {
userId,
guildId,
},
orderBy: [
{ bestRarityRank: 'desc' },
{ bestSizeCm: 'desc' },
{ catchCount: 'desc' },
],
});
}
static async getSizeRanking(guildId: string) {
return (prisma as any).fishingCollectionEntry.findMany({
where: {
guildId,
},
orderBy: [
{ bestSizeCm: 'desc' },
{ bestRarityRank: 'desc' },
{ catchCount: 'desc' },
{ lastCaughtAt: 'asc' },
],
take: 10,
});
}
static getFishDisplayName(fishId: string) {
return this.fishingCatalog.find((fish) => fish.id === fishId)?.displayName ?? fishId;
}
static getRarityDisplayNameById(rarityId: string, locale: SupportedLocale) {
const rarity = this.fishingRarities.find((entry) => entry.id === rarityId);
if (!rarity) {
return rarityId;
}
return this.getRarityDisplayName(rarity, locale);
}
private static async tickSession(session: FishingSession) { private static async tickSession(session: FishingSession) {
if (!this.sessionsByUser.has(this.getUserKey(session.guildId, session.userId))) { if (!this.sessionsByUser.has(this.getUserKey(session.guildId, session.userId))) {
this.clearTick(session); this.clearTick(session);
@ -272,6 +328,7 @@ export class FishingService {
Math.round(this.rollRange(session.currentFish.rewardGold) * session.currentRarity.rewardMultiplier), Math.round(this.rollRange(session.currentFish.rewardGold) * session.currentRarity.rewardMultiplier),
); );
session.reward = reward; session.reward = reward;
session.catchSizeCm = this.rollCatchSizeCm(session.currentFish, session.currentRarity);
await RefinementService.addGold(session.userId, session.guildId, reward); await RefinementService.addGold(session.userId, session.guildId, reward);
await this.finishSession(session, 'success', false); await this.finishSession(session, 'success', false);
return; return;
@ -316,6 +373,8 @@ export class FishingService {
logger.info(`[Fishing] Finished session for ${session.userId} with state ${finalState}.`); logger.info(`[Fishing] Finished session for ${session.userId} with state ${finalState}.`);
await this.recordProfileResult(session, finalState);
await this.renderSession(session, true); await this.renderSession(session, true);
if (finalState === 'success') { if (finalState === 'success') {
@ -480,6 +539,7 @@ export class FishingService {
lineTension: 0, lineTension: 0,
status: 'hooked' as FishingState, status: 'hooked' as FishingState,
reward: null, reward: null,
catchSizeCm: null,
tickInterval: null, tickInterval: null,
isRendering: false, isRendering: false,
needsRender: false, needsRender: false,
@ -553,6 +613,7 @@ export class FishingService {
t(session.locale, 'commands.fishing.catchResultBody', { t(session.locale, 'commands.fishing.catchResultBody', {
rarity: rarityName, rarity: rarityName,
fish: session.currentFish.displayName, fish: session.currentFish.displayName,
sizeCm: (session.catchSizeCm ?? 0).toFixed(1),
reward: String(session.reward ?? 0), reward: String(session.reward ?? 0),
}), }),
) )
@ -560,6 +621,11 @@ export class FishingService {
name: t(session.locale, 'commands.fishing.rarity'), name: t(session.locale, 'commands.fishing.rarity'),
value: rarityName, value: rarityName,
inline: true, inline: true,
})
.addFields({
name: t(session.locale, 'commands.fishing.size'),
value: `${(session.catchSizeCm ?? 0).toFixed(1)} cm`,
inline: true,
}); });
if (artPath && fs.existsSync(artPath)) { if (artPath && fs.existsSync(artPath)) {
@ -699,6 +765,111 @@ export class FishingService {
return locale === 'ko' && rarity.displayNameKo ? rarity.displayNameKo : rarity.displayName; return locale === 'ko' && rarity.displayNameKo ? rarity.displayNameKo : rarity.displayName;
} }
private static async recordProfileResult(session: FishingSession, finalState: 'success' | 'failed') {
const reward = session.reward ?? 0;
const rarityField = this.getRarityCountField(session.currentRarity.id);
const existingProfile = await this.getProfile(session.userId, session.guildId);
await (prisma as any).fishingProfile.upsert({
where: {
userId_guildId: {
userId: session.userId,
guildId: session.guildId,
},
},
create: {
userId: session.userId,
guildId: session.guildId,
totalCastCount: 1,
successCount: finalState === 'success' ? 1 : 0,
failCount: finalState === 'failed' ? 1 : 0,
totalGoldEarned: reward,
bestCatchReward: reward,
commonCatchCount: rarityField === 'commonCatchCount' && finalState === 'success' ? 1 : 0,
uncommonCatchCount: rarityField === 'uncommonCatchCount' && finalState === 'success' ? 1 : 0,
rareCatchCount: rarityField === 'rareCatchCount' && finalState === 'success' ? 1 : 0,
epicCatchCount: rarityField === 'epicCatchCount' && finalState === 'success' ? 1 : 0,
legendaryCatchCount: rarityField === 'legendaryCatchCount' && finalState === 'success' ? 1 : 0,
lastCastAt: new Date(),
},
update: {
totalCastCount: { increment: 1 },
successCount: finalState === 'success' ? { increment: 1 } : undefined,
failCount: finalState === 'failed' ? { increment: 1 } : undefined,
totalGoldEarned: reward > 0 ? { increment: reward } : undefined,
bestCatchReward: reward > 0 ? Math.max(reward, existingProfile?.bestCatchReward ?? 0) : undefined,
[rarityField]: finalState === 'success' ? { increment: 1 } : undefined,
lastCastAt: new Date(),
},
});
if (finalState === 'success') {
await this.recordCollectionCatch(session);
}
}
private static async recordCollectionCatch(session: FishingSession) {
const rarityRank = this.getRarityRank(session.currentRarity.id);
const existingEntry = await (prisma as any).fishingCollectionEntry.findUnique({
where: {
userId_guildId_fishId: {
userId: session.userId,
guildId: session.guildId,
fishId: session.currentFish.id,
},
},
});
const bestRarityRank = Math.max(existingEntry?.bestRarityRank ?? 0, rarityRank);
const bestRarityId = bestRarityRank === rarityRank
? session.currentRarity.id
: existingEntry?.bestRarityId ?? session.currentRarity.id;
const bestSizeCm = Math.max(existingEntry?.bestSizeCm ?? 0, session.catchSizeCm ?? 0);
await (prisma as any).fishingCollectionEntry.upsert({
where: {
userId_guildId_fishId: {
userId: session.userId,
guildId: session.guildId,
fishId: session.currentFish.id,
},
},
create: {
userId: session.userId,
guildId: session.guildId,
fishId: session.currentFish.id,
catchCount: 1,
bestRarityId: session.currentRarity.id,
bestRarityRank: rarityRank,
bestSizeCm: session.catchSizeCm ?? 0,
lastCaughtAt: new Date(),
},
update: {
catchCount: { increment: 1 },
bestRarityId,
bestRarityRank,
bestSizeCm,
lastCaughtAt: new Date(),
},
});
}
private static getRarityCountField(rarityId: string) {
if (rarityId === 'legendary') return 'legendaryCatchCount';
if (rarityId === 'epic') return 'epicCatchCount';
if (rarityId === 'rare') return 'rareCatchCount';
if (rarityId === 'uncommon') return 'uncommonCatchCount';
return 'commonCatchCount';
}
private static getRarityRank(rarityId: string) {
if (rarityId === 'legendary') return 5;
if (rarityId === 'epic') return 4;
if (rarityId === 'rare') return 3;
if (rarityId === 'uncommon') return 2;
return 1;
}
private static getRarityBadge(rarityId: string) { private static getRarityBadge(rarityId: string) {
if (rarityId === 'legendary') return '🟠'; if (rarityId === 'legendary') return '🟠';
if (rarityId === 'epic') return '🟣'; if (rarityId === 'epic') return '🟣';
@ -735,6 +906,12 @@ export class FishingService {
return Number.parseInt(value.replace('#', ''), 16); return Number.parseInt(value.replace('#', ''), 16);
} }
private static rollCatchSizeCm(fish: FishingCatalogEntry, rarity: FishingRarityEntry) {
const base = this.rollDecimalRange(fish.sizeCm);
const multiplier = this.rollDecimalRange(rarity.sizeMultiplier);
return Math.round(base * multiplier * 10) / 10;
}
private static formatSelectedAction(action: FishingAction | null) { private static formatSelectedAction(action: FishingAction | null) {
if (action === 'left') return '⬅️'; if (action === 'left') return '⬅️';
if (action === 'center') return '⏺️'; if (action === 'center') return '⏺️';
@ -753,6 +930,14 @@ export class FishingService {
private static getUserKey(guildId: string, userId: string) { private static getUserKey(guildId: string, userId: string) {
return `${guildId}:${userId}`; return `${guildId}:${userId}`;
} }
private static rollDecimalRange(range: { min: number; max: number }) {
if (range.min === range.max) {
return range.min;
}
return range.min + Math.random() * (range.max - range.min);
}
} }
export function buildFishingGauge(current: number, max: number, width: number) { export function buildFishingGauge(current: number, max: number, width: number) {