Compare commits

..

2 Commits

25 changed files with 1918 additions and 248 deletions

View File

@ -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 메시지는 게임 엔진이 아니므로, 순간 반응보다는 명확한 판정 윈도우가 더 중요합니다.
- 최종 설계는 화려함보다 반응성, 명확성, 실패 상태의 가독성을 우선해야 합니다.

View File

@ -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에서는 통계 조회, 프로필 영속화, 도감/인벤토리 같은 확장 기능을 고려할 수 있습니다.

View File

@ -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`에 존재하던 일부 깨진 문자열은 빌드를 막는 구간 위주로 우선 복구했습니다.
- 세션은 메모리 기반이므로 봇 재시작 시 진행 중 낚시 세션은 유지되지 않습니다.

View File

@ -1,57 +1,61 @@
# Kord Documentation Index
# Kord Documentation Index
ì<EFBFBD>´ 루트 색ì<E280B0>¸ 문서는 프로ì <C3AC>트 ë´ì<C2B4>˜ 모든 구조화ë<E2809D>œ 문서를 카테고리별로 모아 íƒ<C3AD>색ì<E280B0>„ ë<>•기 위해 ìžì„±ë<C2B1>˜ì—ˆìеëˆë¤.
??猷⑦듃 ?됱씤 臾몄꽌???꾨줈?앺듃 ?댁쓽 紐⑤뱺 援ъ“?붾맂 臾몄꽌瑜?移댄뀒怨좊━蹂꾨줈 紐⑥븘 ?먯깋???뺢린 ?꾪빐 ?묒꽦?섏뿀?듬땲??
## ì •ì±… ë°<C3AB> 규칙 (Rules)
## ?뺤콉 諛?洹쒖튃 (Rules)
- [보안 ê°€ì<E282AC>´ë“œë<C593>¼ì<C2BC>¸ (Security Rules)](Rules/security_guidelines.md)
- [다국어 ì§€ì<C3AC> 개발 ê°€ì<E282AC>´ë“œë<C593>¼ì<C2BC>¸ (i18n Development Guidelines)](Rules/i18n_guidelines.md)
- [蹂댁븞 媛€?대뱶?쇱씤 (Security Rules)](Rules/security_guidelines.md)
- [?ㅺ뎅??吏€??媛쒕컻 媛€?대뱶?쇱씤 (i18n Development Guidelines)](Rules/i18n_guidelines.md)
## 기능 명세 (Features)
## 湲곕뒫 紐낆꽭 (Features)
- [임시 ì<>Œì„± 채ë„<C3AB> ìž<C3AC>ë<EFBFBD>™í™” (Temp Voice Channels)](Features/temp_voice_channels.md)
- [?꾩떆 ?뚯꽦 梨꾨꼸 ?먮룞??(Temp Voice Channels)](Features/temp_voice_channels.md)
## 기íš<EFBFBD>서 (Plans)
## 湲고쉷??(Plans)
- [ì—<EFBFBD>러 안내 기능 기íš<C3AD>서 (Error Guidance Plan)](Plans/Error_Guidance_Plan.md)
- [다국어 ì§€ì<C3AC> 기íš<C3AD>서 (i18n Plan)](Plans/i18n_Plan.md)
- [서버 ì<>´ë²¤íЏ ì<>¼ì • 관리 기능 기íš<C3AD>안 (Event Schedule Management Plan)](Plans/Event_Schedule_Management_Plan.md)
- [YouTube ì<EFBFBD>Œì•… 재ìƒ<C3AC> 기능 기íš<C3AD>안 (YouTube Music Playback Plan)](Plans/YouTube_Music_Playback_Plan.md)
- [재련 미니게임 기íš<C3AD>서 (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)
## ì•„í¤í…<EFBFBD>처 ë°<C3AB> ì •ì±… ê²°ì • (Decisions)
## ?꾪궎?띿쿂 諛??뺤콉 寃곗젙 (Decisions)
- [구ë<EFBFBD>… í‹°ì–´ 시스템 설계 (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 유령 채ë„<C3AB> 미삭제 버그 í•´ê²°ê±´](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: ë´‡ ìƒ<C3AC>태 메시지 기능 구현 (Bot Presence Implementation)](WorkDone/2026-03-27_Presence_Implementation.md)
- [2026-03-27: 임시 ì<>Œì„± 채ë„<C3AB> 기능 구현 (Temp Voice Channels Implementation)](WorkDone/2026-03-27_Voice_Channels_Implementation.md)
- [2026-03-27: 임시 ì<>Œì„± 채ë„<C3AB> ê³ ë<C2A0>„í™” (Voice Channels Improvements)](WorkDone/2026-03-27_Voice_Channels_Improvements.md)
- [2026-03-27: 다국어 ì§€ì<C3AC> 구현 (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 명령어 ë°<C3AB> 기능 관리 리팩토ë§<C3AB> (Config & Feature Refactoring)](WorkDone/2026-03-27_Config_And_Feature_Refactoring.md)
- [2026-03-27: ê°<C3AA>ì¬ ì±„ë„<C3AB> 구현 (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: ì—<C3AC>러 안내 UX 개선 ë°<C3AB> 통합 (Error Guidance UX Implementation)](WorkDone/2026-03-27_Error_Guidance_UX_Implementation.md)
- [2026-03-27: Kord 프로ì <C3AC>트 초기 설정 (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: 서버 ì<>´ë²¤íЏ 시작 ìœì <C3AC> 공지 구현 (Event Schedule Start Announcement Implementation)](WorkDone/2026-03-30_Event_Schedule_Start_Announcement_Implementation.md)
- [2026-03-30: ì<>´ë²¤íЏ 리마ì<CB86>¸ë<C2B8>” ë¶„ 단위 옵션 구현 (Event Reminder Offsets Implementation)](WorkDone/2026-03-30_Event_Reminder_Offsets_Implementation.md)
- [2026-03-30: 명령어 계층 구조 리팩토ë§<C3AB> (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: 미니게임 시스템 ë°<C3AB> 재련 게임 구현 (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)

View File

@ -0,0 +1 @@

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

View File

@ -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"
]
}
]
}

127
src/commands/fishing.ts Normal file
View File

@ -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'),
});
}
},
};

View File

@ -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 () => {

View File

@ -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',
},
};

View File

@ -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}}**)',
title: '1截뤴깵 ?몄뼱 ?ㅼ젙',
desc: '?쒕쾭 ?꾩껜???곸슜??遊뉗쓽 湲곕낯 ?몄뼱瑜??좏깮?섏꽭?? (?꾩옱: **{{locale}}**)',
placeholder: '언어를 선택하세요',
nextBtn: '다음 단계',
skipBtn: '건너뛰기'
nextBtn: '?ㅼ쓬 ?④퀎',
skipBtn: '嫄대꼫?곌린'
},
step2: {
title: '2⃣ 필수 권한 점검',
descOk: '✅ **모든 필수 권한이 정상적으로 부여되어 있습니다.**',
descFail: '⚠️ **일부 권한이 부족합니다.**\n결과를 확인하고 봇 역할에 필요한 기능 권한을 부여해주세요.',
title: '2截뤴깵 ?꾩닔 沅뚰븳 ?먭?',
descOk: '??**紐⑤뱺 ?꾩닔 沅뚰븳???뺤긽?곸쑝濡?遺€?щ릺???덉뒿?덈떎.**',
descFail: '?좑툘 **?쇰? 沅뚰븳??遺€議깊빀?덈떎.**\n寃곌낵瑜??뺤씤?섍퀬 遊???븷???꾩슂??湲곕뒫 沅뚰븳??遺€?ы빐二쇱꽭??',
recheckBtn: '다시 검사하기',
nextBtn: '다음 단계'
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',
},
};

View File

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

View File

@ -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<FishingDirection, FishingRange>;
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<string, FishingSession>();
private static sessionsByThread = new Map<string, FishingSession>();
private static threadEnterPromises = new Map<string, Promise<{ thread: ThreadChannel; existed: boolean }>>();
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<FishingSession, 'locale' | 'currentFish' | 'fishPosition' | 'selectedAction' | 'phaseStartedAt' | 'distance' | 'lineTension' | 'status' | 'reward'>,
) {
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<ButtonBuilder>().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);
}

View File

@ -5,6 +5,11 @@ export interface MiniGame {
}
export const MINI_GAMES: Record<string, MiniGame> = {
fishing: {
key: 'fishing',
name: 'Fishing',
description: 'A real-time fishing mini-game that grants gold rewards.',
},
refinement: {
key: 'refinement',
name: '재련',

View File

@ -300,6 +300,18 @@ export class RefinementService {
return this.getOrCreateProfile(userId, guildId);
}
public static async addGold(userId: string, guildId: string, amount: number): Promise<number> {
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 } }

View File

@ -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('· · 🐟');
});
});