diff --git a/Docs/Plans/Fishing_MiniGame_Plan.md b/Docs/Plans/Fishing_MiniGame_Plan.md new file mode 100644 index 0000000..b05a963 --- /dev/null +++ b/Docs/Plans/Fishing_MiniGame_Plan.md @@ -0,0 +1,367 @@ +# 낚시 미니게임 구현 기획안 + +이 문서는 Kord의 `낚시(Fishing)` 미니게임 시스템에 대한 설계 방향과 구현 범위를 정리한 기획서입니다. + +## 개요 + +낚시 미니게임은 기존 미니게임 경제 시스템에서 사용할 재화를 공급하는, 실시간 이모지 조작형 미니게임입니다. + +사용자는 1초마다 갱신되는 상태 메시지를 보며 입력을 미리 선택하고, 물고기가 특정 위치에 일정 시간 머문 뒤 그 입력이 맞았는지 판정받습니다. 핵심 목표는 물고기를 끌어와 거리를 줄이는 동시에, 낚시줄 끊어짐 게이지가 가득 차지 않도록 관리하는 것입니다. + +이 게임은 단순 슬래시 명령 기반이 아니라, 버튼 조작과 실시간 게이지 변화, 물고기 이모지 표시를 통해 더 직접적인 조작감을 주는 것을 목표로 합니다. + +## 핵심 게임 루프 + +### 1. 스레드 입장과 세션 시작 + +- 사용자는 먼저 `/fishing enter` 명령으로 낚시 전용 스레드에 입장합니다. +- 봇은 명령을 실행한 채널에 `UserName's Fishing Spot` 형식의 전용 스레드를 생성합니다. +- 이미 같은 사용자의 낚시 스레드가 있으면 기존 스레드를 재사용합니다. +- 실제 낚시 세션 시작은 전용 스레드 안에서 `/fishing cast`를 입력했을 때만 가능합니다. +- 즉, 스레드 생성과 게임 시작은 별도 단계로 분리합니다. +- 봇은 실시간 게임 메시지를 생성합니다. +- 메시지에는 다음 요소가 포함됩니다. +- 중앙에 표시되는 물고기 이모지 +- 거리 게이지 +- 낚시줄 끊어짐 게이지 +- 네 개의 조작 버튼 +- 현재 라운드에 예약된 입력 +- 다음 판정까지 남은 시간 + +### 1-1. 전용 스레드 규칙 + +- 스레드 이름은 영문 기준 `UserName's Fishing Spot` 형식을 사용합니다. +- 낚시 게임 메시지, 버튼 입력, 결과 메시지는 모두 해당 스레드 안에서만 처리합니다. +- 이렇게 하면 일반 채팅이 밀려도 낚시 UI가 섞이지 않고 유지됩니다. +- 한 사용자는 동시에 하나의 낚시 스레드만 활성화할 수 있습니다. +- `/fishing cast`는 자신의 낚시 스레드 안에서만 사용할 수 있습니다. +- 성공이나 실패로 게임이 끝나더라도 스레드는 즉시 삭제하지 않습니다. +- 같은 스레드 안에서 다시 `/fishing cast`를 입력해 연속 플레이할 수 있어야 합니다. +- `/fishing end`를 실행했을 때만 세션을 정리하고 스레드를 삭제합니다. + +### 2. 조작 버튼 + +사용자는 아래 네 가지 버튼 중 하나를 선택할 수 있습니다. + +- `⬅️` 왼쪽 +- `⏺️` 중앙 +- `➡️` 오른쪽 +- `🛌` 휴식 + +### 3. 물고기 위치 + +- 물고기는 텍스트가 아니라 이모지로 표시됩니다. +- 물고기 이모지는 메시지의 시각적 중앙 라인에 위치해야 합니다. +- 내부 상태상 물고기 위치는 왼쪽, 중앙, 오른쪽 중 하나를 가집니다. +- 물고기는 한 라운드 동안 같은 위치에 일정 시간 머뭅니다. +- 사용자는 그 라운드 안에서 방향 버튼을 미리 입력합니다. +- 물고기가 해당 위치에 일정 시간 이상 머문 뒤 입력이 일치하면 성공 판정이 납니다. +- 입력이 없거나 다른 방향이면 `타이밍 빗나감`으로 처리됩니다. + +### 4. 거리 게이지 + +- 물고기를 얼마나 더 끌어와야 하는지를 나타내는 게이지입니다. +- 시작값은 `100` 같은 양수로 설정합니다. +- 방향을 맞춘 입력이 성공 판정되면 거리가 감소합니다. +- 휴식을 선택하면 거리가 다시 증가합니다. +- 거리가 `0`이 되면 낚시에 성공합니다. + +### 5. 낚시줄 끊어짐 게이지 + +- 낚시줄이 얼마나 끊어질 위기에 가까운지를 나타내는 게이지입니다. +- 방향을 맞춘 입력이 성공 판정되면 이 게이지가 증가합니다. +- 타이밍을 놓치면 소량 증가합니다. +- 휴식을 선택하면 이 게이지가 회복됩니다. +- 게이지가 최대치에 도달하면 즉시 낚시에 실패합니다. + +### 6. 휴식 행동 + +- `🛌` 휴식은 해당 라운드에서 즉시 적용됩니다. +- 낚시줄 끊어짐 게이지를 회복합니다. +- 대신 물고기와의 거리가 다시 증가합니다. +- 즉, 휴식은 실패 위험을 낮추지만 진행도를 되돌리는 안전 선택지입니다. + +### 7. 틱 업데이트 + +- 낚시 상태는 1초마다 갱신됩니다. +- 메시지도 1초 주기로 갱신됩니다. +- 이 갱신 루프는 아래 요소를 처리합니다. +- 현재 라운드 경과 시간 계산 +- 입력 예약 상태 반영 +- 성공 / 타이밍 빗나감 판정 +- 다음 물고기 위치로 라운드 전환 +- 게이지 렌더링 갱신 +- 세션 종료 판정 + +## UI / UX 요구사항 + +### 실시간 메시지 구성 + +낚시 메시지는 최소한 아래 요소를 포함해야 합니다. + +- 물고기 이모지로 표시되는 현재 위치 +- 거리 게이지 +- 낚시줄 끊어짐 게이지 +- 간단한 상태 텍스트 +- 현재 예약 입력 +- 다음 판정까지 남은 시간 +- 예: `입질 중`, `타이밍 빗나감`, `휴식 중`, `성공`, `실패` + +### 게이지 디자인 + +항상 아래 두 개의 게이지가 동시에 보여야 합니다. + +1. 거리 게이지 +- 성공을 향해 감소하는 게이지 +- 휴식 시 다시 증가할 수 있음 +- 채워진 블록 / 빈 블록 형태로 표현 가능 + +2. 낚시줄 끊어짐 게이지 +- 실패를 향해 증가하는 게이지 +- 거리 게이지와 시각적으로 구분되어야 함 + +예시: + +```text +위치: ⬅️ 🐟 ➡️ +입력: ⏺️ +남은 시간: 2s +거리: ████████░░ 20 / 100 +끊어짐: ████░░░░░░ 40 / 100 +``` + +최종 렌더링은 더 다듬을 수 있지만, 핵심은 물고기 자체를 반드시 이모지로 유지하는 것입니다. + +### 조작 버튼 + +버튼은 직관적이고 빠르게 눌릴 수 있어야 하므로, 이모지 중심으로 구성합니다. + +- `⬅️` +- `⏺️` +- `➡️` +- `🛌` + +## 성공 / 실패 조건 + +### 성공 + +- 거리 게이지가 `0`이 됩니다. +- 보상을 지급합니다. +- 세션을 종료하고 버튼을 비활성화합니다. + +### 실패 + +- 낚시줄 끊어짐 게이지가 최대치에 도달합니다. +- 세션을 종료하고 버튼을 비활성화합니다. + +### 선택적 시간 제한 + +- 일정 시간 동안 행동하지 않으면 실패하도록 설계할 수도 있습니다. +- 다만 이 기능은 MVP에는 필수가 아니며, 템포가 느릴 때만 후속 단계에서 검토합니다. + +### 종료 명령 + +- `/fishing end` 명령을 제공해 사용자가 직접 낚시 스레드를 종료할 수 있어야 합니다. +- 종료 명령이 실행되면 진행 중인 낚시 세션이 있다면 즉시 정리됩니다. +- 전용 스레드는 `/fishing end`에서만 삭제됩니다. +- 성공/실패 후에는 버튼만 비활성화하고 스레드는 유지합니다. +- 사용자는 같은 스레드 안에서 다시 `/fishing cast`를 입력해 새 게임을 시작할 수 있어야 합니다. + +## 경제 시스템 연동 + +낚시는 기존 미니게임 경제를 보조하는 수단으로 설계합니다. + +### 권장 보상 모델 + +- 낚시에 성공하면 `gold`를 지급합니다. +- 지급된 `gold`는 현재 `refinement` 게임이 사용하는 경제 프로필에 반영합니다. +- 이렇게 하면 낚시는 정련 게임을 보조하는 재화 수급형 미니게임이 됩니다. + +### 확장 가능성 + +후속 버전에서는 아래 요소를 추가할 수 있습니다. + +- 물고기 희귀도 +- 개별 물고기 인벤토리 +- 미끼 종류 +- 낚싯대 및 업그레이드 +- 물고기 판매 시스템 + +Phase 1에서는 직접 `gold`를 주는 방식이 가장 단순하고 호환성이 좋습니다. + +## 설정 모델 + +낚시는 공용 미니게임 레지스트리에 등록되는 구조로 가야 합니다. + +### MiniGame Registry + +추가할 키: + +- `fishing` + +이를 통해 다음이 가능해집니다. + +- 길드별 활성화 / 비활성화 +- 전용 채널 제한 +- `/minigame`을 통한 상태 조회 + +낚시의 경우 전용 채널 안에서 다시 개별 스레드를 생성하는 방식으로 운영합니다. + +## 기술 구조 + +### 서비스 + +권장 신규 서비스: + +- `FishingService` + +주요 책임: + +- 낚시 전용 스레드 생성 / 삭제 +- 세션 생성 / 종료 +- 사용자별 활성 낚시 세션 추적 +- 틱 업데이트 루프 관리 +- 물고기 위치 생성 +- 입력 예약 및 라운드 판정 +- 보상 정산 + +### 인터랙션 처리 + +권장 커스텀 ID prefix: + +- `fishing_` + +기존 구조와 일관성을 유지합니다. + +- `music_` +- `refine_` + +### 영속 데이터 + +권장 초기 모델: + +- `FishingProfile` +- `userId` +- `guildId` +- `totalCatchCount` +- `successCount` +- `failCount` +- `bestCatchReward` +- `lastCastAt` +- `createdAt` +- `updatedAt` + +이 정도면 초반 통계 추적에 충분하고, 너무 이르게 인벤토리를 과설계하지 않을 수 있습니다. + +### 세션 모델 + +낚시 플레이 자체는 즉각적인 반응을 위해 메모리 세션 기반이 적합합니다. + +권장 런타임 필드: + +- `guildId` +- `userId` +- `threadId` +- `messageId` +- `fishPosition` +- `selectedAction` +- `phaseStartedAt` +- `distance` +- `lineTension` +- `tickInterval` +- `expiresAt` + +## 입력 판정 규칙 + +권장 MVP 규칙은 아래와 같습니다. + +- 각 라운드마다 물고기는 하나의 위치에 일정 시간 머뭅니다. +- 플레이어는 그 라운드에서 방향 입력을 미리 예약합니다. +- 물고기가 해당 위치에 `특정 시간 이상` 머문 시점에 입력이 일치하면 +- 거리 감소 +- 낚시줄 끊어짐 게이지 증가 +- 성공 판정 후 다음 라운드로 이동 +- 라운드 시간이 끝날 때까지 올바른 입력이 없으면 +- `타이밍 빗나감` +- 거리 감소 없음 +- 소량의 끊어짐 증가 +- 휴식을 선택하면 +- 낚시줄 끊어짐 게이지 감소 +- 대신 거리 증가 +- 휴식 적용 후 즉시 다음 라운드로 이동 + +이 구조는 다음 긴장감을 만듭니다. + +- 방향을 잘 예약해 두면 안정적으로 거리 감소를 만들 수 있음 +- 너무 공격적으로 당기면 줄이 끊어질 위험이 커짐 +- 휴식은 실패를 막아주지만 물고기가 다시 멀어짐 + +## 단계별 계획 + +### Phase 1 + +- `fishing`을 미니게임 레지스트리에 등록 +- `/fishing enter` 추가 +- `/fishing cast` 추가 +- `/fishing end` 추가 +- `/fishing enter` 실행 시 `UserName's Fishing Spot` 스레드 생성 +- `/fishing cast`는 전용 스레드 안에서만 허용 +- 메모리 기반 낚시 세션 루프 구현 +- 1초 주기 메시지 갱신 +- 입력 예약형 라운드 판정 구현 +- 두 개의 게이지 표시 +- 네 개의 이모지 조작 버튼 추가 +- 성공 시 `gold` 지급 +- 성공/실패 후 스레드 유지 +- `/fishing end` 실행 시 스레드 삭제 + +### Phase 2 + +- `/fishing status` 추가 +- 사용자별 낚시 통계 추가 +- 낚시 프로필 영속화 +- 물고기 이동 패턴 개선 +- 보상 밸런스 조정 + +### Phase 3 + +- 희귀도 체계 추가 +- 인벤토리 / 도감 추가 +- 미끼 / 낚싯대 보정치 추가 +- 리더보드 지원 + +## 검증 / 테스트 + +### 수동 테스트 시나리오 + +1. 허용된 채널에서 `/fishing enter` 실행 +2. `UserName's Fishing Spot` 스레드가 자동 생성되는지 확인 +3. 자신의 낚시 스레드 밖에서는 `/fishing cast`가 거부되는지 확인 +4. 낚시 관련 메시지가 스레드 안에서만 진행되는지 확인 +5. 실시간 메시지에 물고기 이모지와 두 개의 게이지가 보이는지 확인 +6. 방향 버튼을 미리 눌러 예약 입력이 반영되는지 확인 +7. 일정 시간 뒤 방향이 맞았을 때 거리 감소가 적용되는지 확인 +8. 타이밍을 놓치면 `타이밍 빗나감`으로 처리되는지 확인 +9. 휴식을 눌렀을 때 끊어짐 게이지가 회복되고 거리가 증가하는지 확인 +10. 거리 `0` 도달 시 성공 처리되는지 확인 +11. 끊어짐 게이지 최대 도달 시 실패 처리되는지 확인 +12. 세션 종료 후 버튼이 비활성화되고 스레드는 유지되는지 확인 +13. 같은 스레드 안에서 다시 `/fishing cast`가 가능한지 확인 +14. `/fishing end` 실행 시 스레드가 자동 삭제되는지 확인 +15. 보상이 정상 지급되는지 확인 +16. `/minigame` 설정 기반 채널 제한이 적용되는지 확인 + +### 엣지 케이스 + +- 같은 사용자가 중복으로 세션 시작 +- 스레드 삭제 권한이 없을 때 종료 처리 +- 세션 종료 후 오래된 버튼 클릭 +- 낚시 세션 중 봇 재시작 +- 인터랙션 응답 지연 +- 1초 주기 메시지 갱신 시 Discord rate limit 영향 + +## 참고 사항 + +- 1초 갱신 루프는 이 게임의 손맛을 좌우하므로 메시지 갱신 성능을 꼭 확인해야 합니다. +- Discord 메시지는 게임 엔진이 아니므로, 순간 반응보다는 명확한 판정 윈도우가 더 중요합니다. +- 최종 설계는 화려함보다 반응성, 명확성, 실패 상태의 가독성을 우선해야 합니다. diff --git a/Docs/WorkDone/2026-03-31_Fishing_MiniGame_Phase1_Completion.md b/Docs/WorkDone/2026-03-31_Fishing_MiniGame_Phase1_Completion.md new file mode 100644 index 0000000..4e7fbf8 --- /dev/null +++ b/Docs/WorkDone/2026-03-31_Fishing_MiniGame_Phase1_Completion.md @@ -0,0 +1,64 @@ +# 2026-03-31 낚시 미니게임 Phase 1 완료 + +## 개요 + +낚시 미니게임의 Phase 1 범위를 완료했습니다. 이번 완료 단계에서는 전용 스레드 기반 플레이 흐름, 입력 예약형 판정, 물고기 데이터 기반 난이도/보상 시스템, 성공 시 아트 리소스 출력까지 포함해 실제 플레이 가능한 MVP를 마무리했습니다. + +## 완료 범위 + +- `/fishing enter`, `/fishing cast`, `/fishing end` 명령 흐름 확정 +- `UserName's Fishing Spot` 형식의 전용 스레드 생성 및 재사용 +- 낚시 스레드 안에서만 `/fishing cast` 가능하도록 제한 +- 사용자당 1개 활성 낚시 세션 유지 +- `⬅️`, `⏺️`, `➡️`, `🛌` 이모지 버튼 조작 +- 거리 게이지 / 끊어짐 게이지 렌더링 +- 입력 예약형 판정 구조 적용 +- 휴식 시 끊어짐 회복 + 거리 증가 규칙 반영 +- 성공/실패 후 스레드 유지, `/fishing end`에서만 삭제되도록 흐름 개선 +- `resource/data/fishing/fish_catalog.json` 기반 물고기 데이터 연결 +- 물고기별 출현 확률, 골드 범위, 반응 시간, 위치 약점, 아트 경로 적용 +- 성공 시 물고기 PNG 첨부 및 결과 메시지 전송 +- 대상 물고기와 상세 판정 시간은 플레이 중 UI에서 비공개 처리 +- 메시지 갱신 빈도 축소 및 판정 유예 시간 추가로 반응성 개선 + +## 주요 파일 + +- `src/commands/fishing.ts` +- `src/services/FishingService.ts` +- `src/events/interactionCreate.ts` +- `src/services/MiniGameRegistry.ts` +- `src/services/RefinementService.ts` +- `src/i18n/types.ts` +- `src/i18n/locales/en.ts` +- `src/i18n/locales/ko.ts` +- `resource/data/fishing/fish_catalog.json` +- `resource/art/fishing/*` +- `tests/services/FishingService.test.ts` + +## 플레이 흐름 + +1. 사용자가 허용된 텍스트 채널에서 `/fishing enter` +2. 봇이 낚시 전용 스레드를 생성하거나 기존 스레드를 재사용 +3. 사용자가 자신의 낚시 스레드 안에서 `/fishing cast` +4. 버튼 입력으로 거리와 끊어짐 게이지를 관리 +5. 성공 시 물고기 결과 메시지와 PNG 출력, 골드 지급 +6. 같은 스레드에서 다시 `/fishing cast`로 재도전 가능 +7. `/fishing end`로 스레드 종료 및 삭제 + +## 밸런스 / UX 정리 + +- 라운드 길이와 반응 시간은 Discord 버튼 지연을 고려해 완화했습니다. +- 세션 틱은 더 촘촘하게 돌리되, 메시지 수정은 필요한 시점에만 수행하도록 조정했습니다. +- 반복 플레이 피로를 줄이기 위해 물고기별 위치 약점과 거리 감소량은 JSON 데이터 기반 랜덤 범위를 사용합니다. + +## 검증 + +- `yarn build` +- `yarn test --runInBand` + +두 명령 모두 정상 통과했습니다. + +## 비고 + +- 낚시 세션은 메모리 기반이므로 봇 재시작 시 유지되지 않습니다. +- Phase 2에서는 통계 조회, 프로필 영속화, 도감/인벤토리 같은 확장 기능을 고려할 수 있습니다. diff --git a/Docs/WorkDone/2026-03-31_Fishing_MiniGame_Phase1_Implementation.md b/Docs/WorkDone/2026-03-31_Fishing_MiniGame_Phase1_Implementation.md new file mode 100644 index 0000000..cb2e422 --- /dev/null +++ b/Docs/WorkDone/2026-03-31_Fishing_MiniGame_Phase1_Implementation.md @@ -0,0 +1,44 @@ +# 2026-03-31 낚시 미니게임 Phase 1 구현 + +## 개요 + +낚시 미니게임의 1차 구현을 추가했습니다. 이번 단계에서는 전용 스레드 기반 세션, 실시간 버튼 조작, 거리/끊어짐 게이지, 성공 시 골드 지급, 종료 시 스레드 정리까지 MVP 범위를 구현했습니다. + +## 구현 내용 + +- `fishing` 미니게임을 공용 미니게임 레지스트리에 등록 +- `/fishing cast` 명령 추가 +- `/fishing end` 명령 추가 +- 낚시 시작 시 `UserName's Fishing Spot` 형식의 전용 스레드 생성 +- 사용자당 1개 세션만 허용하는 메모리 기반 세션 관리 추가 +- `⬅️`, `⏺️`, `➡️`, `🛌` 버튼 조작 추가 +- 1초 주기 상태 갱신 루프 추가 +- 거리 게이지와 끊어짐 게이지 렌더링 추가 +- 중앙 라인에 물고기 이모지 위치 표시 추가 +- 방향 일치 시 거리 감소 및 끊어짐 게이지 증가 로직 구현 +- 휴식 선택 시 끊어짐 게이지 회복 로직 구현 +- 성공 시 `RefinementProfile.gold` 기준 골드 지급 연동 +- 성공/실패/강제 종료 시 버튼 비활성화 및 스레드 자동 정리 추가 + +## 주요 파일 + +- `src/commands/fishing.ts` +- `src/services/FishingService.ts` +- `src/events/interactionCreate.ts` +- `src/services/MiniGameRegistry.ts` +- `src/services/RefinementService.ts` +- `src/i18n/types.ts` +- `src/i18n/locales/en.ts` +- `src/i18n/locales/ko.ts` +- `tests/services/FishingService.test.ts` + +## 검증 + +- `tsc` +- `jest tests/services/FishingService.test.ts --runInBand` + +## 비고 + +- Prisma Client는 최신 스키마 기준으로 다시 생성해 타입을 맞췄습니다. +- `ko.ts`에 존재하던 일부 깨진 문자열은 빌드를 막는 구간 위주로 우선 복구했습니다. +- 세션은 메모리 기반이므로 봇 재시작 시 진행 중 낚시 세션은 유지되지 않습니다. diff --git a/Docs/index.md b/Docs/index.md index 4e1ce8a..5f31099 100644 --- a/Docs/index.md +++ b/Docs/index.md @@ -1,57 +1,61 @@ -# Kord Documentation Index +# Kord Documentation Index -이 루트 색인 문서는 프로젝트 내의 모든 구조화된 문서를 카테고리별로 모아 탐색을 돕기 위해 작성되었습니다. +??猷⑦듃 ?됱씤 臾몄꽌???꾨줈?앺듃 ?댁쓽 紐⑤뱺 援ъ“?붾맂 臾몄꽌瑜?移댄뀒怨좊━蹂꾨줈 紐⑥븘 ?먯깋???뺢린 ?꾪빐 ?묒꽦?섏뿀?듬땲?? -## 정책 및 규칙 (Rules) +## ?뺤콉 諛?洹쒖튃 (Rules) -- [보안 가이드라인 (Security Rules)](Rules/security_guidelines.md) -- [다국어 지원 개발 가이드라인 (i18n Development Guidelines)](Rules/i18n_guidelines.md) +- [蹂댁븞 媛€?대뱶?쇱씤 (Security Rules)](Rules/security_guidelines.md) +- [?ㅺ뎅??吏€??媛쒕컻 媛€?대뱶?쇱씤 (i18n Development Guidelines)](Rules/i18n_guidelines.md) -## 기능 명세 (Features) +## 湲곕뒫 紐낆꽭 (Features) -- [임시 음성 채널 자동화 (Temp Voice Channels)](Features/temp_voice_channels.md) +- [?꾩떆 ?뚯꽦 梨꾨꼸 ?먮룞??(Temp Voice Channels)](Features/temp_voice_channels.md) -## 기획서 (Plans) +## 湲고쉷??(Plans) -- [에러 안내 기능 기획서 (Error Guidance Plan)](Plans/Error_Guidance_Plan.md) -- [다국어 지원 기획서 (i18n Plan)](Plans/i18n_Plan.md) -- [서버 이벤트 일정 관리 기능 기획안 (Event Schedule Management Plan)](Plans/Event_Schedule_Management_Plan.md) -- [YouTube 음악 재생 기능 기획안 (YouTube Music Playback Plan)](Plans/YouTube_Music_Playback_Plan.md) -- [재련 미니게임 기획서 (Refinement Mini-Game Plan)](Plans/MiniGame_Refinement_Plan.md) +- [?먮윭 ?덈궡 湲곕뒫 湲고쉷??(Error Guidance Plan)](Plans/Error_Guidance_Plan.md) +- [?ㅺ뎅??吏€??湲고쉷??(i18n Plan)](Plans/i18n_Plan.md) +- [?쒕쾭 ?대깽???쇱젙 愿€由?湲곕뒫 湲고쉷??(Event Schedule Management Plan)](Plans/Event_Schedule_Management_Plan.md) +- [YouTube ?뚯븙 ?ъ깮 湲곕뒫 湲고쉷??(YouTube Music Playback Plan)](Plans/YouTube_Music_Playback_Plan.md) +- [?щ젴 誘몃땲寃뚯엫 湲고쉷??(Refinement Mini-Game Plan)](Plans/MiniGame_Refinement_Plan.md) -## 아키텍처 및 정책 결정 (Decisions) +## ?꾪궎?띿쿂 諛??뺤콉 寃곗젙 (Decisions) -- [구독 티어 시스템 설계 (Subscription Tiers)](Decisions/subscription_tiers.md) +- [援щ룆 ?곗뼱 ?쒖뒪???ㅺ퀎 (Subscription Tiers)](Decisions/subscription_tiers.md) -## 트러블슈팅 (Troubleshooting) +## ?몃윭釉붿뒋??(Troubleshooting) -- [Voice Channel Missing Permissions (50013) 해결건](Troubleshooting/50013_Missing_Permissions.md) -- [Temp Voice 유령 채널 미삭제 버그 해결건](Troubleshooting/handleLeave_ghost_channel.md) +- [Voice Channel Missing Permissions (50013) ?닿껐嫄?(Troubleshooting/50013_Missing_Permissions.md) +- [Temp Voice ?좊졊 梨꾨꼸 誘몄궘??踰꾧렇 ?닿껐嫄?(Troubleshooting/handleLeave_ghost_channel.md) -## 진행/완료 내역 (Work Done) +## 吏꾪뻾/?꾨즺 ?댁뿭 (Work Done) -- [2026-03-27: 봇 상태 메시지 기능 구현 (Bot Presence Implementation)](WorkDone/2026-03-27_Presence_Implementation.md) -- [2026-03-27: 임시 음성 채널 기능 구현 (Temp Voice Channels Implementation)](WorkDone/2026-03-27_Voice_Channels_Implementation.md) -- [2026-03-27: 임시 음성 채널 고도화 (Voice Channels Improvements)](WorkDone/2026-03-27_Voice_Channels_Improvements.md) -- [2026-03-27: 다국어 지원 구현 (i18n Implementation)](WorkDone/2026-03-27_i18n_Implementation.md) -- [2026-03-27: i18n 테스트 코드 검사 도구 구현 (i18n Check Tool Implementation)](WorkDone/2026-03-27_i18n_Check_Tool_Implementation.md) -- [2026-03-27: /config 명령어 및 기능 관리 리팩토링 (Config & Feature Refactoring)](WorkDone/2026-03-27_Config_And_Feature_Refactoring.md) -- [2026-03-27: 감사 채널 구현 (Audit Log Channel Implementation)](WorkDone/2026-03-27_Audit_Log_Channel_Implementation.md) -- [2026-03-27: 권한 진단 기능 구현 (Permission Audit Implementation)](WorkDone/2026-03-27_Permission_Audit_Implementation.md) -- [2026-03-27: 에러 안내 UX 개선 및 통합 (Error Guidance UX Implementation)](WorkDone/2026-03-27_Error_Guidance_UX_Implementation.md) -- [2026-03-27: Kord 프로젝트 초기 설정 (Project Initial Setup)](WorkDone/2026-03-27_Project_Initial_Setup.md) -- [2026-03-30: 서버 이벤트 일정 관리 Phase 1 구현 (Event Schedule Phase 1 Implementation)](WorkDone/2026-03-30_Event_Schedule_Phase1_Implementation.md) -- [2026-03-30: 서버 이벤트 일정 관리 Phase 2 구현 (Event Schedule Phase 2 Implementation)](WorkDone/2026-03-30_Event_Schedule_Phase2_Implementation.md) -- [2026-03-30: 서버 이벤트 시작 시점 공지 구현 (Event Schedule Start Announcement Implementation)](WorkDone/2026-03-30_Event_Schedule_Start_Announcement_Implementation.md) -- [2026-03-30: 이벤트 리마인더 분 단위 옵션 구현 (Event Reminder Offsets Implementation)](WorkDone/2026-03-30_Event_Reminder_Offsets_Implementation.md) -- [2026-03-30: 명령어 계층 구조 리팩토링 (Hierarchical Command Refactoring)](WorkDone/2026-03-30_HierarchicalRefactor.md) -- [2026-03-30: YouTube Phase 1 (YouTube Music Playback Phase 1 Implementation)](WorkDone/2026-03-30_YouTube_Music_Playback_Phase1_Implementation.md) -- [2026-03-31: YouTube Phase 2 (YouTube Music Playback Phase 2 Implementation)](WorkDone/2026-03-31_YouTube_Music_Playback_Phase2_Implementation.md) -- [2026-03-31: YouTube Phase 3 (YouTube Music Playback Phase 3 Implementation)](WorkDone/2026-03-31_YouTube_Music_Playback_Phase3_Implementation.md) -- [2026-03-30: 미니게임 시스템 및 재련 게임 구현 (Mini-Game System & Refinement Implementation)](WorkDone/2026-03-30_RefinementImplementation.md) +- [2026-03-27: 遊??곹깭 硫붿떆吏€ 湲곕뒫 援ы쁽 (Bot Presence Implementation)](WorkDone/2026-03-27_Presence_Implementation.md) +- [2026-03-27: ?꾩떆 ?뚯꽦 梨꾨꼸 湲곕뒫 援ы쁽 (Temp Voice Channels Implementation)](WorkDone/2026-03-27_Voice_Channels_Implementation.md) +- [2026-03-27: ?꾩떆 ?뚯꽦 梨꾨꼸 怨좊룄??(Voice Channels Improvements)](WorkDone/2026-03-27_Voice_Channels_Improvements.md) +- [2026-03-27: ?ㅺ뎅??吏€??援ы쁽 (i18n Implementation)](WorkDone/2026-03-27_i18n_Implementation.md) +- [2026-03-27: i18n ?뚯뒪??肄붾뱶 寃€???꾧뎄 援ы쁽 (i18n Check Tool Implementation)](WorkDone/2026-03-27_i18n_Check_Tool_Implementation.md) +- [2026-03-27: /config 紐낅졊??諛?湲곕뒫 愿€由?由ы뙥?좊쭅 (Config & Feature Refactoring)](WorkDone/2026-03-27_Config_And_Feature_Refactoring.md) +- [2026-03-27: 媛먯궗 梨꾨꼸 援ы쁽 (Audit Log Channel Implementation)](WorkDone/2026-03-27_Audit_Log_Channel_Implementation.md) +- [2026-03-27: 沅뚰븳 吏꾨떒 湲곕뒫 援ы쁽 (Permission Audit Implementation)](WorkDone/2026-03-27_Permission_Audit_Implementation.md) +- [2026-03-27: ?먮윭 ?덈궡 UX 媛쒖꽑 諛??듯빀 (Error Guidance UX Implementation)](WorkDone/2026-03-27_Error_Guidance_UX_Implementation.md) +- [2026-03-27: Kord ?꾨줈?앺듃 珥덇린 ?ㅼ젙 (Project Initial Setup)](WorkDone/2026-03-27_Project_Initial_Setup.md) +- [2026-03-30: ?쒕쾭 ?대깽???쇱젙 愿€由?Phase 1 援ы쁽 (Event Schedule Phase 1 Implementation)](WorkDone/2026-03-30_Event_Schedule_Phase1_Implementation.md) +- [2026-03-30: ?쒕쾭 ?대깽???쇱젙 愿€由?Phase 2 援ы쁽 (Event Schedule Phase 2 Implementation)](WorkDone/2026-03-30_Event_Schedule_Phase2_Implementation.md) +- [2026-03-30: ?쒕쾭 ?대깽???쒖옉 ?쒖젏 怨듭? 援ы쁽 (Event Schedule Start Announcement Implementation)](WorkDone/2026-03-30_Event_Schedule_Start_Announcement_Implementation.md) +- [2026-03-30: ?대깽??由щ쭏?몃뜑 遺??⑥쐞 ?듭뀡 援ы쁽 (Event Reminder Offsets Implementation)](WorkDone/2026-03-30_Event_Reminder_Offsets_Implementation.md) +- [2026-03-30: 紐낅졊??怨꾩링 援ъ“ 由ы뙥?좊쭅 (Hierarchical Command Refactoring)](WorkDone/2026-03-30_HierarchicalRefactor.md) +- [2026-03-30: YouTube 음악 재생 Phase 1 구현 (YouTube Music Playback Phase 1 Implementation)](WorkDone/2026-03-30_YouTube_Music_Playback_Phase1_Implementation.md) +- [2026-03-31: YouTube 음악 재생 Phase 2 구현 (YouTube Music Playback Phase 2 Implementation)](WorkDone/2026-03-31_YouTube_Music_Playback_Phase2_Implementation.md) +- [2026-03-31: YouTube 음악 재생 Phase 3 구현 (YouTube Music Playback Phase 3 Implementation)](WorkDone/2026-03-31_YouTube_Music_Playback_Phase3_Implementation.md) +- [2026-03-30: 誘몃땲寃뚯엫 ?쒖뒪??諛??щ젴 寃뚯엫 援ы쁽 (Mini-Game System & Refinement Implementation)](WorkDone/2026-03-30_RefinementImplementation.md) +- [낚시 미니게임 기획안 (Fishing Mini-Game Plan)](Plans/Fishing_MiniGame_Plan.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) +- [낚시 미니게임 기획안 (Fishing Mini-Game Plan)](Plans/Fishing_MiniGame_Plan.md) diff --git a/resource/art/fishing/.gitkeep b/resource/art/fishing/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/resource/art/fishing/.gitkeep @@ -0,0 +1 @@ + diff --git a/resource/art/fishing/01_clownfish.png b/resource/art/fishing/01_clownfish.png new file mode 100644 index 0000000..bc4b7a5 Binary files /dev/null and b/resource/art/fishing/01_clownfish.png differ diff --git a/resource/art/fishing/02_bluefish.png b/resource/art/fishing/02_bluefish.png new file mode 100644 index 0000000..7d2aaf6 Binary files /dev/null and b/resource/art/fishing/02_bluefish.png differ diff --git a/resource/art/fishing/03_pufferfish.png b/resource/art/fishing/03_pufferfish.png new file mode 100644 index 0000000..a0260a3 Binary files /dev/null and b/resource/art/fishing/03_pufferfish.png differ diff --git a/resource/art/fishing/04_greenfish.png b/resource/art/fishing/04_greenfish.png new file mode 100644 index 0000000..cbb6563 Binary files /dev/null and b/resource/art/fishing/04_greenfish.png differ diff --git a/resource/art/fishing/05_octopus.png b/resource/art/fishing/05_octopus.png new file mode 100644 index 0000000..1613911 Binary files /dev/null and b/resource/art/fishing/05_octopus.png differ diff --git a/resource/art/fishing/06_starfish.png b/resource/art/fishing/06_starfish.png new file mode 100644 index 0000000..89beb09 Binary files /dev/null and b/resource/art/fishing/06_starfish.png differ diff --git a/resource/art/fishing/07_jellyfish.png b/resource/art/fishing/07_jellyfish.png new file mode 100644 index 0000000..c6f7908 Binary files /dev/null and b/resource/art/fishing/07_jellyfish.png differ diff --git a/resource/art/fishing/08_crab.png b/resource/art/fishing/08_crab.png new file mode 100644 index 0000000..6db9272 Binary files /dev/null and b/resource/art/fishing/08_crab.png differ diff --git a/resource/art/fishing/09_swordfish.png b/resource/art/fishing/09_swordfish.png new file mode 100644 index 0000000..0e4c66e Binary files /dev/null and b/resource/art/fishing/09_swordfish.png differ diff --git a/resource/art/fishing/10_shark.png b/resource/art/fishing/10_shark.png new file mode 100644 index 0000000..6a29ddf Binary files /dev/null and b/resource/art/fishing/10_shark.png differ diff --git a/resource/data/fishing/fish_catalog.json b/resource/data/fishing/fish_catalog.json new file mode 100644 index 0000000..05929f2 --- /dev/null +++ b/resource/data/fishing/fish_catalog.json @@ -0,0 +1,277 @@ +{ + "version": 1, + "description": "Fishing mini-game fish catalog and balance data.", + "positionKeys": ["left", "center", "right"], + "fish": [ + { + "id": "clownfish", + "displayName": "Clownfish", + "spawnRate": 20, + "rewardGold": { + "min": 120, + "max": 180 + }, + "reactionWindowSec": 2.0, + "distanceReductionByPosition": { + "left": { + "min": 15, + "max": 21 + }, + "center": { + "min": 10, + "max": 14 + }, + "right": { + "min": 8, + "max": 12 + } + }, + "artResourcePaths": [ + "resource/art/fishing/01_clownfish.png" + ] + }, + { + "id": "bluefish", + "displayName": "Bluefish", + "spawnRate": 17, + "rewardGold": { + "min": 150, + "max": 220 + }, + "reactionWindowSec": 1.8, + "distanceReductionByPosition": { + "left": { + "min": 8, + "max": 12 + }, + "center": { + "min": 15, + "max": 21 + }, + "right": { + "min": 10, + "max": 14 + } + }, + "artResourcePaths": [ + "resource/art/fishing/02_bluefish.png" + ] + }, + { + "id": "pufferfish", + "displayName": "Pufferfish", + "spawnRate": 13, + "rewardGold": { + "min": 180, + "max": 260 + }, + "reactionWindowSec": 1.6, + "distanceReductionByPosition": { + "left": { + "min": 9, + "max": 13 + }, + "center": { + "min": 10, + "max": 14 + }, + "right": { + "min": 17, + "max": 23 + } + }, + "artResourcePaths": [ + "resource/art/fishing/03_pufferfish.png" + ] + }, + { + "id": "greenfish", + "displayName": "Greenfish", + "spawnRate": 12, + "rewardGold": { + "min": 200, + "max": 300 + }, + "reactionWindowSec": 1.5, + "distanceReductionByPosition": { + "left": { + "min": 17, + "max": 23 + }, + "center": { + "min": 9, + "max": 13 + }, + "right": { + "min": 11, + "max": 15 + } + }, + "artResourcePaths": [ + "resource/art/fishing/04_greenfish.png" + ] + }, + { + "id": "octopus", + "displayName": "Octopus", + "spawnRate": 10, + "rewardGold": { + "min": 230, + "max": 340 + }, + "reactionWindowSec": 1.4, + "distanceReductionByPosition": { + "left": { + "min": 10, + "max": 14 + }, + "center": { + "min": 19, + "max": 25 + }, + "right": { + "min": 12, + "max": 16 + } + }, + "artResourcePaths": [ + "resource/art/fishing/05_octopus.png" + ] + }, + { + "id": "starfish", + "displayName": "Starfish", + "spawnRate": 9, + "rewardGold": { + "min": 260, + "max": 380 + }, + "reactionWindowSec": 1.3, + "distanceReductionByPosition": { + "left": { + "min": 12, + "max": 16 + }, + "center": { + "min": 11, + "max": 15 + }, + "right": { + "min": 19, + "max": 25 + } + }, + "artResourcePaths": [ + "resource/art/fishing/06_starfish.png" + ] + }, + { + "id": "jellyfish", + "displayName": "Jellyfish", + "spawnRate": 7, + "rewardGold": { + "min": 320, + "max": 460 + }, + "reactionWindowSec": 1.25, + "distanceReductionByPosition": { + "left": { + "min": 20, + "max": 26 + }, + "center": { + "min": 12, + "max": 16 + }, + "right": { + "min": 13, + "max": 17 + } + }, + "artResourcePaths": [ + "resource/art/fishing/07_jellyfish.png" + ] + }, + { + "id": "crab", + "displayName": "Crab", + "spawnRate": 6, + "rewardGold": { + "min": 380, + "max": 540 + }, + "reactionWindowSec": 1.2, + "distanceReductionByPosition": { + "left": { + "min": 13, + "max": 17 + }, + "center": { + "min": 21, + "max": 27 + }, + "right": { + "min": 14, + "max": 18 + } + }, + "artResourcePaths": [ + "resource/art/fishing/08_crab.png" + ] + }, + { + "id": "swordfish", + "displayName": "Swordfish", + "spawnRate": 4, + "rewardGold": { + "min": 520, + "max": 720 + }, + "reactionWindowSec": 1.2, + "distanceReductionByPosition": { + "left": { + "min": 14, + "max": 18 + }, + "center": { + "min": 15, + "max": 19 + }, + "right": { + "min": 24, + "max": 32 + } + }, + "artResourcePaths": [ + "resource/art/fishing/09_swordfish.png" + ] + }, + { + "id": "shark", + "displayName": "Shark", + "spawnRate": 2, + "rewardGold": { + "min": 800, + "max": 1200 + }, + "reactionWindowSec": 1.2, + "distanceReductionByPosition": { + "left": { + "min": 26, + "max": 34 + }, + "center": { + "min": 17, + "max": 23 + }, + "right": { + "min": 15, + "max": 21 + } + }, + "artResourcePaths": [ + "resource/art/fishing/10_shark.png" + ] + } + ] +} diff --git a/src/commands/fishing.ts b/src/commands/fishing.ts new file mode 100644 index 0000000..e31bc17 --- /dev/null +++ b/src/commands/fishing.ts @@ -0,0 +1,127 @@ +import { + ChannelType, + ChatInputCommandInteraction, + SlashCommandBuilder, +} from 'discord.js'; +import { prisma } from '../database'; +import { SupportedLocale, t } from '../i18n'; +import { FishingService } from '../services/FishingService'; + +export default { + data: new SlashCommandBuilder() + .setName('fishing') + .setDescription('Play the fishing mini-game.') + .setDescriptionLocalizations({ + ko: '낚시 미니게임을 플레이합니다.', + }) + .addSubcommand((subcommand) => + subcommand + .setName('enter') + .setDescription('Create or reopen your fishing thread.') + .setDescriptionLocalizations({ + ko: '낚시 전용 스레드를 생성하거나 다시 엽니다.', + }), + ) + .addSubcommand((subcommand) => + subcommand + .setName('cast') + .setDescription('Start a fishing session inside your fishing thread.') + .setDescriptionLocalizations({ + ko: '자신의 낚시 스레드 안에서 낚시 세션을 시작합니다.', + }), + ) + .addSubcommand((subcommand) => + subcommand + .setName('end') + .setDescription('End your active fishing session and delete the thread.') + .setDescriptionLocalizations({ + ko: '진행 중인 낚시 세션을 종료하고 스레드를 삭제합니다.', + }), + ), + + async execute(interaction: ChatInputCommandInteraction, locale: SupportedLocale) { + if (!interaction.guildId) return; + + const config = await prisma.miniGameConfig.findUnique({ + where: { guildId_gameKey: { guildId: interaction.guildId, gameKey: 'fishing' } }, + }); + const subcommand = interaction.options.getSubcommand(); + + if (subcommand === 'enter') { + await interaction.deferReply({ ephemeral: true }); + + if (!config || !config.enabled) { + await interaction.editReply({ + content: t(locale, 'commands.fishing.disabled'), + }); + return; + } + + if (config.channelId && config.channelId !== interaction.channelId) { + await interaction.editReply({ + content: t(locale, 'commands.fishing.restrictedChannel', { + channel: `<#${config.channelId}>`, + }), + }); + return; + } + + if (!interaction.channel || interaction.channel.type !== ChannelType.GuildText) { + await interaction.editReply({ + content: t(locale, 'commands.fishing.enterTextChannelOnly'), + }); + return; + } + + const result = await FishingService.enterThread(interaction); + await interaction.editReply({ + content: result.existed + ? t(locale, 'commands.fishing.enterExistingThread', { thread: `<#${result.thread.id}>` }) + : t(locale, 'commands.fishing.enterCreated', { thread: `<#${result.thread.id}>` }), + }); + return; + } + + if (subcommand === 'cast') { + await interaction.deferReply({ ephemeral: true }); + + if (!config || !config.enabled) { + await interaction.editReply({ + content: t(locale, 'commands.fishing.disabled'), + }); + return; + } + + if (!FishingService.isOwnedFishingThread(interaction.channel, interaction.user.username)) { + await interaction.editReply({ + content: t(locale, 'commands.fishing.castThreadOnly'), + }); + return; + } + + const result = await FishingService.startSessionInThread(interaction, locale); + await interaction.editReply({ + content: result.existed + ? t(locale, 'commands.fishing.startExistingSession', { thread: `<#${result.thread.id}>` }) + : t(locale, 'commands.fishing.startCreated', { thread: `<#${result.thread.id}>` }), + }); + return; + } + + if (subcommand === 'end') { + await interaction.deferReply({ ephemeral: true }); + + const ended = await FishingService.endThreadByUser(interaction, locale); + if (!ended) { + await interaction.editReply({ + content: t(locale, 'commands.fishing.noActiveSession'), + }); + return; + } + + await interaction.editReply({ + content: t(locale, 'commands.fishing.endDeleted'), + }); + } + }, +}; diff --git a/src/events/interactionCreate.ts b/src/events/interactionCreate.ts index c788c49..663a3a9 100644 --- a/src/events/interactionCreate.ts +++ b/src/events/interactionCreate.ts @@ -29,6 +29,13 @@ export default { await MusicService.handleControlInteraction(interaction, locale); }, locale); } + else if (interaction.isButton() && interaction.customId.startsWith('fishing_')) { + const { FishingService } = require('../services/FishingService'); + const locale = await getInteractionLocale(interaction); + await withErrorHandler(interaction, async () => { + await FishingService.handleButton(interaction, locale); + }, locale); + } else if (interaction.isMessageComponent() && interaction.customId.startsWith('setup_')) { const locale = await getInteractionLocale(interaction); await withErrorHandler(interaction, async () => { diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index b73f7c1..2a28e1e 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -1,11 +1,11 @@ -import { TranslationSchema } from '../types'; +import { TranslationSchema } from '../types'; /** - * English translations — the DEFAULT and FALLBACK locale. + * English translations ??the DEFAULT and FALLBACK locale. * All keys MUST be present here. Other locales can omit keys to fallback to English. */ export const en: TranslationSchema = { - // ── Error Messages ────────────────────────────────────── + // ?€?€ Error Messages ?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€ errors: { E1001: { userMessage: 'The user limit value is invalid.', @@ -71,7 +71,7 @@ export const en: TranslationSchema = { }, }, - // ── Error Category Titles ─────────────────────────────── + // ?€?€ Error Category Titles ?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€ errorTitles: { USER_INPUT: 'Please check your input', PERMISSION: 'Insufficient permissions', @@ -79,17 +79,17 @@ export const en: TranslationSchema = { DISCORD_API: 'Temporary issue', }, - // ── Error Embed Field Labels ──────────────────────────── + // ?€?€ Error Embed Field Labels ?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€ errorFields: { resolution: '💡 How to resolve', }, - // ── Voice Channel ─────────────────────────────────────── + // ?€?€ Voice Channel ?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€ voice: { channelReady: '{{owner}}, your temporary channel is ready! Use the dropdown menu below to manage it.', defaultRoomName: "{{username}}'s Room", controlPanel: { - placeholder: '⚙️ Manage Channel Settings', + placeholder: '?숋툘 Manage Channel Settings', rename: 'Rename Channel', limit: 'Set User Limit', lock: 'Lock / Unlock', @@ -111,7 +111,7 @@ export const en: TranslationSchema = { }, }, - // ── Commands ──────────────────────────────────────────── + // ?€?€ Commands ?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€ commands: { voiceSetup: { description: 'Setup a generator voice channel for temporary channels.', @@ -251,11 +251,47 @@ export const en: TranslationSchema = { statusPaused: 'Paused', unknownDuration: 'Unknown', buttons: { - pause: '⏸ Pause', - resume: '▶ Resume', - skip: '⏭ Skip', - stop: '⏹ Stop', - leave: '👋 Leave', + pause: '??Pause', + resume: '??Resume', + skip: '??Skip', + stop: '??Stop', + leave: '?몝 Leave', + }, + }, + fishing: { + description: 'Play the fishing mini-game.', + enterDescription: 'Create or reopen your fishing thread.', + castDescription: 'Start a fishing session inside your fishing thread.', + endDescription: 'End your fishing thread and delete it.', + 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.', + enterExistingThread: 'Your fishing thread is already available in {{thread}}.', + enterCreated: 'Your fishing thread has been created in {{thread}}.', + castThreadOnly: 'You can only use /fishing cast inside your own fishing thread.', + startExistingSession: 'You already have an active fishing session in {{thread}}.', + startCreated: 'Your fishing session has started in {{thread}}.', + noActiveSession: 'There is no fishing session or thread to close.', + 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.', + titleActive: 'Fishing Session', + titleEnded: 'Fishing Session Ended', + status: 'Status', + targetFish: 'Target Fish', + distance: 'Distance', + tension: 'Line Tension', + reward: 'Reward', + threadHint: 'Use /fishing cast to play again, or /fishing end to delete the thread.', + catchResultTitle: 'Big Catch!', + catchResultBody: 'You caught **{{fish}}** and earned **{{reward}} G**.', + states: { + hooked: 'Hooked', + resting: 'Resting', + tense: 'Pulling', + missed: 'Missed', + success: 'Caught', + failed: 'Line Snapped', }, }, permissionAudit: { @@ -263,8 +299,8 @@ export const en: TranslationSchema = { channel: 'Channel', noResults: 'No features to audit. The bot may not be configured yet.', summaryLabel: 'Summary', - summaryOk: '✅ All checks passed. No issues found.', - summaryIssue: '❌ {{fail}} failure(s) · ⚠️ {{warn}} warning(s) detected.', + summaryOk: '??All checks passed. No issues found.', + summaryIssue: '??{{fail}} failure(s) 쨌 ?좑툘 {{warn}} warning(s) detected.', hierarchyWarning: "Bot role (pos: {{botPos}}) must be above '{{role}}' (pos: {{targetPos}}) to manage it.", features: { BASIC: 'Basic Bot Functionality', @@ -279,53 +315,53 @@ export const en: TranslationSchema = { setup: { description: 'Run the setup wizard to configure the bot step by step.', step0: { - title: '✨ Bot Setup Wizard', - desc: 'Welcome! This wizard will help you configure the following 4 features:\n\n1️⃣ **Language Settings**\n2️⃣ **Permission Check**\n3️⃣ **Audit Channel Setup**\n4️⃣ **Temporary Voice Channel Setup**', + title: '??Bot Setup Wizard', + desc: 'Welcome! This wizard will help you configure the following 4 features:\n\n1截뤴깵 **Language Settings**\n2截뤴깵 **Permission Check**\n3截뤴깵 **Audit Channel Setup**\n4截뤴깵 **Temporary Voice Channel Setup**', startBtn: 'Start Setup' }, step1: { - title: '1️⃣ Language Settings', + title: '1截뤴깵 Language Settings', desc: 'Set the default language for the bot in this server. (Current: **{{locale}}**)', placeholder: 'Select a language', nextBtn: 'Next Step', skipBtn: 'Skip' }, step2: { - title: '2️⃣ Permission Check', - descOk: '✅ **All required permissions are granted.**', - descFail: '⚠️ **Some permissions are missing.**\nPlease check the results and grant the necessary permissions to the bot role.', + title: '2截뤴깵 Permission Check', + descOk: '??**All required permissions are granted.**', + descFail: '?좑툘 **Some permissions are missing.**\nPlease check the results and grant the necessary permissions to the bot role.', recheckBtn: 'Re-check', nextBtn: 'Next Step' }, step3: { - title: '3️⃣ Audit Channel Setup', + title: '3截뤴깵 Audit Channel Setup', desc: 'Select a channel to receive bot events and error logs.', placeholder: 'Select Audit Channel', disableBtn: 'Disable Audit Logs', nextBtn: 'Next Step' }, step4: { - title: '3-1️⃣ Audit Log Categories', + title: '3-1截뤴깵 Audit Log Categories', desc: 'Select which log categories to receive. **Green** buttons are enabled, **Red** buttons are disabled.', nextBtn: 'Next Step', }, step5: { - title: '4️⃣ Temporary Voice Channel Setup', + title: '4截뤴깵 Temporary Voice Channel Setup', desc: 'Select the "Generator Channel" for temporary voice channels.\nYou can choose an existing channel or have the bot **auto-create** a new category and channel.', placeholder: 'Select Generator Channel', - autoBtn: '🚀 Auto Create', + autoBtn: '?? Auto Create', skipBtn: 'Disable Temp Voice', nextBtn: 'Finish Setup' }, step6: { - title: '🎉 Setup Summary', + title: '?럦 Setup Summary', desc: '**1. Language**: {{lang}}\n**2. Audit Channel**: {{audit}}\n**3. Audit Categories**: {{categories}}\n**4. Temp Voice**: {{voice}}', finishBtn: 'Done' }, - finished: '✅ The setup wizard has been finished.', - expired: '⏳ The session has expired. Please run `/setup` again.', + finished: '??The setup wizard has been finished.', + expired: '??The session has expired. Please run `/setup` again.', defaultCategoryName: 'Voice Channels', - defaultGeneratorName: '➕ Create Channel', + defaultGeneratorName: '??Create Channel', auditCategories: { SYSTEM: 'System', BOOT: 'Boot', @@ -350,7 +386,7 @@ export const en: TranslationSchema = { }, }, - // ── Modals ────────────────────────────────────────────── + // ?€?€ Modals ?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€ modals: { renameTitle: 'Rename Voice Channel', renameLabel: 'New Channel Name', @@ -358,14 +394,14 @@ export const en: TranslationSchema = { limitLabel: 'User Limit (0 for unlimited, 1-99)', }, - // ── Select Menu Placeholders ──────────────────────────── + // ?€?€ Select Menu Placeholders ?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€ selects: { kickUser: 'Select a user to kick', banUser: 'Select a user to ban/hide', transferOwner: 'Select a user to transfer ownership to', }, - // ── Presence (Bot Status) ── + // ?€?€ Presence (Bot Status) ?€?€ presence: { servers: 'Monitoring {{guildCount}} servers', help: 'Check out the /help command', @@ -373,3 +409,6 @@ export const en: TranslationSchema = { version: 'Kord v1.0.0', }, }; + + + diff --git a/src/i18n/locales/ko.ts b/src/i18n/locales/ko.ts index e08d74c..23bfbad 100644 --- a/src/i18n/locales/ko.ts +++ b/src/i18n/locales/ko.ts @@ -1,194 +1,194 @@ -import { TranslationSchema } from '../types'; +import { TranslationSchema } from '../types'; /** - * 한국어 번역 파일. - * 모든 키가 en.ts와 1:1 대응되어야 합니다. + * ?쒓뎅??踰덉뿭 ?뚯씪. + * 紐⑤뱺 ?ㅺ? en.ts?€ 1:1 ?€?묐릺?댁빞 ?⑸땲?? */ export const ko: TranslationSchema = { - // ── 에러 메시지 ───────────────────────────────────────── + // ?€?€ ?먮윭 硫붿떆吏€ ?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€ errors: { E1001: { userMessage: '사용자 제한 값이 올바르지 않습니다.', - resolution: '0에서 99 사이의 숫자를 입력해주세요. (0 = 무제한)', + resolution: '0?먯꽌 99 ?ъ씠???レ옄瑜??낅젰?댁<?몄슂. (0 = 臾댁젣??', }, E1002: { - userMessage: '채널 이름 형식이 올바르지 않습니다.', - resolution: '유효한 채널 이름을 입력해주세요. (최대 100자)', + userMessage: '梨꾨꼸 ?대쫫 ?뺤떇???щ컮瑜댁? ?딆뒿?덈떎.', + resolution: '?좏슚??梨꾨꼸 ?대쫫???낅젰?댁<?몄슂. (理쒕? 100??', }, E1003: { - userMessage: '자기 자신에게는 이 작업을 수행할 수 없습니다.', + userMessage: '?먭린 ?먯떊?먭쾶?????묒뾽???섑뻾?????놁뒿?덈떎.', }, E1004: { - userMessage: '선택한 유저가 음성 채널에 없습니다.', - resolution: '작업을 수행하기 전에 해당 유저가 채널에 있는지 확인해주세요.', + userMessage: '?좏깮???좎?媛€ ?뚯꽦 梨꾨꼸???놁뒿?덈떎.', + resolution: '?묒뾽???섑뻾?섍린 ?꾩뿉 ?대떦 ?좎?媛€ 梨꾨꼸???덈뒗吏€ ?뺤씤?댁<?몄슂.', }, E2001: { - userMessage: '봇에 채널을 관리할 권한이 부족합니다.', - resolution: '서버 관리자에게 봇의 "채널 관리" 권한을 확인해달라고 요청하세요.', + userMessage: '遊뉗뿉 梨꾨꼸??愿€由ы븷 沅뚰븳??遺€議깊빀?덈떎.', + resolution: '?쒕쾭 愿€由ъ옄?먭쾶 遊뉗쓽 "梨꾨꼸 愿€由? 沅뚰븳???뺤씤?대떖?쇨퀬 ?붿껌?섏꽭??', }, E2002: { - userMessage: '봇에 음성 채널 관련 권한이 부족합니다.', - resolution: '서버 관리자에게 봇의 "채널 관리", "역할 관리", "멤버 이동" 권한을 확인해달라고 요청하세요.', + userMessage: '遊뉗뿉 ?뚯꽦 梨꾨꼸 愿€??沅뚰븳??遺€議깊빀?덈떎.', + resolution: '?쒕쾭 愿€由ъ옄?먭쾶 遊뉗쓽 "梨꾨꼸 愿€由?, "??븷 愿€由?, "硫ㅻ쾭 ?대룞" 沅뚰븳???뺤씤?대떖?쇨퀬 ?붿껌?섏꽭??', }, E2003: { - userMessage: '이 명령어를 사용할 권한이 없습니다.', - resolution: '이 명령어는 관리자 권한이 필요합니다.', + userMessage: '??紐낅졊?대? ?ъ슜??沅뚰븳???놁뒿?덈떎.', + resolution: '??紐낅졊?대뒗 愿€由ъ옄 沅뚰븳???꾩슂?⑸땲??', }, E2004: { - userMessage: '채널 소유자만 이 기능을 사용할 수 있습니다.', + userMessage: '梨꾨꼸 ?뚯쑀?먮쭔 ??湲곕뒫???ъ슜?????덉뒿?덈떎.', }, E2005: { - userMessage: '활성된 임시 음성 채널에 참여 중이어야 사용할 수 있습니다.', - resolution: '임시 음성 채널에 참여한 뒤 다시 시도해주세요.', + userMessage: '?쒖꽦???꾩떆 ?뚯꽦 梨꾨꼸??李몄뿬 以묒씠?댁빞 ?ъ슜?????덉뒿?덈떎.', + resolution: '?꾩떆 ?뚯꽦 梨꾨꼸??李몄뿬?????ㅼ떆 ?쒕룄?댁<?몄슂.', }, E3001: { - userMessage: '요청을 처리하는 중 내부 오류가 발생했습니다.', - resolution: '잠시 후 다시 시도해주세요. 문제가 지속되면 봇 관리자에게 문의하세요.', + userMessage: '?붿껌??泥섎━?섎뒗 以??대? ?ㅻ쪟媛€ 諛쒖깮?덉뒿?덈떎.', + resolution: '?좎떆 ???ㅼ떆 ?쒕룄?댁<?몄슂. 臾몄젣媛€ 吏€?띾릺硫?遊?愿€由ъ옄?먭쾶 臾몄쓽?섏꽭??', }, E3002: { - userMessage: '요청을 처리하는 중 내부 오류가 발생했습니다.', - resolution: '잠시 후 다시 시도해주세요.', + userMessage: '?붿껌??泥섎━?섎뒗 以??대? ?ㅻ쪟媛€ 諛쒖깮?덉뒿?덈떎.', + resolution: '?좎떆 ???ㅼ떆 ?쒕룄?댁<?몄슂.', }, E3003: { - userMessage: '명령어를 실행하는 중 오류가 발생했습니다.', - resolution: '다시 시도해주세요. 문제가 지속되면 봇 관리자에게 문의하세요.', + userMessage: '紐낅졊?대? ?ㅽ뻾?섎뒗 以??ㅻ쪟媛€ 諛쒖깮?덉뒿?덈떎.', + resolution: '?ㅼ떆 ?쒕룄?댁<?몄슂. 臾몄젣媛€ 吏€?띾릺硫?遊?愿€由ъ옄?먭쾶 臾몄쓽?섏꽭??', }, E3999: { - userMessage: '예상치 못한 오류가 발생했습니다.', - resolution: '나중에 다시 시도해주세요. 문제가 계속되면 봇 관리자에게 문의하세요.', + userMessage: '?덉긽移?紐삵븳 ?ㅻ쪟媛€ 諛쒖깮?덉뒿?덈떎.', + resolution: '?섏쨷???ㅼ떆 ?쒕룄?댁<?몄슂. 臾몄젣媛€ 怨꾩냽?섎㈃ 遊?愿€由ъ옄?먭쾶 臾몄쓽?섏꽭??', }, E4001: { - userMessage: 'Discord에 의해 요청이 제한되었습니다.', - resolution: '잠시 기다린 후 다시 시도해주세요.', + userMessage: 'Discord???섑빐 ?붿껌???쒗븳?섏뿀?듬땲??', + resolution: '?좎떆 湲곕떎由????ㅼ떆 ?쒕룄?댁<?몄슂.', }, E4002: { - userMessage: '권한 부족으로 Discord가 작업을 거부했습니다.', - resolution: '서버 관리자에게 봇의 역할 및 채널 권한을 확인해달라고 요청하세요.', + userMessage: '沅뚰븳 遺€議깆쑝濡?Discord媛€ ?묒뾽??嫄곕??덉뒿?덈떎.', + resolution: '?쒕쾭 愿€由ъ옄?먭쾶 遊뉗쓽 ??븷 諛?梨꾨꼸 沅뚰븳???뺤씤?대떖?쇨퀬 ?붿껌?섏꽭??', }, E4003: { - userMessage: 'Discord에 일시적인 문제가 발생했습니다.', - resolution: '잠시 후 다시 시도해주세요. 문제가 지속되면 https://discordstatus.com 에서 상태를 확인해주세요.', + userMessage: 'Discord???쇱떆?곸씤 臾몄젣媛€ 諛쒖깮?덉뒿?덈떎.', + resolution: '?좎떆 ???ㅼ떆 ?쒕룄?댁<?몄슂. 臾몄젣媛€ 吏€?띾릺硫?https://discordstatus.com ?먯꽌 ?곹깭瑜??뺤씤?댁<?몄슂.', }, }, - // ── 에러 카테고리 타이틀 ──────────────────────────────── + // ?€?€ ?먮윭 移댄뀒怨좊━ ?€?댄? ?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€ errorTitles: { USER_INPUT: '입력을 확인해주세요', - PERMISSION: '권한이 부족합니다', - BOT_INTERNAL: '문제가 발생했습니다', - DISCORD_API: '일시적인 문제입니다', + PERMISSION: '沅뚰븳??遺€議깊빀?덈떎', + BOT_INTERNAL: '臾몄젣媛€ 諛쒖깮?덉뒿?덈떎', + DISCORD_API: '일시적인 문제입니다.', }, - // ── 에러 Embed 필드 라벨 ──────────────────────────────── + // ?€?€ ?먮윭 Embed ?꾨뱶 ?쇰꺼 ?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€ errorFields: { resolution: '💡 해결 방법', }, - // ── 음성 채널 ─────────────────────────────────────────── + // ?€?€ ?뚯꽦 梨꾨꼸 ?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€ voice: { channelReady: '{{owner}}, 임시 채널이 준비되었습니다! 아래 드롭다운 메뉴로 관리하세요.', defaultRoomName: '{{username}}의 방', controlPanel: { - placeholder: '⚙️ 채널 설정 관리', + placeholder: '채널 설정 관리', rename: '채널 이름 변경', - limit: '인원 제한 설정', - lock: '채널 잠금 / 해제', - kick: '유저 추방', + limit: '?몄썝 ?쒗븳 ?ㅼ젙', + lock: '梨꾨꼸 ?좉툑 / ?댁젣', + kick: '?좎? 異붾갑', ban: '유저 차단 / 숨기기', - transfer: '소유권 이전', + transfer: '?뚯쑀沅??댁쟾', }, responses: { channelLocked: '채널이 잠겼습니다! 초대된 멤버만 참여할 수 있습니다.', - channelUnlocked: '채널이 해제되었습니다! 누구나 참여할 수 있습니다.', - channelRenamed: '채널 이름이 **{{name}}**(으)로 변경되었습니다!', - limitSet: '인원 제한이 **{{limit}}**명으로 설정되었습니다!', + channelUnlocked: '梨꾨꼸???댁젣?섏뿀?듬땲?? ?꾧뎄??李몄뿬?????덉뒿?덈떎.', + channelRenamed: '梨꾨꼸 ?대쫫??**{{name}}**(??濡?蹂€寃쎈릺?덉뒿?덈떎!', + limitSet: '?몄썝 ?쒗븳??**{{limit}}**紐낆쑝濡??ㅼ젙?섏뿀?듬땲??', limitUnlimited: '무제한', - kicked: '{{user}}을(를) 채널에서 추방했습니다.', - banned: '{{user}}에게 채널을 숨기고 차단했습니다.', - transferPrompt: '채널의 새 소유자를 선택하세요.', - transferDone: '소유권이 {{user}}에게 이전되었습니다.', - banPrompt: '차단하면 해당 유저에게 채널이 보이지 않게 됩니다.', + kicked: '{{user}}??瑜? 梨꾨꼸?먯꽌 異붾갑?덉뒿?덈떎.', + banned: '{{user}}?먭쾶 梨꾨꼸???④린怨?李⑤떒?덉뒿?덈떎.', + transferPrompt: '梨꾨꼸?????뚯쑀?먮? ?좏깮?섏꽭??', + transferDone: '?뚯쑀沅뚯씠 {{user}}?먭쾶 ?댁쟾?섏뿀?듬땲??', + banPrompt: '李⑤떒?섎㈃ ?대떦 ?좎??먭쾶 梨꾨꼸??蹂댁씠吏€ ?딄쾶 ?⑸땲??', }, }, - // ── 명령어 ────────────────────────────────────────────── + // ?€?€ 紐낅졊???€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€ commands: { voiceSetup: { - description: '임시 음성 채널을 위한 생성기 채널을 설정합니다.', - setDescription: '기존 음성 채널을 생성기로 설정합니다', - createDescription: '새 음성 채널을 만들고 생성기로 설정합니다', - channelOptionDescription: '생성기로 사용할 음성 채널', - categoryOptionDescription: '(선택) 임시 채널이 생성될 카테고리', - nameOptionDescription: '새 생성기 음성 채널의 이름', - setSuccess: '{{channel}}을(를) 음성 생성기 채널로 설정했습니다!', - createSuccess: '{{channel}}을(를) 음성 생성기 채널로 생성 및 설정했습니다!', + description: '?꾩떆 ?뚯꽦 梨꾨꼸???꾪븳 ?앹꽦湲?梨꾨꼸???ㅼ젙?⑸땲??', + setDescription: '기존 음성 채널을 생성기로 설정합니다.', + createDescription: '새 음성 채널을 만들고 생성기로 설정합니다.', + channelOptionDescription: '?앹꽦湲곕줈 ?ъ슜???뚯꽦 梨꾨꼸', + categoryOptionDescription: '(?좏깮) ?꾩떆 梨꾨꼸???앹꽦??移댄뀒怨좊━', + nameOptionDescription: '???앹꽦湲??뚯꽦 梨꾨꼸???대쫫', + setSuccess: '{{channel}}??瑜? ?뚯꽦 ?앹꽦湲?梨꾨꼸濡??ㅼ젙?덉뒿?덈떎!', + createSuccess: '{{channel}}??瑜? ?뚯꽦 ?앹꽦湲?梨꾨꼸濡??앹꽦 諛??ㅼ젙?덉뒿?덈떎!', }, voiceConfig: { - description: '서버의 임시 음성 채널 설정을 관리합니다.', - setNameTitle: '기본 이름 템플릿 설정', - setNameDesc: '임시 채널 생성 시 사용할 기본 이름 형식을 설정합니다. (사용자명: {{username}})', - setLimitTitle: '기본 인원 제한 설정', - setLimitDesc: '임시 채널 생성 시 적용할 기본 인원 제한을 설정합니다.', - statusTitle: '현재 서버 음성 설정', + description: '?쒕쾭???꾩떆 ?뚯꽦 梨꾨꼸 ?ㅼ젙??愿€由ы빀?덈떎.', + setNameTitle: '湲곕낯 ?대쫫 ?쒗뵆由??ㅼ젙', + setNameDesc: '?꾩떆 梨꾨꼸 ?앹꽦 ???ъ슜??湲곕낯 ?대쫫 ?뺤떇???ㅼ젙?⑸땲?? (?ъ슜?먮챸: {{username}})', + setLimitTitle: '湲곕낯 ?몄썝 ?쒗븳 ?ㅼ젙', + setLimitDesc: '?꾩떆 梨꾨꼸 ?앹꽦 ???곸슜??湲곕낯 ?몄썝 ?쒗븳???ㅼ젙?⑸땲??', + statusTitle: '?꾩옱 ?쒕쾭 ?뚯꽦 ?ㅼ젙', templateLabel: '이름 템플릿', - limitLabel: '기본 인원 제한', - setSuccess: '서버의 임시 채널 설정이 업데이트되었습니다.', - limitValue: '{{limit}}명 (0 = 무제한)', + limitLabel: '湲곕낯 ?몄썝 ?쒗븳', + setSuccess: '?쒕쾭???꾩떆 梨꾨꼸 ?ㅼ젙???낅뜲?댄듃?섏뿀?듬땲??', + limitValue: '{{limit}}紐?(0 = 臾댁젣??', }, language: { - description: '봇의 언어를 설정합니다.', - scopeDescription: '본인에게만 또는 서버 전체에 적용', - localeDescription: '사용할 언어', - scopeUser: '나만 적용', - scopeServer: '서버 전체 (관리자 전용)', - userSet: '개인 언어가 **{{locale}}**(으)로 설정되었습니다.', - serverSet: '서버 언어가 **{{locale}}**(으)로 설정되었습니다.', - serverPermissionDenied: '서버 언어 변경은 서버 관리자만 가능합니다.', + description: '遊뉗쓽 ?몄뼱瑜??ㅼ젙?⑸땲??', + scopeDescription: '蹂몄씤?먭쾶留??먮뒗 ?쒕쾭 ?꾩껜???곸슜', + localeDescription: '?ъ슜???몄뼱', + scopeUser: '?섎쭔 ?곸슜', + scopeServer: '?쒕쾭 ?꾩껜 (愿€由ъ옄 ?꾩슜)', + userSet: '媛쒖씤 ?몄뼱媛€ **{{locale}}**(??濡??ㅼ젙?섏뿀?듬땲??', + serverSet: '?쒕쾭 ?몄뼱媛€ **{{locale}}**(??濡??ㅼ젙?섏뿀?듬땲??', + serverPermissionDenied: '?쒕쾭 ?몄뼱 蹂€寃쎌? ?쒕쾭 愿€由ъ옄留?媛€?ν빀?덈떎.', }, event: { - description: '서버 이벤트 일정을 관리합니다.', - createDescription: '새 서버 이벤트를 생성합니다.', - listDescription: '예정된 서버 이벤트 목록을 조회합니다.', - cancelDescription: '예약된 서버 이벤트를 취소합니다.', - announceDescription: '이벤트 공지 Embed를 다시 게시합니다.', - titleDescription: '이벤트 제목', - dateDescription: 'YYYY-MM-DD 형식의 날짜', - timeDescription: 'HH:mm 형식의 시간 (24시간제, Asia/Seoul 기준)', - descriptionOptionDescription: '선택 사항인 이벤트 설명', - channelDescription: '선택 사항인 공지 채널', - reminderDescription: '리마인더 메시지 사용 여부', - remindersDescription: '분 단위 리마인더 목록, 예: 0,10,60', - idDescription: '취소할 이벤트 ID', - createSuccessTitle: '이벤트 생성 완료', - createSuccessBody: '**{{title}}** 이벤트가 예약되었습니다.', - listTitle: '예정된 이벤트 목록', - listEmpty: '예정된 이벤트가 없습니다.', - listItemValue: '**시작 시각:** {{startsAt}}\n**남은 시간:** {{relative}}\n**상태:** {{status}}\n**리마인더:** {{reminder}}\n**채널:** {{channel}}', - cancelSuccess: '`{{id}}` 이벤트를 취소했습니다.', - cancelNotFound: 'ID가 `{{id}}`인 예약 이벤트를 찾지 못했습니다.', - announceSuccess: '`{{id}}` 이벤트를 {{channel}} 채널에 공지했습니다.', - announceNotAvailable: '이 이벤트에는 사용할 수 있는 공지 채널이 설정되어 있지 않습니다.', - startAnnouncementTitle: '이벤트 시작', - startAnnouncementLead: '이 이벤트가 지금 시작됩니다.', - invalidDateTime: '이벤트 날짜 또는 시간 형식이 올바르지 않습니다.', - invalidDateTimeResolution: '날짜는 `YYYY-MM-DD`, 시간은 `HH:mm` 24시간 형식으로 입력해주세요.', - invalidReminderOffsets: '리마인더 분 입력 형식이 올바르지 않습니다.', - invalidReminderOffsetsResolution: '`0,10,60`처럼 0 이상의 분을 쉼표로 구분해 입력해주세요. 비워두면 자동 공지는 하지 않습니다.', - invalidPastDateTime: '과거 시각으로 이벤트를 예약할 수 없습니다.', - invalidPastDateTimeResolution: '미래 시각을 선택한 뒤 다시 시도해주세요.', + description: '?쒕쾭 ?대깽???쇱젙??愿€由ы빀?덈떎.', + createDescription: '???쒕쾭 ?대깽?몃? ?앹꽦?⑸땲??', + listDescription: '?덉젙???쒕쾭 ?대깽??紐⑸줉??議고쉶?⑸땲??', + cancelDescription: '?덉빟???쒕쾭 ?대깽?몃? 痍⑥냼?⑸땲??', + announceDescription: '?대깽??怨듭? Embed瑜??ㅼ떆 寃뚯떆?⑸땲??', + titleDescription: '?대깽???쒕ぉ', + dateDescription: 'YYYY-MM-DD ?뺤떇???좎쭨', + timeDescription: 'HH:mm ?뺤떇???쒓컙 (24?쒓컙?? Asia/Seoul 湲곗?)', + descriptionOptionDescription: '?좏깮 ?ы빆???대깽???ㅻ챸', + channelDescription: '?좏깮 ?ы빆??怨듭? 梨꾨꼸', + reminderDescription: '由щ쭏?몃뜑 硫붿떆吏€ ?ъ슜 ?щ?', + remindersDescription: '遺??⑥쐞 由щ쭏?몃뜑 紐⑸줉, ?? 0,10,60', + idDescription: '痍⑥냼???대깽??ID', + createSuccessTitle: '?대깽???앹꽦 ?꾨즺', + createSuccessBody: '**{{title}}** ?대깽?멸? ?덉빟?섏뿀?듬땲??', + listTitle: '?덉젙???대깽??紐⑸줉', + listEmpty: '?덉젙???대깽?멸? ?놁뒿?덈떎.', + listItemValue: '**?쒖옉 ?쒓컖:** {{startsAt}}\n**?⑥? ?쒓컙:** {{relative}}\n**?곹깭:** {{status}}\n**由щ쭏?몃뜑:** {{reminder}}\n**梨꾨꼸:** {{channel}}', + cancelSuccess: '`{{id}}` ?대깽?몃? 痍⑥냼?덉뒿?덈떎.', + cancelNotFound: 'ID媛€ `{{id}}`???덉빟 ?대깽?몃? 李얠? 紐삵뻽?듬땲??', + announceSuccess: '`{{id}}` ?대깽?몃? {{channel}} 梨꾨꼸??怨듭??덉뒿?덈떎.', + announceNotAvailable: '???대깽?몄뿉???ъ슜?????덈뒗 怨듭? 梨꾨꼸???ㅼ젙?섏뼱 ?덉? ?딆뒿?덈떎.', + startAnnouncementTitle: '?대깽???쒖옉', + startAnnouncementLead: '???대깽?멸? 吏€湲??쒖옉?⑸땲??', + invalidDateTime: '?대깽???좎쭨 ?먮뒗 ?쒓컙 ?뺤떇???щ컮瑜댁? ?딆뒿?덈떎.', + invalidDateTimeResolution: '?좎쭨??`YYYY-MM-DD`, ?쒓컙?€ `HH:mm` 24?쒓컙 ?뺤떇?쇰줈 ?낅젰?댁<?몄슂.', + invalidReminderOffsets: '由щ쭏?몃뜑 遺??낅젰 ?뺤떇???щ컮瑜댁? ?딆뒿?덈떎.', + invalidReminderOffsetsResolution: '`0,10,60`泥섎읆 0 ?댁긽??遺꾩쓣 ?쇳몴濡?援щ텇???낅젰?댁<?몄슂. 鍮꾩썙?먮㈃ ?먮룞 怨듭????섏? ?딆뒿?덈떎.', + invalidPastDateTime: '怨쇨굅 ?쒓컖?쇰줈 ?대깽?몃? ?덉빟?????놁뒿?덈떎.', + invalidPastDateTimeResolution: '誘몃옒 ?쒓컖???좏깮?????ㅼ떆 ?쒕룄?댁<?몄슂.', statusScheduled: '예약됨', statusCancelled: '취소됨', statusCompleted: '완료됨', - reminderOn: '사용', + reminderOn: '?ъ슜', reminderOff: '사용 안 함', - reminderNone: '자동 공지 없음', + reminderNone: '?먮룞 怨듭? ?놁쓬', announcementChannelNone: '미설정', fields: { - eventId: '이벤트 ID', - startsAt: '시작 시각', - reminder: '리마인더', - announcementChannel: '공지 채널', - status: '상태', + eventId: '?대깽??ID', + startsAt: '?쒖옉 ?쒓컖', + reminder: '由щ쭏?몃뜑', + announcementChannel: '怨듭? 梨꾨꼸', + status: '?곹깭', }, }, music: { @@ -258,118 +258,157 @@ export const ko: TranslationSchema = { leave: 'Leave', }, }, + fishing: { + description: '낚시 미니게임을 플레이합니다.', + enterDescription: '낚시 전용 스레드를 생성하거나 다시 엽니다.', + castDescription: '자신의 낚시 스레드 안에서 낚시 세션을 시작합니다.', + endDescription: '낚시 스레드를 종료하고 삭제합니다.', + disabled: '이 서버에서는 낚시 미니게임이 비활성화되어 있습니다.', + restrictedChannel: '낚시는 {{channel}} 채널에서만 시작할 수 있습니다.', + enterTextChannelOnly: '낚시 스레드는 일반 텍스트 채널에서만 열 수 있습니다.', + enterExistingThread: '이미 {{thread}}에 자신의 낚시 스레드가 열려 있습니다.', + enterCreated: '{{thread}}에 낚시 스레드를 만들었습니다.', + castThreadOnly: '/fishing cast는 자신의 낚시 스레드 안에서만 사용할 수 있습니다.', + startExistingSession: '이미 {{thread}}에서 진행 중인 낚시 세션이 있습니다.', + startCreated: '{{thread}}에서 낚시 세션을 시작했습니다.', + noActiveSession: '종료할 낚시 세션이나 스레드가 없습니다.', + ownerOnly: '이 낚시 세션의 소유자만 조작할 수 있습니다.', + wrongThread: '이 조작은 자신의 낚시 스레드 안에서만 사용할 수 있습니다.', + endDeleted: '낚시 스레드를 종료했습니다. 스레드를 삭제합니다.', + titleActive: '낚시 세션', + titleEnded: '낚시 세션 종료', + status: '상태', + targetFish: '대상 물고기', + distance: '거리', + tension: '끊어짐 게이지', + reward: '보상', + threadHint: '/fishing cast로 다시 시작하거나 /fishing end로 스레드를 삭제할 수 있습니다.', + catchResultTitle: '낚시 성공!', + catchResultBody: '**{{fish}}**를 낚았습니다. **{{reward}} G**를 획득했습니다.', + states: { + hooked: '입질 중', + resting: '휴식 중', + tense: '당기는 중', + missed: '타이밍 빗나감', + success: '낚시 성공', + failed: '줄이 끊어짐', + }, + }, permissionAudit: { title: '봇 권한 진단 보고서', - channel: '채널', - noResults: '진단할 기능이 없습니다. 봇이 아직 설정되지 않았을 수 있습니다.', - summaryLabel: '진단 결과 요약', - summaryOk: '✅ 모든 항목 정상. 문제가 없습니다.', - summaryIssue: '❌ {{fail}}개 실패 · ⚠️ {{warn}}개 경고 감지됨.', - hierarchyWarning: "봇 역할(순위: {{botPos}})이 '{{role}}'(순위: {{targetPos}})보다 위에 있어야 관리할 수 있습니다.", + channel: '梨꾨꼸', + noResults: '吏꾨떒??湲곕뒫???놁뒿?덈떎. 遊뉗씠 ?꾩쭅 ?ㅼ젙?섏? ?딆븯?????덉뒿?덈떎.', + summaryLabel: '吏꾨떒 寃곌낵 ?붿빟', + summaryOk: '??紐⑤뱺 ??ぉ ?뺤긽. 臾몄젣媛€ ?놁뒿?덈떎.', + summaryIssue: '??{{fail}}媛??ㅽ뙣 쨌 ?좑툘 {{warn}}媛?寃쎄퀬 媛먯???', + hierarchyWarning: "遊???븷(?쒖쐞: {{botPos}})??'{{role}}'(?쒖쐞: {{targetPos}})蹂대떎 ?꾩뿉 ?덉뼱??愿€由ы븷 ???덉뒿?덈떎.", features: { - BASIC: '기본 봇 기능', - VOICE_GLOBAL: '임시 음성 채널 (전역)', - VOICE_GENERATOR_CHANNEL: '음성 생성기 채널', - VOICE_GENERATOR_CATEGORY: '음성 생성기 카테고리', - INVITE_TRACKING: '초대 추적', - INVITE_ROLE_HIERARCHY: '초대 역할 부여 (계층 검사)', - MIMIC_WEBHOOK: '메시지 흉내 (Webhook)', + BASIC: '湲곕낯 遊?湲곕뒫', + VOICE_GLOBAL: '?꾩떆 ?뚯꽦 梨꾨꼸 (?꾩뿭)', + VOICE_GENERATOR_CHANNEL: '?뚯꽦 ?앹꽦湲?梨꾨꼸', + VOICE_GENERATOR_CATEGORY: '?뚯꽦 ?앹꽦湲?移댄뀒怨좊━', + INVITE_TRACKING: '珥덈? 異붿쟻', + INVITE_ROLE_HIERARCHY: '珥덈? ??븷 遺€??(怨꾩링 寃€??', + MIMIC_WEBHOOK: '硫붿떆吏€ ?됰궡 (Webhook)', }, }, setup: { - description: '설정 마법사를 실행하여 봇의 필수 기능들을 단계별로 설정합니다.', + description: '?ㅼ젙 留덈쾿?щ? ?ㅽ뻾?섏뿬 遊뉗쓽 ?꾩닔 湲곕뒫?ㅼ쓣 ?④퀎蹂꾨줈 ?ㅼ젙?⑸땲??', step0: { - title: '✨ 봇 설정 마법사 시작', - desc: '환영합니다! 이 마법사를 통해 아래 4가지 항목을 설정합니다.\n\n1️⃣ **언어 설정**\n2️⃣ **필수 권한 점검**\n3️⃣ **감사 채널 설정**\n4️⃣ **임시 음성 채널 설정**', - startBtn: '설정 시작하기' + title: '??遊??ㅼ젙 留덈쾿???쒖옉', + desc: '?섏쁺?⑸땲?? ??留덈쾿?щ? ?듯빐 ?꾨옒 4媛€吏€ ??ぉ???ㅼ젙?⑸땲??\n\n1截뤴깵 **?몄뼱 ?ㅼ젙**\n2截뤴깵 **?꾩닔 沅뚰븳 ?먭?**\n3截뤴깵 **媛먯궗 梨꾨꼸 ?ㅼ젙**\n4截뤴깵 **?꾩떆 ?뚯꽦 梨꾨꼸 ?ㅼ젙**', + startBtn: '?ㅼ젙 ?쒖옉?섍린' }, step1: { - title: '1️⃣ 언어 설정', - desc: '서버 전체에 적용될 봇의 기본 언어를 선택하세요. (현재: **{{locale}}**)', - placeholder: '언어를 선택하세요', - nextBtn: '다음 단계', - skipBtn: '건너뛰기' + title: '1截뤴깵 ?몄뼱 ?ㅼ젙', + desc: '?쒕쾭 ?꾩껜???곸슜??遊뉗쓽 湲곕낯 ?몄뼱瑜??좏깮?섏꽭?? (?꾩옱: **{{locale}}**)', + placeholder: '언어를 선택하세요', + nextBtn: '?ㅼ쓬 ?④퀎', + skipBtn: '嫄대꼫?곌린' }, step2: { - title: '2️⃣ 필수 권한 점검', - descOk: '✅ **모든 필수 권한이 정상적으로 부여되어 있습니다.**', - descFail: '⚠️ **일부 권한이 부족합니다.**\n결과를 확인하고 봇 역할에 필요한 기능 권한을 부여해주세요.', - recheckBtn: '다시 검사하기', - nextBtn: '다음 단계' + title: '2截뤴깵 ?꾩닔 沅뚰븳 ?먭?', + descOk: '??**紐⑤뱺 ?꾩닔 沅뚰븳???뺤긽?곸쑝濡?遺€?щ릺???덉뒿?덈떎.**', + descFail: '?좑툘 **?쇰? 沅뚰븳??遺€議깊빀?덈떎.**\n寃곌낵瑜??뺤씤?섍퀬 遊???븷???꾩슂??湲곕뒫 沅뚰븳??遺€?ы빐二쇱꽭??', + recheckBtn: '다시 검사하기', + nextBtn: '?ㅼ쓬 ?④퀎' }, step3: { - title: '3️⃣ 감사 채널 설정', - desc: '봇의 주요 이벤트와 에러 통보를 받을 채널을 선택해주세요.', - placeholder: '감사 통보 채널 선택', - disableBtn: '감사 채널 끄기/해제', - nextBtn: '다음 단계' + title: '3截뤴깵 媛먯궗 梨꾨꼸 ?ㅼ젙', + desc: '遊뉗쓽 二쇱슂 ?대깽?몄? ?먮윭 ?듬낫瑜?諛쏆쓣 梨꾨꼸???좏깮?댁<?몄슂.', + placeholder: '媛먯궗 ?듬낫 梨꾨꼸 ?좏깮', + disableBtn: '媛먯궗 梨꾨꼸 ?꾧린/?댁젣', + nextBtn: '?ㅼ쓬 ?④퀎' }, step4: { - title: '감사 로그 카테고리 설정', - desc: '로그를 수신할 카테고리를 선택해주세요.', - nextBtn: '다음 단계', + title: '媛먯궗 濡쒓렇 移댄뀒怨좊━ ?ㅼ젙', + desc: '濡쒓렇瑜??섏떊??移댄뀒怨좊━瑜??좏깮?댁<?몄슂.', + nextBtn: '?ㅼ쓬 ?④퀎', }, step5: { - title: '4️⃣ 임시 음성 채널 설정', - desc: '임시 음성 채널을 생성할 "생성기 채널"을 선택해주세요.\n기존의 채널을 고르거나 카테고리/채널을 봇이 **자동 생성**하게 할 수도 있습니다.', - placeholder: '생성기로 쓸 음성 채널 선택', - autoBtn: '🚀 자동 생성하기', - skipBtn: '임시 음성 사용 안함', - nextBtn: '설정 완료' + title: '4截뤴깵 ?꾩떆 ?뚯꽦 梨꾨꼸 ?ㅼ젙', + desc: '?꾩떆 ?뚯꽦 梨꾨꼸???앹꽦??"?앹꽦湲?梨꾨꼸"???좏깮?댁<?몄슂.\n湲곗〈??梨꾨꼸??怨좊Ⅴ嫄곕굹 移댄뀒怨좊━/梨꾨꼸??遊뉗씠 **?먮룞 ?앹꽦**?섍쾶 ???섎룄 ?덉뒿?덈떎.', + placeholder: '?앹꽦湲곕줈 ???뚯꽦 梨꾨꼸 ?좏깮', + autoBtn: '?? ?먮룞 ?앹꽦?섍린', + skipBtn: '?꾩떆 ?뚯꽦 ?ъ슜 ?덊븿', + nextBtn: '?ㅼ젙 ?꾨즺' }, step6: { - title: '🎉 설정 완료 요약', - desc: '**1. 언어**: {{lang}}\n**2. 감사 채널**: {{audit}}\n**3. 감사 카테고리**: {{categories}}\n**4. 임시 음성 채널**: {{voice}}', + title: '?럦 ?ㅼ젙 ?꾨즺 ?붿빟', + desc: '**1. ?몄뼱**: {{lang}}\n**2. 媛먯궗 梨꾨꼸**: {{audit}}\n**3. 媛먯궗 移댄뀒怨좊━**: {{categories}}\n**4. ?꾩떆 ?뚯꽦 梨꾨꼸**: {{voice}}', finishBtn: '마치기' }, - finished: '✅ 설정 마법사를 종료했습니다.', - expired: '⏳ 시간이 만료되었습니다. `/setup`을 다시 실행해주세요.', - defaultCategoryName: '음성 채널', - defaultGeneratorName: '➕ 채널 생성하기', + finished: '???ㅼ젙 留덈쾿?щ? 醫낅즺?덉뒿?덈떎.', + expired: '???쒓컙??留뚮즺?섏뿀?듬땲?? `/setup`???ㅼ떆 ?ㅽ뻾?댁<?몄슂.', + defaultCategoryName: '?뚯꽦 梨꾨꼸', + defaultGeneratorName: '??梨꾨꼸 ?앹꽦?섍린', auditCategories: { SYSTEM: '시스템', BOOT: '부팅', - VOICE: '음성', - PERMISSION: '권한', - INVITE: '초대', + VOICE: '?뚯꽦', + PERMISSION: '沅뚰븳', + INVITE: '珥덈?', }, }, config: { - title: '기능 설정 변경 결과', - noOptions: '변경할 옵션을 하나 이상 선택해주세요.', + title: '湲곕뒫 ?ㅼ젙 蹂€寃?寃곌낵', + noOptions: '蹂€寃쏀븷 ?듭뀡???섎굹 ?댁긽 ?좏깮?댁<?몄슂.', mimic: { - label: '미믹(Mimic)', + label: '誘몃?(Mimic)', enabled: '활성화', - disabled: '비활성화', + disabled: '鍮꾪솢?깊솕', }, emoji: { - label: '이모지 확대(Big Emoji)', + label: '?대え吏€ ?뺣?(Big Emoji)', enabled: '활성화', - disabled: '비활성화', + disabled: '鍮꾪솢?깊솕', }, }, }, - // ── 모달 ──────────────────────────────────────────────── + // ?€?€ 紐⑤떖 ?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€ modals: { renameTitle: '음성 채널 이름 변경', - renameLabel: '새 채널 이름', - limitTitle: '인원 제한 설정', - limitLabel: '인원 제한 (0 = 무제한, 1-99)', + renameLabel: '??梨꾨꼸 ?대쫫', + limitTitle: '?몄썝 ?쒗븳 ?ㅼ젙', + limitLabel: '?몄썝 ?쒗븳 (0 = 臾댁젣?? 1-99)', }, - // ── 셀렉트 메뉴 플레이스홀더 ──────────────────────────── + // ?€?€ ?€?됲듃 硫붾돱 ?뚮젅?댁뒪?€???€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€ selects: { kickUser: '추방할 유저를 선택하세요', banUser: '차단할 유저를 선택하세요', transferOwner: '소유권을 이전할 유저를 선택하세요', }, - // ── 상태 메시지 ────────────────────────────────────────── + // ?€?€ ?곹깭 硫붿떆吏€ ?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€ presence: { - servers: '{{guildCount}}개의 서버에서 활동 중', + servers: '{{guildCount}}개의 서버에서 작동 중', help: '/help 명령어를 확인하세요', managing: '임시 음성 채널 관리 중', version: 'Kord v1.0.0', }, }; + + + diff --git a/src/i18n/types.ts b/src/i18n/types.ts index fddfb5b..6975133 100644 --- a/src/i18n/types.ts +++ b/src/i18n/types.ts @@ -212,6 +212,42 @@ export interface TranslationSchema { leave: string; }; }; + fishing: { + description: string; + enterDescription: string; + castDescription: string; + endDescription: string; + disabled: string; + restrictedChannel: string; + enterTextChannelOnly: string; + enterExistingThread: string; + enterCreated: string; + castThreadOnly: string; + startExistingSession: string; + startCreated: string; + noActiveSession: string; + ownerOnly: string; + wrongThread: string; + endDeleted: string; + titleActive: string; + titleEnded: string; + status: string; + targetFish: string; + distance: string; + tension: string; + reward: string; + threadHint: string; + catchResultTitle: string; + catchResultBody: string; + states: { + hooked: string; + resting: string; + tense: string; + missed: string; + success: string; + failed: string; + }; + }; permissionAudit: { title: string; channel: string; diff --git a/src/services/FishingService.ts b/src/services/FishingService.ts new file mode 100644 index 0000000..4121ad0 --- /dev/null +++ b/src/services/FishingService.ts @@ -0,0 +1,635 @@ +import { + ActionRowBuilder, + AttachmentBuilder, + ButtonBuilder, + ButtonInteraction, + ButtonStyle, + ChannelType, + ChatInputCommandInteraction, + EmbedBuilder, + Message, + TextChannel, + ThreadAutoArchiveDuration, + ThreadChannel, +} from 'discord.js'; +import fs from 'node:fs'; +import path from 'node:path'; +import { SupportedLocale, t } from '../i18n'; +import { RefinementService } from './RefinementService'; +import { logger } from '../utils/logger'; + +export type FishingDirection = 'left' | 'center' | 'right'; +type FishingAction = FishingDirection | 'rest'; +type FishingState = 'hooked' | 'resting' | 'tense' | 'missed' | 'success' | 'failed'; + +interface FishingRange { + min: number; + max: number; +} + +interface FishingCatalogEntry { + id: string; + displayName: string; + spawnRate: number; + rewardGold: FishingRange; + reactionWindowSec: number; + distanceReductionByPosition: Record; + artResourcePaths: string[]; +} + +interface FishingCatalogFile { + fish: FishingCatalogEntry[]; +} + +interface FishingSession { + guildId: string; + userId: string; + userName: string; + locale: SupportedLocale; + threadId: string; + thread: ThreadChannel; + controlMessage: Message; + currentFish: FishingCatalogEntry; + fishPosition: FishingDirection; + selectedAction: FishingAction | null; + phaseStartedAt: number; + distance: number; + lineTension: number; + status: FishingState; + reward: number | null; + tickInterval: NodeJS.Timeout | null; + isRendering: boolean; + needsRender: boolean; +} + +const MAX_DISTANCE = 100; +const MAX_TENSION = 100; +const MATCH_TENSION_INCREASE = 12; +const MISS_TENSION_INCREASE = 6; +const REST_TENSION_RECOVERY = 20; +const REST_DISTANCE_INCREASE = 8; +const DISTANCE_BAR_WIDTH = 12; +const TENSION_BAR_WIDTH = 12; +const ROUND_DURATION_MS = 2500; +const REACTION_MIN_MS = 1200; +const REACTION_GRACE_MS = 300; +const SESSION_TICK_MS = 250; + +export class FishingService { + private static sessionsByUser = new Map(); + private static sessionsByThread = new Map(); + private static threadEnterPromises = new Map>(); + private static fishingCatalog = this.loadFishingCatalog(); + + static async enterThread(interaction: ChatInputCommandInteraction) { + if (!interaction.guildId || !interaction.channel || interaction.channel.type !== ChannelType.GuildText) { + throw new Error('ENTER_TEXT_ONLY'); + } + + const userKey = this.getUserKey(interaction.guildId, interaction.user.id); + const pending = this.threadEnterPromises.get(userKey); + if (pending) { + return pending; + } + + const enterPromise = this.findOrCreateThread( + interaction.channel as TextChannel, + interaction.user.username, + interaction.user.tag, + ); + + this.threadEnterPromises.set(userKey, enterPromise); + + try { + return await enterPromise; + } finally { + this.threadEnterPromises.delete(userKey); + } + } + + static isOwnedFishingThread(channel: unknown, userName: string): channel is ThreadChannel { + return !!channel + && typeof channel === 'object' + && 'isThread' in channel + && typeof (channel as { isThread: () => boolean }).isThread === 'function' + && (channel as { isThread: () => boolean }).isThread() + && 'name' in channel + && (channel as { name: string }).name === this.buildThreadName(userName); + } + + static async startSessionInThread(interaction: ChatInputCommandInteraction, locale: SupportedLocale) { + if (!interaction.guildId || !this.isOwnedFishingThread(interaction.channel, interaction.user.username)) { + throw new Error('CAST_THREAD_ONLY'); + } + + const userKey = this.getUserKey(interaction.guildId, interaction.user.id); + const existing = this.sessionsByUser.get(userKey); + if (existing) { + return { thread: existing.thread, existed: true }; + } + + const thread = interaction.channel; + await this.createSessionInThread({ + guildId: interaction.guildId, + userId: interaction.user.id, + userName: interaction.user.username, + locale, + thread, + }); + + return { thread, existed: false }; + } + + static async endThreadByUser(interaction: ChatInputCommandInteraction, locale: SupportedLocale) { + if (!interaction.guildId) { + return false; + } + + const session = this.sessionsByUser.get(this.getUserKey(interaction.guildId, interaction.user.id)); + if (!session) { + const thread = await this.findOwnedFishingThread(interaction); + if (!thread) { + return false; + } + + await this.deleteThread(thread); + return true; + } + + session.locale = locale; + await this.finishSession(session, 'failed', true); + return true; + } + + static async handleButton(interaction: ButtonInteraction, locale: SupportedLocale) { + const [, action, ownerId] = interaction.customId.split('_'); + const session = interaction.guildId + ? this.sessionsByUser.get(this.getUserKey(interaction.guildId, ownerId)) ?? this.sessionsByThread.get(interaction.channelId) + : null; + + if (!interaction.guildId || !session) { + await interaction.update({ + components: [this.buildControlRow(ownerId || 'stale', true)], + }); + await interaction.followUp({ + content: t(locale, 'commands.fishing.noActiveSession'), + ephemeral: true, + }); + return; + } + + if (interaction.user.id !== session.userId) { + await interaction.reply({ + content: t(locale, 'commands.fishing.ownerOnly'), + ephemeral: true, + }); + return; + } + + if (interaction.channelId !== session.threadId) { + await interaction.reply({ + content: t(locale, 'commands.fishing.wrongThread'), + ephemeral: true, + }); + return; + } + + await interaction.deferUpdate(); + session.locale = locale; + await this.queueAction(session, action as FishingAction); + } + + private static async tickSession(session: FishingSession) { + if (!this.sessionsByUser.has(this.getUserKey(session.guildId, session.userId))) { + this.clearTick(session); + return; + } + + const elapsed = Date.now() - session.phaseStartedAt; + const reactionWindowMs = this.getReactionWindowMs(session.currentFish); + + if ( + session.selectedAction + && session.selectedAction !== 'rest' + && session.selectedAction === session.fishPosition + && elapsed >= reactionWindowMs + ) { + await this.resolveSuccessfulPull(session); + return; + } + + if (elapsed >= ROUND_DURATION_MS + REACTION_GRACE_MS) { + await this.resolveMiss(session); + } + } + + private static async queueAction(session: FishingSession, action: FishingAction) { + session.selectedAction = action; + + if (action === 'rest') { + session.lineTension = Math.max(0, session.lineTension - REST_TENSION_RECOVERY); + session.distance = Math.min(MAX_DISTANCE, session.distance + REST_DISTANCE_INCREASE); + session.status = 'resting'; + this.startNextPhase(session); + await this.renderSession(session, false); + return; + } + + session.status = 'hooked'; + await this.renderSession(session, false); + } + + private static async resolveSuccessfulPull(session: FishingSession) { + const distanceReduction = this.rollRange(session.currentFish.distanceReductionByPosition[session.fishPosition]); + session.distance = Math.max(0, session.distance - distanceReduction); + session.lineTension = Math.min(MAX_TENSION, session.lineTension + MATCH_TENSION_INCREASE); + session.status = session.lineTension >= MAX_TENSION ? 'failed' : 'tense'; + + if (session.distance <= 0) { + const reward = this.rollRange(session.currentFish.rewardGold); + session.reward = reward; + await RefinementService.addGold(session.userId, session.guildId, reward); + await this.finishSession(session, 'success', false); + return; + } + + if (session.lineTension >= MAX_TENSION) { + await this.finishSession(session, 'failed', false); + return; + } + + this.startNextPhase(session); + await this.renderSession(session, false); + } + + private static async resolveMiss(session: FishingSession) { + session.lineTension = Math.min(MAX_TENSION, session.lineTension + MISS_TENSION_INCREASE); + session.status = session.selectedAction && session.selectedAction !== 'rest' ? 'missed' : 'hooked'; + + if (session.lineTension >= MAX_TENSION) { + await this.finishSession(session, 'failed', false); + return; + } + + this.startNextPhase(session); + await this.renderSession(session, false); + } + + private static startNextPhase(session: FishingSession) { + session.selectedAction = null; + session.phaseStartedAt = Date.now(); + session.fishPosition = this.randomDirection(); + } + + private static async finishSession(session: FishingSession, finalState: 'success' | 'failed', deleteThread: boolean) { + session.status = finalState; + this.clearTick(session); + this.sessionsByUser.delete(this.getUserKey(session.guildId, session.userId)); + this.sessionsByThread.delete(session.threadId); + + logger.info(`[Fishing] Finished session for ${session.userId} with state ${finalState}.`); + + await this.renderSession(session, true); + + if (finalState === 'success') { + await this.sendCatchResult(session); + } + + if (deleteThread) { + await this.deleteThread(session.thread); + } + } + + private static async deleteThread(thread: ThreadChannel) { + try { + await thread.delete(); + } catch (error) { + logger.warn('Failed to delete fishing thread, archiving instead:', error); + try { + await thread.setArchived(true); + await thread.setLocked(true); + } catch (archiveError) { + logger.warn('Failed to archive fishing thread after delete failure:', archiveError); + } + } + } + + private static async renderSession(session: FishingSession, disabled: boolean) { + if (session.isRendering) { + session.needsRender = true; + return; + } + + session.isRendering = true; + + try { + await session.controlMessage.edit({ + embeds: [this.buildEmbed(session)], + components: [this.buildControlRow(session.userId, disabled)], + }); + } finally { + session.isRendering = false; + } + + if (session.needsRender) { + session.needsRender = false; + await this.renderSession(session, disabled); + } + } + + private static buildEmbed( + session: Pick, + ) { + const distanceProgress = MAX_DISTANCE - session.distance; + const distanceBar = this.buildGauge(distanceProgress, MAX_DISTANCE, DISTANCE_BAR_WIDTH); + const tensionBar = this.buildGauge(session.lineTension, MAX_TENSION, TENSION_BAR_WIDTH); + const stateKey = `commands.fishing.states.${session.status}`; + const selectedAction = this.formatSelectedAction(session.selectedAction); + + const embed = new EmbedBuilder() + .setColor(session.status === 'success' ? 0x57f287 : session.status === 'failed' ? 0xed4245 : 0x5865f2) + .setTitle( + session.status === 'success' || session.status === 'failed' + ? t(session.locale, 'commands.fishing.titleEnded') + : t(session.locale, 'commands.fishing.titleActive'), + ) + .setDescription([ + '```', + '⬅️ ⏺️ ➡️', + this.buildFishLane(session.fishPosition), + '```', + ].join('\n')) + .addFields( + { + name: t(session.locale, 'commands.fishing.status'), + value: `${t(session.locale, stateKey)}\n🎯 ${selectedAction}`, + inline: true, + }, + { + name: t(session.locale, 'commands.fishing.distance'), + value: `${distanceBar} ${session.distance} / ${MAX_DISTANCE}`, + inline: false, + }, + { + name: t(session.locale, 'commands.fishing.tension'), + value: `${tensionBar} ${session.lineTension} / ${MAX_TENSION}`, + inline: false, + }, + ) + .setFooter({ + text: t(session.locale, 'commands.fishing.threadHint'), + }); + + if (session.reward !== null) { + embed.addFields({ + name: t(session.locale, 'commands.fishing.reward'), + value: `${session.reward} G`, + inline: true, + }); + } + + return embed; + } + + private static buildControlRow(userId: string, disabled: boolean) { + return new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(`fishing_left_${userId}`) + .setEmoji('⬅️') + .setStyle(ButtonStyle.Secondary) + .setDisabled(disabled), + new ButtonBuilder() + .setCustomId(`fishing_center_${userId}`) + .setEmoji('⏺️') + .setStyle(ButtonStyle.Secondary) + .setDisabled(disabled), + new ButtonBuilder() + .setCustomId(`fishing_right_${userId}`) + .setEmoji('➡️') + .setStyle(ButtonStyle.Secondary) + .setDisabled(disabled), + new ButtonBuilder() + .setCustomId(`fishing_rest_${userId}`) + .setEmoji('🛌') + .setStyle(ButtonStyle.Primary) + .setDisabled(disabled), + ); + } + + private static buildGauge(current: number, max: number, width: number) { + const ratio = max > 0 ? Math.max(0, Math.min(1, current / max)) : 0; + const filled = Math.round(ratio * width); + return `${'█'.repeat(filled)}${'░'.repeat(width - filled)}`; + } + + private static async createSessionInThread(params: { + guildId: string; + userId: string; + userName: string; + locale: SupportedLocale; + thread: ThreadChannel; + }) { + const currentFish = this.pickFishByRate(); + const sessionBase = { + guildId: params.guildId, + userId: params.userId, + userName: params.userName, + locale: params.locale, + threadId: params.thread.id, + thread: params.thread, + currentFish, + fishPosition: this.randomDirection(), + selectedAction: null, + phaseStartedAt: Date.now(), + distance: MAX_DISTANCE, + lineTension: 0, + status: 'hooked' as FishingState, + reward: null, + tickInterval: null, + isRendering: false, + needsRender: false, + }; + + const controlMessage = await params.thread.send({ + embeds: [this.buildEmbed(sessionBase)], + components: [this.buildControlRow(params.userId, false)], + }); + + const session: FishingSession = { + ...sessionBase, + controlMessage, + }; + + this.sessionsByUser.set(this.getUserKey(session.guildId, session.userId), session); + this.sessionsByThread.set(session.threadId, session); + + logger.info(`[Fishing] Started session for ${session.userId} in thread ${session.threadId}.`); + + session.tickInterval = setInterval(() => { + void this.tickSession(session); + }, SESSION_TICK_MS); + + return session; + } + + private static async findOrCreateThread(parentChannel: TextChannel, userName: string, userTag: string) { + const threadName = this.buildThreadName(userName); + const activeThreads = await parentChannel.threads.fetchActive(); + const existingThread = activeThreads.threads.find((thread) => thread.name === threadName); + + if (existingThread) { + logger.info(`[Fishing] Reusing existing thread ${existingThread.id} for ${userName}.`); + return { thread: existingThread as ThreadChannel, existed: true }; + } + + const thread = await parentChannel.threads.create({ + name: threadName, + autoArchiveDuration: ThreadAutoArchiveDuration.OneHour, + reason: `Fishing thread for ${userTag}`, + }); + + return { thread, existed: false }; + } + + private static async findOwnedFishingThread(interaction: ChatInputCommandInteraction) { + if (this.isOwnedFishingThread(interaction.channel, interaction.user.username)) { + return interaction.channel; + } + + if (!interaction.channel || interaction.channel.type !== ChannelType.GuildText) { + return null; + } + + const activeThreads = await interaction.channel.threads.fetchActive(); + const thread = activeThreads.threads.find((candidate) => candidate.name === this.buildThreadName(interaction.user.username)); + return (thread as ThreadChannel | undefined) ?? null; + } + + private static async sendCatchResult(session: FishingSession) { + const artPath = this.pickRandomArtPath(session.currentFish); + const embed = new EmbedBuilder() + .setColor(0x57f287) + .setTitle(t(session.locale, 'commands.fishing.catchResultTitle')) + .setDescription( + t(session.locale, 'commands.fishing.catchResultBody', { + fish: session.currentFish.displayName, + reward: String(session.reward ?? 0), + }), + ); + + if (artPath && fs.existsSync(artPath)) { + const fileName = path.basename(artPath); + embed.setImage(`attachment://${fileName}`); + await session.thread.send({ + embeds: [embed], + files: [new AttachmentBuilder(artPath, { name: fileName })], + }); + return; + } + + await session.thread.send({ embeds: [embed] }); + } + + static previewFishLane(position: FishingDirection) { + return this.buildFishLane(position); + } + + static previewGauge(current: number, max: number, width: number) { + return this.buildGauge(current, max, width); + } + + private static buildFishLane(position: FishingDirection) { + if (position === 'left') return '🐟 · ·'; + if (position === 'right') return '· · 🐟'; + return '· 🐟 ·'; + } + + private static randomDirection(): FishingDirection { + const directions: FishingDirection[] = ['left', 'center', 'right']; + return directions[Math.floor(Math.random() * directions.length)]; + } + + private static buildThreadName(userName: string) { + const sanitized = userName.replace(/[^\w\s-]/g, '').trim() || 'User'; + return `${sanitized}'s Fishing Spot`.slice(0, 100); + } + + private static loadFishingCatalog(): FishingCatalogEntry[] { + const catalogPath = this.resolveResourcePath('resource/data/fishing/fish_catalog.json'); + const raw = fs.readFileSync(catalogPath, 'utf-8'); + const parsed = JSON.parse(raw) as FishingCatalogFile; + + if (!parsed.fish?.length) { + throw new Error('Fishing catalog is empty.'); + } + + return parsed.fish; + } + + private static pickFishByRate() { + const totalRate = this.fishingCatalog.reduce((sum, fish) => sum + fish.spawnRate, 0); + let roll = Math.random() * totalRate; + + for (const fish of this.fishingCatalog) { + roll -= fish.spawnRate; + if (roll <= 0) { + return fish; + } + } + + return this.fishingCatalog[this.fishingCatalog.length - 1]; + } + + private static getReactionWindowMs(fish: FishingCatalogEntry) { + const configured = Math.round(fish.reactionWindowSec * 1000); + return Math.max(REACTION_MIN_MS, Math.min(configured, ROUND_DURATION_MS)); + } + + private static rollRange(range: FishingRange) { + if (range.min === range.max) { + return range.min; + } + + return range.min + Math.floor(Math.random() * (range.max - range.min + 1)); + } + + private static pickRandomArtPath(fish: FishingCatalogEntry) { + if (!fish.artResourcePaths.length) { + return null; + } + + const relativePath = fish.artResourcePaths[Math.floor(Math.random() * fish.artResourcePaths.length)]; + return this.resolveResourcePath(relativePath); + } + + private static resolveResourcePath(relativePath: string) { + return path.resolve(__dirname, '..', '..', relativePath); + } + + private static formatSelectedAction(action: FishingAction | null) { + if (action === 'left') return '⬅️'; + if (action === 'center') return '⏺️'; + if (action === 'right') return '➡️'; + if (action === 'rest') return '🛌'; + return '—'; + } + + private static clearTick(session: FishingSession) { + if (session.tickInterval) { + clearInterval(session.tickInterval); + session.tickInterval = null; + } + } + + private static getUserKey(guildId: string, userId: string) { + return `${guildId}:${userId}`; + } +} + +export function buildFishingGauge(current: number, max: number, width: number) { + return FishingService.previewGauge(current, max, width); +} + +export function buildFishingLane(position: FishingDirection) { + return FishingService.previewFishLane(position); +} diff --git a/src/services/MiniGameRegistry.ts b/src/services/MiniGameRegistry.ts index 894e9f6..d81a736 100644 --- a/src/services/MiniGameRegistry.ts +++ b/src/services/MiniGameRegistry.ts @@ -5,6 +5,11 @@ export interface MiniGame { } export const MINI_GAMES: Record = { + fishing: { + key: 'fishing', + name: 'Fishing', + description: 'A real-time fishing mini-game that grants gold rewards.', + }, refinement: { key: 'refinement', name: '재련', diff --git a/src/services/RefinementService.ts b/src/services/RefinementService.ts index 38ae93b..57aceba 100644 --- a/src/services/RefinementService.ts +++ b/src/services/RefinementService.ts @@ -300,6 +300,18 @@ export class RefinementService { return this.getOrCreateProfile(userId, guildId); } + public static async addGold(userId: string, guildId: string, amount: number): Promise { + const profile = await this.getOrCreateProfile(userId, guildId); + const updated = await prisma.refinementProfile.update({ + where: { userId_guildId: { userId, guildId } }, + data: { + gold: profile.gold + amount, + }, + }); + + return updated.gold; + } + private static async getOrCreateProfile(userId: string, guildId: string) { let profile = await prisma.refinementProfile.findUnique({ where: { userId_guildId: { userId, guildId } } diff --git a/tests/services/FishingService.test.ts b/tests/services/FishingService.test.ts new file mode 100644 index 0000000..11eaff6 --- /dev/null +++ b/tests/services/FishingService.test.ts @@ -0,0 +1,13 @@ +import { buildFishingGauge, buildFishingLane } from '../../src/services/FishingService'; + +describe('FishingService helpers', () => { + it('renders a gauge with filled and empty segments', () => { + expect(buildFishingGauge(50, 100, 10)).toBe('█████░░░░░'); + }); + + it('renders the fish lane for each position', () => { + expect(buildFishingLane('left')).toContain('🐟'); + expect(buildFishingLane('center')).toBe('· 🐟 ·'); + expect(buildFishingLane('right')).toBe('· · 🐟'); + }); +});