Compare commits
11 Commits
85f5057fd7
...
45d2b22978
| Author | SHA1 | Date |
|---|---|---|
|
|
45d2b22978 | |
|
|
f9769ba87c | |
|
|
ba253a8ad3 | |
|
|
d51eadb8c4 | |
|
|
019cb314be | |
|
|
49a2855f2b | |
|
|
2762801fbd | |
|
|
cc26613377 | |
|
|
793bba5a6a | |
|
|
0c7a562b00 | |
|
|
f504024bd5 |
|
|
@ -0,0 +1,53 @@
|
||||||
|
# Kord Documentation Index
|
||||||
|
|
||||||
|
이 루트 색인 문서는 프로젝트 내의 모든 구조화된 문서를 카테고리별로 모아 탐색을 돕기 위해 작성되었습니다.
|
||||||
|
|
||||||
|
|
||||||
|
## 정책 및 규칙 (Rules)
|
||||||
|
|
||||||
|
- [보안 가이드라인 (Security Rules)](Rules/security_guidelines.md)
|
||||||
|
- [다국어 지원 개발 가이드라인 (i18n Development Guidelines)](Rules/i18n_guidelines.md)
|
||||||
|
|
||||||
|
|
||||||
|
## 기능 명세 (Features)
|
||||||
|
|
||||||
|
- [임시 음성 채널 자동화 (Temp Voice Channels)](Features/temp_voice_channels.md)
|
||||||
|
|
||||||
|
|
||||||
|
## 기획서 (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)
|
||||||
|
|
||||||
|
|
||||||
|
## 아키텍처 및 정책 결정 (Decisions)
|
||||||
|
|
||||||
|
- [구독 티어 시스템 설계 (Subscription Tiers)](Decisions/subscription_tiers.md)
|
||||||
|
|
||||||
|
|
||||||
|
## 트러블슈팅 (Troubleshooting)
|
||||||
|
|
||||||
|
- [Voice Channel Missing Permissions (50013) 해결건](Troubleshooting/50013_Missing_Permissions.md)
|
||||||
|
- [Temp Voice 유령 채널 미삭제 버그 해결건](Troubleshooting/handleLeave_ghost_channel.md)
|
||||||
|
|
||||||
|
|
||||||
|
## 진행/완료 내역 (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)
|
||||||
|
|
@ -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 메시지는 게임 엔진이 아니므로, 순간 반응보다는 명확한 판정 윈도우가 더 중요합니다.
|
||||||
|
- 최종 설계는 화려함보다 반응성, 명확성, 실패 상태의 가독성을 우선해야 합니다.
|
||||||
|
|
@ -0,0 +1,63 @@
|
||||||
|
# Refinement Mini-Game Implementation Plan
|
||||||
|
|
||||||
|
This document outlines the design and implementation details of the 'Refinement' (재련) mini-game system for Kord.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
The Refinement mini-game allows users to strengthen their virtual weapons, participate in simple battles, and earn gold. It features a high-risk, high-reward progression system with a community-wide 'Fever Time' bonus.
|
||||||
|
|
||||||
|
## Core Features
|
||||||
|
|
||||||
|
### 1. Weapon Refinement
|
||||||
|
- **Progression**: Level- **재련 비용**: `floor(10 × 1.6^level)G` (레벨별 기하급수적 증가)
|
||||||
|
- **판매 가격**: `floor(현재_단계_비용 × 보정계수)G`
|
||||||
|
- 0~4강: 비용의 **2배**
|
||||||
|
- 5~25강: 비용의 **(2 + (L-4) × 10)배** (기대값 보정으로 고강화 가치 극대화)
|
||||||
|
- **재련 성공 확률**:
|
||||||
|
- 0→4강: 100% ~ 90%
|
||||||
|
- 4→5강: **5%** (급락)
|
||||||
|
- 11→20강: **0.1%** (신화)
|
||||||
|
- 21→25강: **0.01%** (기적)
|
||||||
|
- **전투 보상**: `(패배자_단계_비용 × (패배자_레벨/승리자_레벨) × 2.0) × (0.8~1.2 랜덤)`
|
||||||
|
- **최대 단계**: **25강** (Lvl 25)
|
||||||
|
- **최대 내구도**: **10 + Level** (강화 단계가 높을수록 내구도가 확장됨)
|
||||||
|
- **전투 규칙**:
|
||||||
|
- 0강 무기 소유자는 전투를 걸 수 없으며, 공격 대상으로 지정될 수도 없습니다.
|
||||||
|
- **일일 공격 제한**: 각 유저는 **하루 최대 10회**만 전투를 신청할 수 있습니다. (00시 초기화)
|
||||||
|
- **승리 확률 페널티**: 상대보다 5강 이상 낮을 경우 승률이 급락하며, 10강 차이부터는 **승률 0%**가 적용됩니다.
|
||||||
|
- 전투 참여 시(공격/방어 모두) 내구도 -1.
|
||||||
|
- 내구도 0인 상태에서 전투를 시도하면 전투 후 무기가 즉시 **파괴**됩니다.
|
||||||
|
- 재련 성공 시 내구도가 새로운 **최대치(10+Level)**로 완전 회복(수리)됩니다.
|
||||||
|
- 내구도가 0인 상태에서 전투 시도(공격) 시 전투 후 무기 파괴.
|
||||||
|
as the level increases.
|
||||||
|
- **Risk**: Failure at higher levels can lead to weapon destruction (reset to level 0).
|
||||||
|
- **Durability**: Each battle reduces durability. **Success and Level Up fully restores durability.**
|
||||||
|
|
||||||
|
### 2. Battle System
|
||||||
|
- **One-way Attack**: Users can attack others without a formal acceptance process.
|
||||||
|
- **Outcome**: Calculated based on weapon levels.
|
||||||
|
- **Rewards**: The winner receives gold based on both participants' levels.
|
||||||
|
- **Weapon Destruction**: If a user attacks while their durability is 0, their weapon is permanently destroyed.
|
||||||
|
|
||||||
|
### 3. Fever System
|
||||||
|
- **Dynamic Analysis**: The bot tracks server message activity per hour.
|
||||||
|
- **Activation**: Fever mode activates during the server's peak activity hour (determined by historical data).
|
||||||
|
- **Bonus**: +10% success rate for all refinement attempts during Fever Time.
|
||||||
|
|
||||||
|
### 4. Economy & Management
|
||||||
|
- **Gold Sources**: Daily check-ins, winning battles, and selling weapons.
|
||||||
|
- **Check-in**: `/refine checkin` grants a set amount of gold once per day.
|
||||||
|
- **Selling**: Max level weapons must be sold to restart the progression.
|
||||||
|
- **Registry**: Server admins can enable/disable mini-games and restrict them to specific channels via `/minigame`.
|
||||||
|
|
||||||
|
## Technical Architecture
|
||||||
|
- **Prisma**: Persists user profiles, server configs, activity logs, and fever states.
|
||||||
|
- **ActivityTrackerService**: Buffers and saves message counts to avoid DB bottleneck.
|
||||||
|
- **FeverService**: Analyzes activity and manages the 1-hour fever window.
|
||||||
|
- **RefinementService**: Contains the core game logic (probabilities, rewards).
|
||||||
|
|
||||||
|
## Implementation Progress
|
||||||
|
- [x] Database Schema updates
|
||||||
|
- [x] Infrastructure (Registry, Activity Tracking)
|
||||||
|
- [x] Game Logic (Refinement, Battle, Economy)
|
||||||
|
- [x] Commands and Interaction Handlers
|
||||||
|
- [x] Fever System Integration
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
# 미니게임 시스템 및 재련(Refinement) 구현 완료 보고서
|
||||||
|
|
||||||
|
- **날짜**: 2026-03-30
|
||||||
|
- **담당**: Antigravity (AI Assistant)
|
||||||
|
|
||||||
|
## 1. 개요 (Overview)
|
||||||
|
Kord 봇의 메인 기능 외에 즐길 거리를 제공하기 위해 **미니게임 관리 시스템**을 구축하고, 첫 번째 정식 게임으로 **재련(Refinement)** 미니게임을 구현했습니다. 또한 서버 활동량을 분석하여 보너스를 제공하는 **피버(Fever)** 시스템을 도입했습니다.
|
||||||
|
|
||||||
|
## 2. 주요 구현 내용
|
||||||
|
|
||||||
|
### 2.1. 미니게임 관리 인프라 (Core)
|
||||||
|
- **MiniGameRegistry**: 미니게임을 통합 등록하고 관리하는 구조를 설계했습니다.
|
||||||
|
- **Config Command**: `/minigame` 명령어를 통해 서버 관리자가 게임별 활성화(`toggle`), 전용 채널(`channel`), 상태(`status`)를 관리할 수 있습니다.
|
||||||
|
- **Prisma 7 Migration**: 봇의 DB 라이브러리를 Prisma 7.6.0으로 업그레이드하고, 새로운 엔진 구조에 맞춰 **Driver Adapter(PostgreSQL)** 설정을 완료했습니다.
|
||||||
|
|
||||||
|
### 2.2. 재련(Refinement) 미니게임
|
||||||
|
- **성장 시스템**: 0단계부터 20단계까지 무기를 강화할 수 있습니다.
|
||||||
|
- **확률 로직**: 단계가 올라갈수록 성공 확률이 감소하며, 실패 시 무기가 파괴될 위험이 존재합니다.
|
||||||
|
- **강화 효과**: 강화 성공 시 무기의 **내구도가 즉시 100% 회복**됩니다.
|
||||||
|
- **배틀 시스템**: 수락 절차 없이 일방적으로 공격하는 배틀 시스템을 구현했습니다. (내구도 소모 및 파괴 리스크 포함)
|
||||||
|
- **경제 시스템**: 일일 출석(`checkin`)과 강화 단계에 따른 무기 판매(`sell`) 기능을 통해 골드 선순환 구조를 만들었습니다.
|
||||||
|
|
||||||
|
### 2.3. 피버 타임 시스템 (Fever System)
|
||||||
|
- **활동 추적**: 모든 메시지 활동을 시간대별(Hour)로 집계하여 `ActivityLog`에 기록합니다.
|
||||||
|
- **피크 시간 분석**: 과거 데이터를 분석하여 서버가 가장 활발한 시간대를 찾아냅니다.
|
||||||
|
- **보너스**: 피버 타임(1시간) 동안 재련 성공 확률이 **+10%** 추가로 가산됩니다.
|
||||||
|
|
||||||
|
## 3. 기술적 변경 사항
|
||||||
|
- **DB 스키마**: `MiniGameConfig`, `RefinementProfile`, `ActivityLog`, `FeverState` 총 4개의 모델을 추가했습니다.
|
||||||
|
- **서비스 레이어**: `RefinementService`, `FeverService`, `ActivityTrackerService` 등 비즈니스 로직을 독립된 서비스로 분리했습니다.
|
||||||
|
- **인터랙션**: 재련 결과 임베드에서 버튼을 통해 즉시 다시 시도할 수 있는 UX를 구현했습니다.
|
||||||
|
|
||||||
|
## 4. 검증 결과 (Verification)
|
||||||
|
- **런타임 테스트**: `yarn run dev` 환경에서 Driver Adapter를 통한 DB 연결 및 봇 초기화 성공을 확인했습니다.
|
||||||
|
- **테스트 코드**: Prisma 7 초기화 이슈를 해결하여 기존 테스트 슈트(Voice, Config 등)가 정상 작동함을 확인했습니다.
|
||||||
|
- **명령어 동작**: `/minigame` 및 `/refine`의 모든 서브커맨드 응답 및 데이터 반영을 확인했습니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
**보고서 끝.**
|
||||||
|
|
@ -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에서는 통계 조회, 프로필 영속화, 도감/인벤토리 같은 확장 기능을 고려할 수 있습니다.
|
||||||
|
|
@ -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`에 존재하던 일부 깨진 문자열은 빌드를 막는 구간 위주로 우선 복구했습니다.
|
||||||
|
- 세션은 메모리 기반이므로 봇 재시작 시 진행 중 낚시 세션은 유지되지 않습니다.
|
||||||
|
|
@ -1,59 +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)
|
- [蹂댁븞 媛?대뱶?쇱씤 (Security Rules)](Rules/security_guidelines.md)
|
||||||
- [다êµì–´ ì§€ì›<C3AC> 개발 ê°€ì<E282AC>´ë“œë<C593>¼ì<C2BC>¸ (i18n Development Guidelines)](Rules/i18n_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)
|
||||||
|
|
||||||
- [기능 로드맵 (Feature Roadmap)](Plans/Feature_Roadmap.md)
|
- [?먮윭 ?덈궡 湲곕뒫 湲고쉷??(Error Guidance Plan)](Plans/Error_Guidance_Plan.md)
|
||||||
- [임시 ì<>Œì„± 채ë„<C3AB> 기능 기íš<C3AD>서 (Temp Voice Channel Plan)](Plans/Temp_Voice_Channel_Plan.md)
|
- [?ㅺ뎅??吏??湲고쉷??(i18n Plan)](Plans/i18n_Plan.md)
|
||||||
- [ì—<EFBFBD>러 안내 기능 기íš<C3AD>서 (Error Guidance Plan)](Plans/Error_Guidance_Plan.md)
|
- [?쒕쾭 ?대깽???쇱젙 愿由?湲곕뒫 湲고쉷??(Event Schedule Management Plan)](Plans/Event_Schedule_Management_Plan.md)
|
||||||
- [ë´‡ ìƒ<C3AC>태 메시지 기íš<C3AD> (Bot Presence Plan)](Plans/Bot_Presence_Plan.md)
|
- [YouTube ?뚯븙 ?ъ깮 湲곕뒫 湲고쉷??(YouTube Music Playback Plan)](Plans/YouTube_Music_Playback_Plan.md)
|
||||||
- [ì—<EFBFBD>러 안내 기능 기íš<C3AD>서 (Error Guidance Plan)](Plans/Error_Guidance_Plan.md)
|
- [?щ젴 誘몃땲寃뚯엫 湲고쉷??(Refinement Mini-Game Plan)](Plans/MiniGame_Refinement_Plan.md)
|
||||||
- [다êµì–´ ì§€ì›<C3AC> 기íš<C3AD>서 (i18n Plan)](Plans/i18n_Plan.md)
|
|
||||||
- [서버 ì<>´ë²¤íЏ ì<>¼ì • 관리 기능 기íš<C3AD>안 (Event Schedule Management Plan)](Plans/Event_Schedule_Management_Plan.md)
|
|
||||||
- [YouTube ì<>Œì•… 재ìƒ<C3AC> 기능 기íš<C3AD>안 (YouTube Music Playback Plan)](Plans/YouTube_Music_Playback_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)
|
- [Voice Channel Missing Permissions (50013) ?닿껐嫄?(Troubleshooting/50013_Missing_Permissions.md)
|
||||||
- [Temp Voice ìœ ë ¹ 채ë„<C3AB> 미ì‚ì œ 버그 í•´ê²°ê±´](Troubleshooting/handleLeave_ghost_channel.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: 遊??곹깭 硫붿떆吏 湲곕뒫 援ы쁽 (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: ?꾩떆 ?뚯꽦 梨꾨꼸 湲곕뒫 援ы쁽 (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: ?꾩떆 ?뚯꽦 梨꾨꼸 怨좊룄??(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 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: 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: /config 紐낅졊??諛?湲곕뒫 愿由?由ы뙥?좊쭅 (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: 媛먯궗 梨꾨꼸 援ы쁽 (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: 沅뚰븳 吏꾨떒 湲곕뒫 援ы쁽 (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: ?먮윭 ?덈궡 UX 媛쒖꽑 諛??듯빀 (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-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 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: ?쒕쾭 ?대깽???쇱젙 愿由?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: ?쒕쾭 ?대깽???쒖옉 ?쒖젏 怨듭? 援ы쁽 (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: ?대깽??由щ쭏?몃뜑 遺??⑥쐞 ?듭뀡 援ы쁽 (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: 紐낅졊??怨꾩링 援ъ“ 由ы뙥?좊쭅 (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-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 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-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)
|
||||||
|
|
|
||||||
|
|
@ -68,4 +68,7 @@ Kord는 Discord 서버 관리를 돕는 강력하고 유연한 다기능 봇입
|
||||||
- **서버 설정 (Config)**: 따라하기(Mimic), 큰 이모지(Big Emoji) 등 봇의 기능을 서버별 환경에 맞게 토글하거나 설정할 수 있습니다.
|
- **서버 설정 (Config)**: 따라하기(Mimic), 큰 이모지(Big Emoji) 등 봇의 기능을 서버별 환경에 맞게 토글하거나 설정할 수 있습니다.
|
||||||
- **권한 감사 (Permission Audit)**: 봇의 정상 작동을 방해하는 권한 문제를 즉시 진단하고 해결 방법을 안내합니다.
|
- **권한 감사 (Permission Audit)**: 봇의 정상 작동을 방해하는 권한 문제를 즉시 진단하고 해결 방법을 안내합니다.
|
||||||
- **초기 설정 마법사 (Setup Wizard)**: 봇 도입 초기 기능을 한눈에 설정할 수 있는 직관적인 UI를 제공합니다.
|
- **초기 설정 마법사 (Setup Wizard)**: 봇 도입 초기 기능을 한눈에 설정할 수 있는 직관적인 UI를 제공합니다.
|
||||||
|
- **미니게임 시스템 (Mini-Games)**: 서버 내에서 즐길 수 있는 다양한 미니게임을 제공하며, 관리자가 게임별 활성화 및 전용 채널을 설정할 수 있습니다.
|
||||||
|
- **재련 (Refinement)**: 무기를 강화하고 다른 유저와 전투하며 골드를 획득하는 성장형 미니게임입니다.
|
||||||
|
- **피버 타임 (Fever Time)**: 서버 활동량을 자동으로 분석하여 가장 활발한 시간대에 재련 성공 확률 보너스를 제공합니다.
|
||||||
- **다국어 지원 (i18n)**: 한국어와 영어를 포함한 다국어 환경을 지원합니다.
|
- **다국어 지원 (i18n)**: 한국어와 영어를 포함한 다국어 환경을 지원합니다.
|
||||||
|
|
|
||||||
10
package.json
|
|
@ -4,11 +4,14 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@discordjs/opus": "^0.10.0",
|
"@discordjs/opus": "^0.10.0",
|
||||||
"@discordjs/voice": "^0.19.2",
|
"@discordjs/voice": "^0.19.2",
|
||||||
"@prisma/client": "6.4.1",
|
"@prisma/adapter-pg": "^7.6.0",
|
||||||
|
"@prisma/client": "7.6.0",
|
||||||
|
"@types/pg": "^8.20.0",
|
||||||
"discord.js": "^14.25.1",
|
"discord.js": "^14.25.1",
|
||||||
"dotenv": "^17.3.1",
|
"dotenv": "^17.3.1",
|
||||||
"ffmpeg-static": "^5.3.0",
|
"ffmpeg-static": "^5.3.0",
|
||||||
"ioredis": "^5.10.1",
|
"ioredis": "^5.10.1",
|
||||||
|
"pg": "^8.20.0",
|
||||||
"prism-media": "^1.3.5",
|
"prism-media": "^1.3.5",
|
||||||
"youtubei.js": "^17.0.1"
|
"youtubei.js": "^17.0.1"
|
||||||
},
|
},
|
||||||
|
|
@ -20,7 +23,7 @@
|
||||||
"eslint": "^10.1.0",
|
"eslint": "^10.1.0",
|
||||||
"jest": "^30.3.0",
|
"jest": "^30.3.0",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
"prisma": "6.4.1",
|
"prisma": "7.6.0",
|
||||||
"ts-jest": "^29.4.6",
|
"ts-jest": "^29.4.6",
|
||||||
"tsx": "^4.21.0",
|
"tsx": "^4.21.0",
|
||||||
"typescript": "^6.0.2"
|
"typescript": "^6.0.2"
|
||||||
|
|
@ -31,5 +34,8 @@
|
||||||
"start": "node dist/index.js",
|
"start": "node dist/index.js",
|
||||||
"test": "jest",
|
"test": "jest",
|
||||||
"check-i18n": "tsx scripts/check-i18n-tests.ts"
|
"check-i18n": "tsx scripts/check-i18n-tests.ts"
|
||||||
|
},
|
||||||
|
"prisma": {
|
||||||
|
"seed": "tsx prisma/seed.ts"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
import path from 'node:path';
|
||||||
|
import { defineConfig } from 'prisma/config';
|
||||||
|
import 'dotenv/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
schema: path.join('prisma', 'schema.prisma'),
|
||||||
|
datasource: {
|
||||||
|
url: process.env.DATABASE_URL!,
|
||||||
|
},
|
||||||
|
migrations: {
|
||||||
|
seed: 'tsx ./prisma/seed.ts',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,72 @@
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "MiniGameConfig" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"guildId" TEXT NOT NULL,
|
||||||
|
"gameKey" TEXT NOT NULL,
|
||||||
|
"enabled" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"channelId" TEXT,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "MiniGameConfig_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "RefinementProfile" (
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"guildId" TEXT NOT NULL,
|
||||||
|
"gold" INTEGER NOT NULL DEFAULT 1000,
|
||||||
|
"weaponLevel" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"maxWeaponLevel" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"durability" INTEGER NOT NULL DEFAULT 10,
|
||||||
|
"tryCount" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"successCount" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"failCount" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"destroyCount" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"battleWin" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"battleLoss" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"isDisabled" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"lastCheckIn" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "RefinementProfile_pkey" PRIMARY KEY ("userId","guildId")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "ActivityLog" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"guildId" TEXT NOT NULL,
|
||||||
|
"hour" INTEGER NOT NULL,
|
||||||
|
"dayOfWeek" INTEGER NOT NULL,
|
||||||
|
"count" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"weekStart" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "ActivityLog_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "FeverState" (
|
||||||
|
"guildId" TEXT NOT NULL,
|
||||||
|
"isActive" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"peakHour" INTEGER,
|
||||||
|
"bonusRate" DOUBLE PRECISION NOT NULL DEFAULT 0.1,
|
||||||
|
"expiresAt" TIMESTAMP(3),
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "FeverState_pkey" PRIMARY KEY ("guildId")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "MiniGameConfig_guildId_idx" ON "MiniGameConfig"("guildId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "MiniGameConfig_guildId_gameKey_key" ON "MiniGameConfig"("guildId", "gameKey");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "RefinementProfile_guildId_weaponLevel_idx" ON "RefinementProfile"("guildId", "weaponLevel" DESC);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "ActivityLog_guildId_weekStart_idx" ON "ActivityLog"("guildId", "weekStart");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "ActivityLog_guildId_hour_dayOfWeek_weekStart_key" ON "ActivityLog"("guildId", "hour", "dayOfWeek", "weekStart");
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "RefinementProfile" ADD COLUMN "dailyBattleCount" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
ADD COLUMN "lastBattleReset" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "RefinementLevelConfig" (
|
||||||
|
"level" INTEGER NOT NULL,
|
||||||
|
"successRate" DOUBLE PRECISION NOT NULL,
|
||||||
|
"destroyRate" DOUBLE PRECISION NOT NULL,
|
||||||
|
"sellMultiplier" DOUBLE PRECISION NOT NULL,
|
||||||
|
"cost" INTEGER NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "RefinementLevelConfig_pkey" PRIMARY KEY ("level")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "RefinementBattleConfig" (
|
||||||
|
"levelGap" INTEGER NOT NULL,
|
||||||
|
"winRate" DOUBLE PRECISION NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "RefinementBattleConfig_pkey" PRIMARY KEY ("levelGap")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "RefinementSystemConfig" (
|
||||||
|
"key" TEXT NOT NULL,
|
||||||
|
"value" TEXT NOT NULL,
|
||||||
|
"description" TEXT,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "RefinementSystemConfig_pkey" PRIMARY KEY ("key")
|
||||||
|
);
|
||||||
|
|
@ -4,7 +4,6 @@ generator client {
|
||||||
|
|
||||||
datasource db {
|
datasource db {
|
||||||
provider = "postgresql"
|
provider = "postgresql"
|
||||||
url = env("DATABASE_URL")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
model GuildConfig {
|
model GuildConfig {
|
||||||
|
|
@ -140,3 +139,93 @@ enum EventStatus {
|
||||||
CANCELLED
|
CANCELLED
|
||||||
COMPLETED
|
COMPLETED
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Mini Game System ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// 서버별 미니게임 활성화 상태 관리
|
||||||
|
model MiniGameConfig {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
guildId String
|
||||||
|
gameKey String
|
||||||
|
enabled Boolean @default(false)
|
||||||
|
channelId String?
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@unique([guildId, gameKey])
|
||||||
|
@@index([guildId])
|
||||||
|
}
|
||||||
|
|
||||||
|
// 재련 - 유저 상태
|
||||||
|
model RefinementProfile {
|
||||||
|
userId String
|
||||||
|
guildId String
|
||||||
|
gold Int @default(1000)
|
||||||
|
weaponLevel Int @default(0)
|
||||||
|
maxWeaponLevel Int @default(0)
|
||||||
|
durability Int @default(10)
|
||||||
|
tryCount Int @default(0)
|
||||||
|
successCount Int @default(0)
|
||||||
|
failCount Int @default(0)
|
||||||
|
destroyCount Int @default(0)
|
||||||
|
battleWin Int @default(0)
|
||||||
|
battleLoss Int @default(0)
|
||||||
|
dailyBattleCount Int @default(0)
|
||||||
|
lastBattleReset DateTime @default(now())
|
||||||
|
isDisabled Boolean @default(false)
|
||||||
|
lastCheckIn DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@id([userId, guildId])
|
||||||
|
@@index([guildId, weaponLevel(sort: Desc)])
|
||||||
|
}
|
||||||
|
|
||||||
|
// 재련 - 레벨별 수치 설정 (수치 대조 및 수정용)
|
||||||
|
model RefinementLevelConfig {
|
||||||
|
level Int @id
|
||||||
|
successRate Float
|
||||||
|
destroyRate Float
|
||||||
|
sellMultiplier Float
|
||||||
|
cost Int
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
// 재련 - 레벨 차이별 승률 설정 (승률 곡선 관리)
|
||||||
|
model RefinementBattleConfig {
|
||||||
|
levelGap Int @id // (공격자 레벨 - 방어자 레벨)
|
||||||
|
winRate Float
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
// 재련 - 전역 시스템 설정 (일일 제한, 초기 자본 등)
|
||||||
|
model RefinementSystemConfig {
|
||||||
|
key String @id
|
||||||
|
value String
|
||||||
|
description String?
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
// 서버 활동 추이 (시간대별 메시지 수)
|
||||||
|
model ActivityLog {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
guildId String
|
||||||
|
hour Int
|
||||||
|
dayOfWeek Int
|
||||||
|
count Int @default(0)
|
||||||
|
weekStart DateTime
|
||||||
|
|
||||||
|
@@unique([guildId, hour, dayOfWeek, weekStart])
|
||||||
|
@@index([guildId, weekStart])
|
||||||
|
}
|
||||||
|
|
||||||
|
// 피버 상태
|
||||||
|
model FeverState {
|
||||||
|
guildId String @id
|
||||||
|
isActive Boolean @default(false)
|
||||||
|
peakHour Int?
|
||||||
|
bonusRate Float @default(0.1)
|
||||||
|
expiresAt DateTime?
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,116 @@
|
||||||
|
import { Pool } from 'pg';
|
||||||
|
import { PrismaPg } from '@prisma/adapter-pg';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import 'dotenv/config';
|
||||||
|
|
||||||
|
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
||||||
|
const adapter = new PrismaPg(pool);
|
||||||
|
const prisma = new PrismaClient({ adapter });
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('🌱 Start seeding refinement balance data...');
|
||||||
|
|
||||||
|
// 1. Refinement System Config (Global)
|
||||||
|
const systemConfigs = [
|
||||||
|
{ key: 'MAX_LEVEL', value: '25', description: 'Maximum weapon level' },
|
||||||
|
{ key: 'START_GOLD', value: '1000', description: 'Initial gold for new players' },
|
||||||
|
{ key: 'CHECKIN_GOLD', value: '500', description: 'Gold awarded for daily check-in' },
|
||||||
|
{ key: 'DAILY_BATTLE_LIMIT', value: '10', description: 'Maximum battles per day' },
|
||||||
|
{ key: 'INVERSE_REWARD_MULTIPLIER', value: '2.0', description: 'Base multiplier for battle rewards' },
|
||||||
|
{ key: 'REWARD_RANDOM_MIN', value: '0.8', description: 'Minimum random factor for rewards' },
|
||||||
|
{ key: 'REWARD_RANDOM_MAX', value: '1.2', description: 'Maximum random factor for rewards' },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const config of systemConfigs) {
|
||||||
|
await prisma.refinementSystemConfig.upsert({
|
||||||
|
where: { key: config.key },
|
||||||
|
update: config,
|
||||||
|
create: config,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Refinement Level Config (0-25)
|
||||||
|
const levelConfigs = [];
|
||||||
|
for (let l = 0; l <= 25; l++) {
|
||||||
|
let successRate = 0;
|
||||||
|
if (l === 0) successRate = 1.0;
|
||||||
|
else if (l === 1) successRate = 0.98;
|
||||||
|
else if (l === 2) successRate = 0.95;
|
||||||
|
else if (l === 3) successRate = 0.92;
|
||||||
|
else if (l === 4) successRate = 0.90;
|
||||||
|
else if (l === 5) successRate = 0.05;
|
||||||
|
else if (l === 6) successRate = 0.04;
|
||||||
|
else if (l === 7) successRate = 0.03;
|
||||||
|
else if (l === 8) successRate = 0.02;
|
||||||
|
else if (l < 10) successRate = 0.01;
|
||||||
|
else if (l < 20) successRate = 0.001;
|
||||||
|
else successRate = 0.0001;
|
||||||
|
|
||||||
|
const cost = Math.floor(10 * Math.pow(1.6, l));
|
||||||
|
const destroyRate = l * 0.015;
|
||||||
|
const sellMultiplier = l < 5 ? 2.0 : 2 + (l - 4) * 10;
|
||||||
|
|
||||||
|
levelConfigs.push({
|
||||||
|
level: l,
|
||||||
|
successRate,
|
||||||
|
destroyRate,
|
||||||
|
cost,
|
||||||
|
sellMultiplier,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const config of levelConfigs) {
|
||||||
|
await prisma.refinementLevelConfig.upsert({
|
||||||
|
where: { level: config.level },
|
||||||
|
update: config,
|
||||||
|
create: config,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Refinement Battle Config (Gap)
|
||||||
|
const battleConfigs = [];
|
||||||
|
|
||||||
|
for (let g = 0; g <= 25; g++) {
|
||||||
|
battleConfigs.push({ gap: g, winRate: Math.min(0.99, 0.5 + g * 0.05) });
|
||||||
|
}
|
||||||
|
|
||||||
|
const negativeGaps = [
|
||||||
|
{ gap: -1, rate: 0.4 },
|
||||||
|
{ gap: -2, rate: 0.3 },
|
||||||
|
{ gap: -3, rate: 0.2 },
|
||||||
|
{ gap: -4, rate: 0.1 },
|
||||||
|
{ gap: -5, rate: 0.05 },
|
||||||
|
{ gap: -6, rate: 0.04 },
|
||||||
|
{ gap: -7, rate: 0.03 },
|
||||||
|
{ gap: -8, rate: 0.02 },
|
||||||
|
{ gap: -9, rate: 0.01 },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const n of negativeGaps) {
|
||||||
|
battleConfigs.push({ gap: n.gap, winRate: n.rate });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let g = -10; g >= -25; g--) {
|
||||||
|
battleConfigs.push({ gap: g, winRate: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const config of battleConfigs) {
|
||||||
|
await prisma.refinementBattleConfig.upsert({
|
||||||
|
where: { levelGap: config.gap },
|
||||||
|
update: { winRate: config.winRate },
|
||||||
|
create: { levelGap: config.gap, winRate: config.winRate },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('✅ Seeding completed!');
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
await pool.end();
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
|
||||||
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 97 KiB |
|
After Width: | Height: | Size: 111 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 114 KiB |
|
After Width: | Height: | Size: 110 KiB |
|
|
@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,7 @@ import { loadEvents } from '../handlers/EventLoader';
|
||||||
import { handleGlobalExceptions } from '../utils/errorHandler';
|
import { handleGlobalExceptions } from '../utils/errorHandler';
|
||||||
import { connectDB } from '../database';
|
import { connectDB } from '../database';
|
||||||
import { connectRedis } from '../cache';
|
import { connectRedis } from '../cache';
|
||||||
|
import { FeverService } from '../services/FeverService';
|
||||||
|
|
||||||
export class KordClient extends Client {
|
export class KordClient extends Client {
|
||||||
public commands: Collection<string, any> = new Collection();
|
public commands: Collection<string, any> = new Collection();
|
||||||
|
|
|
||||||
|
|
@ -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'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,133 @@
|
||||||
|
import {
|
||||||
|
SlashCommandBuilder,
|
||||||
|
ChatInputCommandInteraction,
|
||||||
|
PermissionFlagsBits,
|
||||||
|
EmbedBuilder,
|
||||||
|
Colors,
|
||||||
|
ChannelType
|
||||||
|
} from 'discord.js';
|
||||||
|
import { prisma } from '../database';
|
||||||
|
import { t, SupportedLocale } from '../i18n';
|
||||||
|
import { MINI_GAMES, getAllMiniGames } from '../services/MiniGameRegistry';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
data: new SlashCommandBuilder()
|
||||||
|
.setName('minigame')
|
||||||
|
.setDescription('Manage mini-games for the server.')
|
||||||
|
.setDescriptionLocalizations({
|
||||||
|
ko: '서버의 미니게임을 관리합니다.',
|
||||||
|
})
|
||||||
|
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator)
|
||||||
|
// --- Toggle Subcommand ---
|
||||||
|
.addSubcommand(subcommand =>
|
||||||
|
subcommand
|
||||||
|
.setName('toggle')
|
||||||
|
.setDescription('Enable or disable a specific mini-game.')
|
||||||
|
.addStringOption(option =>
|
||||||
|
option.setName('game')
|
||||||
|
.setDescription('Mini-game to toggle')
|
||||||
|
.setRequired(true)
|
||||||
|
.addChoices(...Object.values(MINI_GAMES).map(g => ({ name: g.name, value: g.key })))
|
||||||
|
)
|
||||||
|
.addBooleanOption(option =>
|
||||||
|
option.setName('enable')
|
||||||
|
.setDescription('Whether to enable the mini-game')
|
||||||
|
.setRequired(true)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
// --- Status Subcommand ---
|
||||||
|
.addSubcommand(subcommand =>
|
||||||
|
subcommand
|
||||||
|
.setName('status')
|
||||||
|
.setDescription('View the current status of all mini-games.')
|
||||||
|
)
|
||||||
|
// --- Channel Subcommand ---
|
||||||
|
.addSubcommand(subcommand =>
|
||||||
|
subcommand
|
||||||
|
.setName('channel')
|
||||||
|
.setDescription('Set a dedicated channel for a mini-game.')
|
||||||
|
.addStringOption(option =>
|
||||||
|
option.setName('game')
|
||||||
|
.setDescription('Mini-game to set channel for')
|
||||||
|
.setRequired(true)
|
||||||
|
.addChoices(...Object.values(MINI_GAMES).map(g => ({ name: g.name, value: g.key })))
|
||||||
|
)
|
||||||
|
.addChannelOption(option =>
|
||||||
|
option.setName('channel')
|
||||||
|
.setDescription('The channel to use (empty to allow all)')
|
||||||
|
.addChannelTypes(ChannelType.GuildText)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
|
||||||
|
async execute(interaction: ChatInputCommandInteraction, locale: SupportedLocale) {
|
||||||
|
if (!interaction.guildId) return;
|
||||||
|
const subcommand = interaction.options.getSubcommand();
|
||||||
|
|
||||||
|
if (subcommand === 'toggle') {
|
||||||
|
const gameKey = interaction.options.getString('game', true);
|
||||||
|
const enable = interaction.options.getBoolean('enable', true);
|
||||||
|
|
||||||
|
await prisma.miniGameConfig.upsert({
|
||||||
|
where: { guildId_gameKey: { guildId: interaction.guildId, gameKey } },
|
||||||
|
update: { enabled: enable },
|
||||||
|
create: { guildId: interaction.guildId, gameKey, enabled: enable },
|
||||||
|
});
|
||||||
|
|
||||||
|
const game = MINI_GAMES[gameKey];
|
||||||
|
const state = enable ? '활성화' : '비활성화';
|
||||||
|
|
||||||
|
const embed = new EmbedBuilder()
|
||||||
|
.setColor(enable ? Colors.Green : Colors.Grey)
|
||||||
|
.setTitle('🎮 미니게임 설정 변경')
|
||||||
|
.setDescription(`${game.name} 미니게임이 **${state}**되었습니다.`);
|
||||||
|
|
||||||
|
return interaction.reply({ embeds: [embed], ephemeral: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subcommand === 'status') {
|
||||||
|
const configs = await prisma.miniGameConfig.findMany({
|
||||||
|
where: { guildId: interaction.guildId },
|
||||||
|
});
|
||||||
|
|
||||||
|
const embed = new EmbedBuilder()
|
||||||
|
.setColor(Colors.Blue)
|
||||||
|
.setTitle('🎮 미니게임 현황')
|
||||||
|
.setDescription('현재 서버의 미니게임 활성화 상태입니다.');
|
||||||
|
|
||||||
|
getAllMiniGames().forEach(game => {
|
||||||
|
const config = configs.find(c => c.gameKey === game.key);
|
||||||
|
const isEnabled = config?.enabled ?? false;
|
||||||
|
const channel = config?.channelId ? `<#${config.channelId}>` : '모든 채널';
|
||||||
|
|
||||||
|
embed.addFields({
|
||||||
|
name: game.name,
|
||||||
|
value: `상태: ${isEnabled ? '✅ 활성' : '❌ 비활성'}\n채널: ${channel}`,
|
||||||
|
inline: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return interaction.reply({ embeds: [embed], ephemeral: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subcommand === 'channel') {
|
||||||
|
const gameKey = interaction.options.getString('game', true);
|
||||||
|
const channel = interaction.options.getChannel('channel');
|
||||||
|
|
||||||
|
await prisma.miniGameConfig.upsert({
|
||||||
|
where: { guildId_gameKey: { guildId: interaction.guildId, gameKey } },
|
||||||
|
update: { channelId: channel?.id || null },
|
||||||
|
create: { guildId: interaction.guildId, gameKey, channelId: channel?.id || null },
|
||||||
|
});
|
||||||
|
|
||||||
|
const game = MINI_GAMES[gameKey];
|
||||||
|
const channelMsg = channel ? `<#${channel.id}>` : '모든 채널';
|
||||||
|
|
||||||
|
const embed = new EmbedBuilder()
|
||||||
|
.setColor(Colors.Gold)
|
||||||
|
.setTitle('🎮 미니게임 채널 설정')
|
||||||
|
.setDescription(`${game.name} 미니게임 전용 채널이 **${channelMsg}**로 설정되었습니다.`);
|
||||||
|
|
||||||
|
return interaction.reply({ embeds: [embed], ephemeral: true });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,277 @@
|
||||||
|
import {
|
||||||
|
SlashCommandBuilder,
|
||||||
|
ChatInputCommandInteraction,
|
||||||
|
EmbedBuilder,
|
||||||
|
Colors,
|
||||||
|
ButtonBuilder,
|
||||||
|
ButtonStyle,
|
||||||
|
ActionRowBuilder,
|
||||||
|
ComponentType
|
||||||
|
} from 'discord.js';
|
||||||
|
import { prisma } from '../database';
|
||||||
|
import { SupportedLocale } from '../i18n';
|
||||||
|
import { RefinementService } from '../services/RefinementService';
|
||||||
|
import { FeverService } from '../services/FeverService';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
data: new SlashCommandBuilder()
|
||||||
|
.setName('refine')
|
||||||
|
.setDescription('Mini-game: Refinement')
|
||||||
|
.setDescriptionLocalizations({
|
||||||
|
ko: '미니게임: 재련 (무기 강화 및 전투)',
|
||||||
|
})
|
||||||
|
// --- Try Subcommand ---
|
||||||
|
.addSubcommand(subcommand =>
|
||||||
|
subcommand
|
||||||
|
.setName('try')
|
||||||
|
.setDescription('Try to refine your weapon.')
|
||||||
|
.setDescriptionLocalizations({ ko: '무기 재련을 시도합니다.' })
|
||||||
|
)
|
||||||
|
// --- Battle Subcommand ---
|
||||||
|
.addSubcommand(subcommand =>
|
||||||
|
subcommand
|
||||||
|
.setName('battle')
|
||||||
|
.setDescription('Battle with another user.')
|
||||||
|
.setDescriptionLocalizations({ ko: '다른 유저와 전투를 수행합니다.' })
|
||||||
|
.addUserOption(option =>
|
||||||
|
option.setName('target')
|
||||||
|
.setDescription('The user to attack')
|
||||||
|
.setRequired(true)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
// --- Checkin Subcommand ---
|
||||||
|
.addSubcommand(subcommand =>
|
||||||
|
subcommand
|
||||||
|
.setName('checkin')
|
||||||
|
.setDescription('Daily check-in to get gold.')
|
||||||
|
.setDescriptionLocalizations({ ko: '일일 출석 및 골드를 수령합니다.' })
|
||||||
|
)
|
||||||
|
// --- Profile Subcommand ---
|
||||||
|
.addSubcommand(subcommand =>
|
||||||
|
subcommand
|
||||||
|
.setName('profile')
|
||||||
|
.setDescription('View refinement profile.')
|
||||||
|
.setDescriptionLocalizations({ ko: '재련 프로필을 확인합니다.' })
|
||||||
|
.addUserOption(option =>
|
||||||
|
option.setName('user')
|
||||||
|
.setDescription('User to view')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
// --- Ranking Subcommand ---
|
||||||
|
.addSubcommand(subcommand =>
|
||||||
|
subcommand
|
||||||
|
.setName('ranking')
|
||||||
|
.setDescription('View server rankings.')
|
||||||
|
.setDescriptionLocalizations({ ko: '서버 랭킹을 확인합니다.' })
|
||||||
|
.addStringOption(option =>
|
||||||
|
option.setName('type')
|
||||||
|
.setDescription('Ranking type')
|
||||||
|
.setRequired(true)
|
||||||
|
.addChoices(
|
||||||
|
{ name: '최대 강화 단계', value: 'max_level' },
|
||||||
|
{ name: '승률', value: 'win_rate' },
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
// --- Sell Subcommand ---
|
||||||
|
.addSubcommand(subcommand =>
|
||||||
|
subcommand
|
||||||
|
.setName('sell')
|
||||||
|
.setDescription('Sell your weapon and reset to level 0.')
|
||||||
|
.setDescriptionLocalizations({ ko: '무기를 판매하고 0단계로 회귀합니다.' })
|
||||||
|
)
|
||||||
|
// --- Help Subcommand ---
|
||||||
|
.addSubcommand(subcommand =>
|
||||||
|
subcommand
|
||||||
|
.setName('help')
|
||||||
|
.setDescription('Get help about the refinement game.')
|
||||||
|
.setDescriptionLocalizations({ ko: '재련 게임 도움말을 확인합니다.' })
|
||||||
|
),
|
||||||
|
|
||||||
|
async execute(interaction: ChatInputCommandInteraction, locale: SupportedLocale) {
|
||||||
|
if (!interaction.guildId) return;
|
||||||
|
|
||||||
|
// 1. 미니게임 활성화 및 전용 채널 체크
|
||||||
|
const config = await prisma.miniGameConfig.findUnique({
|
||||||
|
where: { guildId_gameKey: { guildId: interaction.guildId, gameKey: 'refinement' } }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!config || !config.enabled) {
|
||||||
|
return interaction.reply({ content: '❌ 이 서버에서는 재련 미니게임이 비활성화되어 있습니다.', ephemeral: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.channelId && config.channelId !== interaction.channelId) {
|
||||||
|
return interaction.reply({ content: `❌ 재련 미니게임은 <#${config.channelId}> 채널에서만 이용 가능합니다.`, ephemeral: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const subcommand = interaction.options.getSubcommand();
|
||||||
|
|
||||||
|
// --- TRY ---
|
||||||
|
if (subcommand === 'try') {
|
||||||
|
try {
|
||||||
|
const result = await RefinementService.tryRefine(interaction.user.id, interaction.guildId);
|
||||||
|
const fever = await FeverService.getFeverBonus(interaction.guildId);
|
||||||
|
|
||||||
|
const embed = new EmbedBuilder()
|
||||||
|
.setTitle(result.success ? '✨ 재련 성공!' : (result.destroyed ? '🧨 무기 파괴!' : '❌ 재련 실패...'))
|
||||||
|
.setColor(result.success ? Colors.Green : (result.destroyed ? Colors.Red : Colors.Grey))
|
||||||
|
.addFields(
|
||||||
|
{ name: '강화 단계', value: `${result.levelBefore} → **${result.levelAfter}**`, inline: true },
|
||||||
|
{ name: '비용', value: `${result.cost} G`, inline: true },
|
||||||
|
{ name: '잔액', value: `${result.remainingGold} G`, inline: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (fever.active) {
|
||||||
|
embed.setFooter({ text: `🔥 피버 타임 활성화 중! (성공률 +${fever.bonusRate * 100}%)` });
|
||||||
|
}
|
||||||
|
|
||||||
|
const retryBtn = new ButtonBuilder()
|
||||||
|
.setCustomId(`refine_try_again:${interaction.user.id}`)
|
||||||
|
.setLabel('다시 시도')
|
||||||
|
.setStyle(ButtonStyle.Primary);
|
||||||
|
|
||||||
|
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(retryBtn);
|
||||||
|
|
||||||
|
return interaction.reply({ embeds: [embed], components: [row] });
|
||||||
|
} catch (err: any) {
|
||||||
|
return interaction.reply({ content: `❌ 오류: ${err.message}`, ephemeral: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- BATTLE ---
|
||||||
|
if (subcommand === 'battle') {
|
||||||
|
const targetUser = interaction.options.getUser('target', true);
|
||||||
|
if (targetUser.id === interaction.user.id) {
|
||||||
|
return interaction.reply({ content: '❌ 자신을 공격할 수 없습니다.', ephemeral: true });
|
||||||
|
}
|
||||||
|
if (targetUser.bot) {
|
||||||
|
return interaction.reply({ content: '❌ 봇과 전투할 수 없습니다.', ephemeral: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await RefinementService.startBattle(interaction.user.id, targetUser.id, interaction.guildId);
|
||||||
|
|
||||||
|
const embed = new EmbedBuilder()
|
||||||
|
.setTitle('⚔️ 전투 결과')
|
||||||
|
.setColor(result.winnerId === interaction.user.id ? Colors.Gold : Colors.Red)
|
||||||
|
.setDescription(`<@${result.attackerId}> 님과 <@${result.targetId}> 님의 대결!`)
|
||||||
|
.addFields(
|
||||||
|
{ name: '승자', value: `<@${result.winnerId}>`, inline: false },
|
||||||
|
{ name: '패자', value: `<@${result.loserId}>`, inline: false },
|
||||||
|
{ name: '보상', value: `${result.reward} G`, inline: true },
|
||||||
|
{ name: '내 단계', value: `${result.attackerLevel}강`, inline: true },
|
||||||
|
{ name: '상대 단계', value: `${result.targetLevel}강`, inline: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.destroyed) {
|
||||||
|
embed.addFields({ name: '🧨 무기 파괴', value: '공격자가 내구도 0인 상태에서 공격하여 무기가 완전히 파괴되었습니다!', inline: false });
|
||||||
|
} else {
|
||||||
|
embed.addFields({ name: '🛡️ 내구도', value: `나: ${result.attackerDurability} | 상대: ${result.targetDurability}`, inline: false });
|
||||||
|
if (result.attackerDurability <= 0) embed.setFooter({ text: '⚠️ [경고] 무기가 전투 불능이 되었습니다. 내구도 0에서 다시 공격하면 파괴됩니다!' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return interaction.reply({ embeds: [embed] });
|
||||||
|
} catch (err: any) {
|
||||||
|
return interaction.reply({ content: `❌ 오류: ${err.message}`, ephemeral: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- CHECKIN ---
|
||||||
|
if (subcommand === 'checkin') {
|
||||||
|
try {
|
||||||
|
const res = await RefinementService.checkIn(interaction.user.id, interaction.guildId);
|
||||||
|
return interaction.reply({ content: `✅ 출석 완료! **${res.goldAdded} G**를 수령했습니다. (총: ${res.totalGold} G)`, ephemeral: true });
|
||||||
|
} catch (err: any) {
|
||||||
|
return interaction.reply({ content: `❌ 오류: ${err.message}`, ephemeral: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- PROFILE ---
|
||||||
|
if (subcommand === 'profile') {
|
||||||
|
const targetUser = interaction.options.getUser('user') || interaction.user;
|
||||||
|
const profile = await RefinementService.getProfile(targetUser.id, interaction.guildId);
|
||||||
|
|
||||||
|
const maxDurability = RefinementService.getMaxDurability(profile.weaponLevel);
|
||||||
|
const embed = new EmbedBuilder()
|
||||||
|
.setTitle(`👤 ${targetUser.username} 님의 재련 프로필`)
|
||||||
|
.setColor(Colors.Blue)
|
||||||
|
.setThumbnail(targetUser.displayAvatarURL())
|
||||||
|
.addFields(
|
||||||
|
{ name: '현재 무기', value: `${profile.weaponLevel} 단계`, inline: true },
|
||||||
|
{ name: '최대 무기', value: `${profile.maxWeaponLevel} 단계`, inline: true },
|
||||||
|
{ name: '소지 골드', value: `${profile.gold} G`, inline: true },
|
||||||
|
{ name: '내구도', value: `${profile.durability} / ${maxDurability}`, inline: true },
|
||||||
|
{ name: '전투 상태', value: profile.isDisabled ? '❌ 전투 불능' : '✅ 양호', inline: true },
|
||||||
|
{ name: '일일 공격', value: `${profile.dailyBattleCount} / 10`, inline: true },
|
||||||
|
{ name: '출석', value: profile.lastCheckIn ? profile.lastCheckIn.toLocaleDateString() : '미기록', inline: true },
|
||||||
|
{ name: '통계', value: `시도: ${profile.tryCount} | 성공: ${profile.successCount} | 실패: ${profile.failCount} | 파괴: ${profile.destroyCount}`, inline: false },
|
||||||
|
{ name: '전투', value: `승리: ${profile.battleWin} | 패배: ${profile.battleLoss}`, inline: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
return interaction.reply({ embeds: [embed] });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- RANKING ---
|
||||||
|
if (subcommand === 'ranking') {
|
||||||
|
const type = interaction.options.getString('type', true);
|
||||||
|
const ranking = await prisma.refinementProfile.findMany({
|
||||||
|
where: { guildId: interaction.guildId },
|
||||||
|
orderBy: type === 'max_level' ? { maxWeaponLevel: 'desc' } : undefined,
|
||||||
|
take: 10
|
||||||
|
});
|
||||||
|
|
||||||
|
let rankingList = ranking;
|
||||||
|
if (type === 'win_rate') {
|
||||||
|
rankingList = ranking
|
||||||
|
.sort((a, b) => {
|
||||||
|
const rateA = a.battleWin / (a.battleWin + a.battleLoss || 1);
|
||||||
|
const rateB = b.battleWin / (b.battleWin + b.battleLoss || 1);
|
||||||
|
return rateB - rateA;
|
||||||
|
})
|
||||||
|
.slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
const embed = new EmbedBuilder()
|
||||||
|
.setTitle(type === 'max_level' ? '🏆 서버 최고 강화 랭킹' : '🏆 서버 최고 승률 랭킹')
|
||||||
|
.setColor(Colors.Gold);
|
||||||
|
|
||||||
|
const listStr = rankingList.map((p, i) => {
|
||||||
|
const val = type === 'max_level'
|
||||||
|
? `${p.maxWeaponLevel}단계`
|
||||||
|
: `${Math.round((p.battleWin / (p.battleWin + p.battleLoss || 1)) * 100)}% (${p.battleWin}승)`;
|
||||||
|
return `${i + 1}. <@${p.userId}> - **${val}**`;
|
||||||
|
}).join('\n') || '데이터가 없습니다.';
|
||||||
|
|
||||||
|
embed.setDescription(listStr);
|
||||||
|
return interaction.reply({ embeds: [embed] });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- SELL ---
|
||||||
|
if (subcommand === 'sell') {
|
||||||
|
try {
|
||||||
|
const res = await RefinementService.sellWeapon(interaction.user.id, interaction.guildId);
|
||||||
|
return interaction.reply({ content: `💰 **${res.level}단계** 무기를 판매하고 **${res.price} G**를 받았습니다. (총: ${res.gold} G)`, ephemeral: true });
|
||||||
|
} catch (err: any) {
|
||||||
|
return interaction.reply({ content: `❌ 오류: ${err.message}`, ephemeral: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- HELP ---
|
||||||
|
if (subcommand === 'help') {
|
||||||
|
const embed = new EmbedBuilder()
|
||||||
|
.setTitle('🎮 재련 미니게임 도움말')
|
||||||
|
.setColor(Colors.White)
|
||||||
|
.setDescription('무기를 강화하고 다른 유저와 전투하며 강력한 전사가 되어보세요!')
|
||||||
|
.addFields(
|
||||||
|
{ name: '🛠️ 재련 (/refine try)', value: '골드를 소모하여 강화합니다. 5강부터 확률이 극악이며, 성공 시 **내구도가 최대치(10+레벨)**로 수리됩니다.' },
|
||||||
|
{ name: '⚔️ 전투 (/refine battle)', value: '다른 유저를 공격합니다. **일일 10회 제한**이 있으며, 5강 이상 차이나는 강자 공격 시 승률이 급락(최대 0%)합니다.' },
|
||||||
|
{ name: '🛡️ 내구도 및 수명', value: '단계가 높을수록 최대 내구도가 증가하여 더 오래 사용할 수 있습니다. 내구도 0에서 공격 시 무기가 파괴됩니다.' },
|
||||||
|
{ name: '📅 출석 (/refine checkin)', value: '매일 한 번 골드를 수령할 수 있습니다.' },
|
||||||
|
{ name: '💰 판매 (/refine sell)', value: '무기를 팔고 0단계로 돌아갑니다. 고강화 무기는 난이도(기대값)에 비례해 **수억 골드**의 가치를 가집니다.' },
|
||||||
|
{ name: '🔥 피버 타임', value: '서버가 가장 활발한 시간대에 재련 성공 확률이 10% 증가합니다!' }
|
||||||
|
);
|
||||||
|
|
||||||
|
return interaction.reply({ embeds: [embed] });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -1,14 +1,24 @@
|
||||||
|
import { Pool } from 'pg';
|
||||||
|
import { PrismaPg } from '@prisma/adapter-pg';
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
import { logger } from '../utils/logger';
|
import { logger } from '../utils/logger';
|
||||||
|
|
||||||
|
// Prisma 7 requires a driver adapter for direct database connections.
|
||||||
|
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
||||||
|
const adapter = new PrismaPg(pool);
|
||||||
|
|
||||||
export const prisma = new PrismaClient({
|
export const prisma = new PrismaClient({
|
||||||
|
adapter,
|
||||||
log: ['warn', 'error'],
|
log: ['warn', 'error'],
|
||||||
});
|
});
|
||||||
|
|
||||||
export const connectDB = async () => {
|
export const connectDB = async () => {
|
||||||
try {
|
try {
|
||||||
await prisma.$connect();
|
// Adapter-based client connects when first used,
|
||||||
logger.info('Connected to PostgreSQL successfully.');
|
// but we can test the pool connection here.
|
||||||
|
const client = await pool.connect();
|
||||||
|
client.release();
|
||||||
|
logger.info('Connected to PostgreSQL successfully via Driver Adapter.');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to connect to PostgreSQL:', error);
|
logger.error('Failed to connect to PostgreSQL:', error);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
|
|
|
||||||
|
|
@ -29,12 +29,26 @@ export default {
|
||||||
await MusicService.handleControlInteraction(interaction, locale);
|
await MusicService.handleControlInteraction(interaction, locale);
|
||||||
}, 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_')) {
|
else if (interaction.isMessageComponent() && interaction.customId.startsWith('setup_')) {
|
||||||
const locale = await getInteractionLocale(interaction);
|
const locale = await getInteractionLocale(interaction);
|
||||||
await withErrorHandler(interaction, async () => {
|
await withErrorHandler(interaction, async () => {
|
||||||
await handleSetupWizardInteraction(interaction, locale);
|
await handleSetupWizardInteraction(interaction, locale);
|
||||||
}, locale);
|
}, locale);
|
||||||
}
|
}
|
||||||
|
else if (interaction.isButton() && interaction.customId.startsWith('refine_')) {
|
||||||
|
const { handleRefinementInteraction } = require('../interactions/handlers/refinementHandler');
|
||||||
|
const locale = await getInteractionLocale(interaction);
|
||||||
|
await withErrorHandler(interaction, async () => {
|
||||||
|
await handleRefinementInteraction(interaction, locale);
|
||||||
|
}, locale);
|
||||||
|
}
|
||||||
else if (interaction.isStringSelectMenu()) {
|
else if (interaction.isStringSelectMenu()) {
|
||||||
const customId = interaction.customId;
|
const customId = interaction.customId;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { Events, Message } from 'discord.js';
|
||||||
import { MimicService } from '../services/MimicService';
|
import { MimicService } from '../services/MimicService';
|
||||||
import { BigEmojiService } from '../services/BigEmojiService';
|
import { BigEmojiService } from '../services/BigEmojiService';
|
||||||
import { prisma } from '../database';
|
import { prisma } from '../database';
|
||||||
|
import { ActivityTrackerService } from '../services/ActivityTrackerService';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: Events.MessageCreate,
|
name: Events.MessageCreate,
|
||||||
|
|
@ -9,6 +10,9 @@ export default {
|
||||||
async execute(message: Message) {
|
async execute(message: Message) {
|
||||||
if (!message.guildId || message.author.bot) return;
|
if (!message.guildId || message.author.bot) return;
|
||||||
|
|
||||||
|
// 활동 추적 기록
|
||||||
|
await ActivityTrackerService.recordActivity(message.guildId);
|
||||||
|
|
||||||
const config = await prisma.guildConfig.findUnique({
|
const config = await prisma.guildConfig.findUnique({
|
||||||
where: { guildId: message.guildId }
|
where: { guildId: message.guildId }
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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.
|
* All keys MUST be present here. Other locales can omit keys to fallback to English.
|
||||||
*/
|
*/
|
||||||
export const en: TranslationSchema = {
|
export const en: TranslationSchema = {
|
||||||
// ── Error Messages ──────────────────────────────────────
|
// ?? Error Messages ??????????????????????????????????????
|
||||||
errors: {
|
errors: {
|
||||||
E1001: {
|
E1001: {
|
||||||
userMessage: 'The user limit value is invalid.',
|
userMessage: 'The user limit value is invalid.',
|
||||||
|
|
@ -71,7 +71,7 @@ export const en: TranslationSchema = {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Error Category Titles ───────────────────────────────
|
// ?? Error Category Titles ???????????????????????????????
|
||||||
errorTitles: {
|
errorTitles: {
|
||||||
USER_INPUT: 'Please check your input',
|
USER_INPUT: 'Please check your input',
|
||||||
PERMISSION: 'Insufficient permissions',
|
PERMISSION: 'Insufficient permissions',
|
||||||
|
|
@ -79,17 +79,17 @@ export const en: TranslationSchema = {
|
||||||
DISCORD_API: 'Temporary issue',
|
DISCORD_API: 'Temporary issue',
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Error Embed Field Labels ────────────────────────────
|
// ?? Error Embed Field Labels ????????????????????????????
|
||||||
errorFields: {
|
errorFields: {
|
||||||
resolution: '💡 How to resolve',
|
resolution: '💡 How to resolve',
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Voice Channel ───────────────────────────────────────
|
// ?? Voice Channel ???????????????????????????????????????
|
||||||
voice: {
|
voice: {
|
||||||
channelReady: '{{owner}}, your temporary channel is ready! Use the dropdown menu below to manage it.',
|
channelReady: '{{owner}}, your temporary channel is ready! Use the dropdown menu below to manage it.',
|
||||||
defaultRoomName: "{{username}}'s Room",
|
defaultRoomName: "{{username}}'s Room",
|
||||||
controlPanel: {
|
controlPanel: {
|
||||||
placeholder: '⚙️ Manage Channel Settings',
|
placeholder: '?숋툘 Manage Channel Settings',
|
||||||
rename: 'Rename Channel',
|
rename: 'Rename Channel',
|
||||||
limit: 'Set User Limit',
|
limit: 'Set User Limit',
|
||||||
lock: 'Lock / Unlock',
|
lock: 'Lock / Unlock',
|
||||||
|
|
@ -111,7 +111,7 @@ export const en: TranslationSchema = {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Commands ────────────────────────────────────────────
|
// ?? Commands ????????????????????????????????????????????
|
||||||
commands: {
|
commands: {
|
||||||
voiceSetup: {
|
voiceSetup: {
|
||||||
description: 'Setup a generator voice channel for temporary channels.',
|
description: 'Setup a generator voice channel for temporary channels.',
|
||||||
|
|
@ -251,11 +251,47 @@ export const en: TranslationSchema = {
|
||||||
statusPaused: 'Paused',
|
statusPaused: 'Paused',
|
||||||
unknownDuration: 'Unknown',
|
unknownDuration: 'Unknown',
|
||||||
buttons: {
|
buttons: {
|
||||||
pause: '⏸ Pause',
|
pause: '??Pause',
|
||||||
resume: '▶ Resume',
|
resume: '??Resume',
|
||||||
skip: '⏭ Skip',
|
skip: '??Skip',
|
||||||
stop: '⏹ Stop',
|
stop: '??Stop',
|
||||||
leave: '👋 Leave',
|
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: {
|
permissionAudit: {
|
||||||
|
|
@ -263,8 +299,8 @@ export const en: TranslationSchema = {
|
||||||
channel: 'Channel',
|
channel: 'Channel',
|
||||||
noResults: 'No features to audit. The bot may not be configured yet.',
|
noResults: 'No features to audit. The bot may not be configured yet.',
|
||||||
summaryLabel: 'Summary',
|
summaryLabel: 'Summary',
|
||||||
summaryOk: '✅ All checks passed. No issues found.',
|
summaryOk: '??All checks passed. No issues found.',
|
||||||
summaryIssue: '❌ {{fail}} failure(s) · ⚠️ {{warn}} warning(s) detected.',
|
summaryIssue: '??{{fail}} failure(s) 쨌 ?좑툘 {{warn}} warning(s) detected.',
|
||||||
hierarchyWarning: "Bot role (pos: {{botPos}}) must be above '{{role}}' (pos: {{targetPos}}) to manage it.",
|
hierarchyWarning: "Bot role (pos: {{botPos}}) must be above '{{role}}' (pos: {{targetPos}}) to manage it.",
|
||||||
features: {
|
features: {
|
||||||
BASIC: 'Basic Bot Functionality',
|
BASIC: 'Basic Bot Functionality',
|
||||||
|
|
@ -279,53 +315,53 @@ export const en: TranslationSchema = {
|
||||||
setup: {
|
setup: {
|
||||||
description: 'Run the setup wizard to configure the bot step by step.',
|
description: 'Run the setup wizard to configure the bot step by step.',
|
||||||
step0: {
|
step0: {
|
||||||
title: '✨ Bot Setup Wizard',
|
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**',
|
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'
|
startBtn: 'Start Setup'
|
||||||
},
|
},
|
||||||
step1: {
|
step1: {
|
||||||
title: '1️⃣ Language Settings',
|
title: '1截뤴깵 Language Settings',
|
||||||
desc: 'Set the default language for the bot in this server. (Current: **{{locale}}**)',
|
desc: 'Set the default language for the bot in this server. (Current: **{{locale}}**)',
|
||||||
placeholder: 'Select a language',
|
placeholder: 'Select a language',
|
||||||
nextBtn: 'Next Step',
|
nextBtn: 'Next Step',
|
||||||
skipBtn: 'Skip'
|
skipBtn: 'Skip'
|
||||||
},
|
},
|
||||||
step2: {
|
step2: {
|
||||||
title: '2️⃣ Permission Check',
|
title: '2截뤴깵 Permission Check',
|
||||||
descOk: '✅ **All required permissions are granted.**',
|
descOk: '??**All required permissions are granted.**',
|
||||||
descFail: '⚠️ **Some permissions are missing.**\nPlease check the results and grant the necessary permissions to the bot role.',
|
descFail: '?좑툘 **Some permissions are missing.**\nPlease check the results and grant the necessary permissions to the bot role.',
|
||||||
recheckBtn: 'Re-check',
|
recheckBtn: 'Re-check',
|
||||||
nextBtn: 'Next Step'
|
nextBtn: 'Next Step'
|
||||||
},
|
},
|
||||||
step3: {
|
step3: {
|
||||||
title: '3️⃣ Audit Channel Setup',
|
title: '3截뤴깵 Audit Channel Setup',
|
||||||
desc: 'Select a channel to receive bot events and error logs.',
|
desc: 'Select a channel to receive bot events and error logs.',
|
||||||
placeholder: 'Select Audit Channel',
|
placeholder: 'Select Audit Channel',
|
||||||
disableBtn: 'Disable Audit Logs',
|
disableBtn: 'Disable Audit Logs',
|
||||||
nextBtn: 'Next Step'
|
nextBtn: 'Next Step'
|
||||||
},
|
},
|
||||||
step4: {
|
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.',
|
desc: 'Select which log categories to receive. **Green** buttons are enabled, **Red** buttons are disabled.',
|
||||||
nextBtn: 'Next Step',
|
nextBtn: 'Next Step',
|
||||||
},
|
},
|
||||||
step5: {
|
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.',
|
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',
|
placeholder: 'Select Generator Channel',
|
||||||
autoBtn: '🚀 Auto Create',
|
autoBtn: '?? Auto Create',
|
||||||
skipBtn: 'Disable Temp Voice',
|
skipBtn: 'Disable Temp Voice',
|
||||||
nextBtn: 'Finish Setup'
|
nextBtn: 'Finish Setup'
|
||||||
},
|
},
|
||||||
step6: {
|
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}}',
|
desc: '**1. Language**: {{lang}}\n**2. Audit Channel**: {{audit}}\n**3. Audit Categories**: {{categories}}\n**4. Temp Voice**: {{voice}}',
|
||||||
finishBtn: 'Done'
|
finishBtn: 'Done'
|
||||||
},
|
},
|
||||||
finished: '✅ The setup wizard has been finished.',
|
finished: '??The setup wizard has been finished.',
|
||||||
expired: '⏳ The session has expired. Please run `/setup` again.',
|
expired: '??The session has expired. Please run `/setup` again.',
|
||||||
defaultCategoryName: 'Voice Channels',
|
defaultCategoryName: 'Voice Channels',
|
||||||
defaultGeneratorName: '➕ Create Channel',
|
defaultGeneratorName: '??Create Channel',
|
||||||
auditCategories: {
|
auditCategories: {
|
||||||
SYSTEM: 'System',
|
SYSTEM: 'System',
|
||||||
BOOT: 'Boot',
|
BOOT: 'Boot',
|
||||||
|
|
@ -350,7 +386,7 @@ export const en: TranslationSchema = {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Modals ──────────────────────────────────────────────
|
// ?? Modals ??????????????????????????????????????????????
|
||||||
modals: {
|
modals: {
|
||||||
renameTitle: 'Rename Voice Channel',
|
renameTitle: 'Rename Voice Channel',
|
||||||
renameLabel: 'New Channel Name',
|
renameLabel: 'New Channel Name',
|
||||||
|
|
@ -358,14 +394,14 @@ export const en: TranslationSchema = {
|
||||||
limitLabel: 'User Limit (0 for unlimited, 1-99)',
|
limitLabel: 'User Limit (0 for unlimited, 1-99)',
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Select Menu Placeholders ────────────────────────────
|
// ?? Select Menu Placeholders ????????????????????????????
|
||||||
selects: {
|
selects: {
|
||||||
kickUser: 'Select a user to kick',
|
kickUser: 'Select a user to kick',
|
||||||
banUser: 'Select a user to ban/hide',
|
banUser: 'Select a user to ban/hide',
|
||||||
transferOwner: 'Select a user to transfer ownership to',
|
transferOwner: 'Select a user to transfer ownership to',
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Presence (Bot Status) ──
|
// ?? Presence (Bot Status) ??
|
||||||
presence: {
|
presence: {
|
||||||
servers: 'Monitoring {{guildCount}} servers',
|
servers: 'Monitoring {{guildCount}} servers',
|
||||||
help: 'Check out the /help command',
|
help: 'Check out the /help command',
|
||||||
|
|
@ -373,3 +409,6 @@ export const en: TranslationSchema = {
|
||||||
version: 'Kord v1.0.0',
|
version: 'Kord v1.0.0',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,194 +1,194 @@
|
||||||
import { TranslationSchema } from '../types';
|
import { TranslationSchema } from '../types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 한국어 번역 파일.
|
* ?쒓뎅??踰덉뿭 ?뚯씪.
|
||||||
* 모든 키가 en.ts와 1:1 대응되어야 합니다.
|
* 紐⑤뱺 ?ㅺ? en.ts? 1:1 ??묐릺?댁빞 ?⑸땲??
|
||||||
*/
|
*/
|
||||||
export const ko: TranslationSchema = {
|
export const ko: TranslationSchema = {
|
||||||
// ── 에러 메시지 ─────────────────────────────────────────
|
// ?? ?먮윭 硫붿떆吏 ?????????????????????????????????????????
|
||||||
errors: {
|
errors: {
|
||||||
E1001: {
|
E1001: {
|
||||||
userMessage: '사용자 제한 값이 올바르지 않습니다.',
|
userMessage: '사용자 제한 값이 올바르지 않습니다.',
|
||||||
resolution: '0에서 99 사이의 숫자를 입력해주세요. (0 = 무제한)',
|
resolution: '0?먯꽌 99 ?ъ씠???レ옄瑜??낅젰?댁<?몄슂. (0 = 臾댁젣??',
|
||||||
},
|
},
|
||||||
E1002: {
|
E1002: {
|
||||||
userMessage: '채널 이름 형식이 올바르지 않습니다.',
|
userMessage: '梨꾨꼸 ?대쫫 ?뺤떇???щ컮瑜댁? ?딆뒿?덈떎.',
|
||||||
resolution: '유효한 채널 이름을 입력해주세요. (최대 100자)',
|
resolution: '?좏슚??梨꾨꼸 ?대쫫???낅젰?댁<?몄슂. (理쒕? 100??',
|
||||||
},
|
},
|
||||||
E1003: {
|
E1003: {
|
||||||
userMessage: '자기 자신에게는 이 작업을 수행할 수 없습니다.',
|
userMessage: '?먭린 ?먯떊?먭쾶?????묒뾽???섑뻾?????놁뒿?덈떎.',
|
||||||
},
|
},
|
||||||
E1004: {
|
E1004: {
|
||||||
userMessage: '선택한 유저가 음성 채널에 없습니다.',
|
userMessage: '?좏깮???좎?媛 ?뚯꽦 梨꾨꼸???놁뒿?덈떎.',
|
||||||
resolution: '작업을 수행하기 전에 해당 유저가 채널에 있는지 확인해주세요.',
|
resolution: '?묒뾽???섑뻾?섍린 ?꾩뿉 ?대떦 ?좎?媛 梨꾨꼸???덈뒗吏 ?뺤씤?댁<?몄슂.',
|
||||||
},
|
},
|
||||||
E2001: {
|
E2001: {
|
||||||
userMessage: '봇에 채널을 관리할 권한이 부족합니다.',
|
userMessage: '遊뉗뿉 梨꾨꼸??愿由ы븷 沅뚰븳??遺議깊빀?덈떎.',
|
||||||
resolution: '서버 관리자에게 봇의 "채널 관리" 권한을 확인해달라고 요청하세요.',
|
resolution: '?쒕쾭 愿由ъ옄?먭쾶 遊뉗쓽 "梨꾨꼸 愿由? 沅뚰븳???뺤씤?대떖?쇨퀬 ?붿껌?섏꽭??',
|
||||||
},
|
},
|
||||||
E2002: {
|
E2002: {
|
||||||
userMessage: '봇에 음성 채널 관련 권한이 부족합니다.',
|
userMessage: '遊뉗뿉 ?뚯꽦 梨꾨꼸 愿??沅뚰븳??遺議깊빀?덈떎.',
|
||||||
resolution: '서버 관리자에게 봇의 "채널 관리", "역할 관리", "멤버 이동" 권한을 확인해달라고 요청하세요.',
|
resolution: '?쒕쾭 愿由ъ옄?먭쾶 遊뉗쓽 "梨꾨꼸 愿由?, "??븷 愿由?, "硫ㅻ쾭 ?대룞" 沅뚰븳???뺤씤?대떖?쇨퀬 ?붿껌?섏꽭??',
|
||||||
},
|
},
|
||||||
E2003: {
|
E2003: {
|
||||||
userMessage: '이 명령어를 사용할 권한이 없습니다.',
|
userMessage: '??紐낅졊?대? ?ъ슜??沅뚰븳???놁뒿?덈떎.',
|
||||||
resolution: '이 명령어는 관리자 권한이 필요합니다.',
|
resolution: '??紐낅졊?대뒗 愿由ъ옄 沅뚰븳???꾩슂?⑸땲??',
|
||||||
},
|
},
|
||||||
E2004: {
|
E2004: {
|
||||||
userMessage: '채널 소유자만 이 기능을 사용할 수 있습니다.',
|
userMessage: '梨꾨꼸 ?뚯쑀?먮쭔 ??湲곕뒫???ъ슜?????덉뒿?덈떎.',
|
||||||
},
|
},
|
||||||
E2005: {
|
E2005: {
|
||||||
userMessage: '활성된 임시 음성 채널에 참여 중이어야 사용할 수 있습니다.',
|
userMessage: '?쒖꽦???꾩떆 ?뚯꽦 梨꾨꼸??李몄뿬 以묒씠?댁빞 ?ъ슜?????덉뒿?덈떎.',
|
||||||
resolution: '임시 음성 채널에 참여한 뒤 다시 시도해주세요.',
|
resolution: '?꾩떆 ?뚯꽦 梨꾨꼸??李몄뿬?????ㅼ떆 ?쒕룄?댁<?몄슂.',
|
||||||
},
|
},
|
||||||
E3001: {
|
E3001: {
|
||||||
userMessage: '요청을 처리하는 중 내부 오류가 발생했습니다.',
|
userMessage: '?붿껌??泥섎━?섎뒗 以??대? ?ㅻ쪟媛 諛쒖깮?덉뒿?덈떎.',
|
||||||
resolution: '잠시 후 다시 시도해주세요. 문제가 지속되면 봇 관리자에게 문의하세요.',
|
resolution: '?좎떆 ???ㅼ떆 ?쒕룄?댁<?몄슂. 臾몄젣媛 吏?띾릺硫?遊?愿由ъ옄?먭쾶 臾몄쓽?섏꽭??',
|
||||||
},
|
},
|
||||||
E3002: {
|
E3002: {
|
||||||
userMessage: '요청을 처리하는 중 내부 오류가 발생했습니다.',
|
userMessage: '?붿껌??泥섎━?섎뒗 以??대? ?ㅻ쪟媛 諛쒖깮?덉뒿?덈떎.',
|
||||||
resolution: '잠시 후 다시 시도해주세요.',
|
resolution: '?좎떆 ???ㅼ떆 ?쒕룄?댁<?몄슂.',
|
||||||
},
|
},
|
||||||
E3003: {
|
E3003: {
|
||||||
userMessage: '명령어를 실행하는 중 오류가 발생했습니다.',
|
userMessage: '紐낅졊?대? ?ㅽ뻾?섎뒗 以??ㅻ쪟媛 諛쒖깮?덉뒿?덈떎.',
|
||||||
resolution: '다시 시도해주세요. 문제가 지속되면 봇 관리자에게 문의하세요.',
|
resolution: '?ㅼ떆 ?쒕룄?댁<?몄슂. 臾몄젣媛 吏?띾릺硫?遊?愿由ъ옄?먭쾶 臾몄쓽?섏꽭??',
|
||||||
},
|
},
|
||||||
E3999: {
|
E3999: {
|
||||||
userMessage: '예상치 못한 오류가 발생했습니다.',
|
userMessage: '?덉긽移?紐삵븳 ?ㅻ쪟媛 諛쒖깮?덉뒿?덈떎.',
|
||||||
resolution: '나중에 다시 시도해주세요. 문제가 계속되면 봇 관리자에게 문의하세요.',
|
resolution: '?섏쨷???ㅼ떆 ?쒕룄?댁<?몄슂. 臾몄젣媛 怨꾩냽?섎㈃ 遊?愿由ъ옄?먭쾶 臾몄쓽?섏꽭??',
|
||||||
},
|
},
|
||||||
E4001: {
|
E4001: {
|
||||||
userMessage: 'Discord에 의해 요청이 제한되었습니다.',
|
userMessage: 'Discord???섑빐 ?붿껌???쒗븳?섏뿀?듬땲??',
|
||||||
resolution: '잠시 기다린 후 다시 시도해주세요.',
|
resolution: '?좎떆 湲곕떎由????ㅼ떆 ?쒕룄?댁<?몄슂.',
|
||||||
},
|
},
|
||||||
E4002: {
|
E4002: {
|
||||||
userMessage: '권한 부족으로 Discord가 작업을 거부했습니다.',
|
userMessage: '沅뚰븳 遺議깆쑝濡?Discord媛 ?묒뾽??嫄곕??덉뒿?덈떎.',
|
||||||
resolution: '서버 관리자에게 봇의 역할 및 채널 권한을 확인해달라고 요청하세요.',
|
resolution: '?쒕쾭 愿由ъ옄?먭쾶 遊뉗쓽 ??븷 諛?梨꾨꼸 沅뚰븳???뺤씤?대떖?쇨퀬 ?붿껌?섏꽭??',
|
||||||
},
|
},
|
||||||
E4003: {
|
E4003: {
|
||||||
userMessage: 'Discord에 일시적인 문제가 발생했습니다.',
|
userMessage: 'Discord???쇱떆?곸씤 臾몄젣媛 諛쒖깮?덉뒿?덈떎.',
|
||||||
resolution: '잠시 후 다시 시도해주세요. 문제가 지속되면 https://discordstatus.com 에서 상태를 확인해주세요.',
|
resolution: '?좎떆 ???ㅼ떆 ?쒕룄?댁<?몄슂. 臾몄젣媛 吏?띾릺硫?https://discordstatus.com ?먯꽌 ?곹깭瑜??뺤씤?댁<?몄슂.',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── 에러 카테고리 타이틀 ────────────────────────────────
|
// ?? ?먮윭 移댄뀒怨좊━ ??댄? ????????????????????????????????
|
||||||
errorTitles: {
|
errorTitles: {
|
||||||
USER_INPUT: '입력을 확인해주세요',
|
USER_INPUT: '입력을 확인해주세요',
|
||||||
PERMISSION: '권한이 부족합니다',
|
PERMISSION: '沅뚰븳??遺議깊빀?덈떎',
|
||||||
BOT_INTERNAL: '문제가 발생했습니다',
|
BOT_INTERNAL: '臾몄젣媛 諛쒖깮?덉뒿?덈떎',
|
||||||
DISCORD_API: '일시적인 문제입니다',
|
DISCORD_API: '일시적인 문제입니다.',
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── 에러 Embed 필드 라벨 ────────────────────────────────
|
// ?? ?먮윭 Embed ?꾨뱶 ?쇰꺼 ????????????????????????????????
|
||||||
errorFields: {
|
errorFields: {
|
||||||
resolution: '💡 해결 방법',
|
resolution: '💡 해결 방법',
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── 음성 채널 ───────────────────────────────────────────
|
// ?? ?뚯꽦 梨꾨꼸 ???????????????????????????????????????????
|
||||||
voice: {
|
voice: {
|
||||||
channelReady: '{{owner}}, 임시 채널이 준비되었습니다! 아래 드롭다운 메뉴로 관리하세요.',
|
channelReady: '{{owner}}, 임시 채널이 준비되었습니다! 아래 드롭다운 메뉴로 관리하세요.',
|
||||||
defaultRoomName: '{{username}}의 방',
|
defaultRoomName: '{{username}}의 방',
|
||||||
controlPanel: {
|
controlPanel: {
|
||||||
placeholder: '⚙️ 채널 설정 관리',
|
placeholder: '채널 설정 관리',
|
||||||
rename: '채널 이름 변경',
|
rename: '채널 이름 변경',
|
||||||
limit: '인원 제한 설정',
|
limit: '?몄썝 ?쒗븳 ?ㅼ젙',
|
||||||
lock: '채널 잠금 / 해제',
|
lock: '梨꾨꼸 ?좉툑 / ?댁젣',
|
||||||
kick: '유저 추방',
|
kick: '?좎? 異붾갑',
|
||||||
ban: '유저 차단 / 숨기기',
|
ban: '유저 차단 / 숨기기',
|
||||||
transfer: '소유권 이전',
|
transfer: '?뚯쑀沅??댁쟾',
|
||||||
},
|
},
|
||||||
responses: {
|
responses: {
|
||||||
channelLocked: '채널이 잠겼습니다! 초대된 멤버만 참여할 수 있습니다.',
|
channelLocked: '채널이 잠겼습니다! 초대된 멤버만 참여할 수 있습니다.',
|
||||||
channelUnlocked: '채널이 해제되었습니다! 누구나 참여할 수 있습니다.',
|
channelUnlocked: '梨꾨꼸???댁젣?섏뿀?듬땲?? ?꾧뎄??李몄뿬?????덉뒿?덈떎.',
|
||||||
channelRenamed: '채널 이름이 **{{name}}**(으)로 변경되었습니다!',
|
channelRenamed: '梨꾨꼸 ?대쫫??**{{name}}**(??濡?蹂寃쎈릺?덉뒿?덈떎!',
|
||||||
limitSet: '인원 제한이 **{{limit}}**명으로 설정되었습니다!',
|
limitSet: '?몄썝 ?쒗븳??**{{limit}}**紐낆쑝濡??ㅼ젙?섏뿀?듬땲??',
|
||||||
limitUnlimited: '무제한',
|
limitUnlimited: '무제한',
|
||||||
kicked: '{{user}}을(를) 채널에서 추방했습니다.',
|
kicked: '{{user}}??瑜? 梨꾨꼸?먯꽌 異붾갑?덉뒿?덈떎.',
|
||||||
banned: '{{user}}에게 채널을 숨기고 차단했습니다.',
|
banned: '{{user}}?먭쾶 梨꾨꼸???④린怨?李⑤떒?덉뒿?덈떎.',
|
||||||
transferPrompt: '채널의 새 소유자를 선택하세요.',
|
transferPrompt: '梨꾨꼸?????뚯쑀?먮? ?좏깮?섏꽭??',
|
||||||
transferDone: '소유권이 {{user}}에게 이전되었습니다.',
|
transferDone: '?뚯쑀沅뚯씠 {{user}}?먭쾶 ?댁쟾?섏뿀?듬땲??',
|
||||||
banPrompt: '차단하면 해당 유저에게 채널이 보이지 않게 됩니다.',
|
banPrompt: '李⑤떒?섎㈃ ?대떦 ?좎??먭쾶 梨꾨꼸??蹂댁씠吏 ?딄쾶 ?⑸땲??',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── 명령어 ──────────────────────────────────────────────
|
// ?? 紐낅졊????????????????????????????????????????????????
|
||||||
commands: {
|
commands: {
|
||||||
voiceSetup: {
|
voiceSetup: {
|
||||||
description: '임시 음성 채널을 위한 생성기 채널을 설정합니다.',
|
description: '?꾩떆 ?뚯꽦 梨꾨꼸???꾪븳 ?앹꽦湲?梨꾨꼸???ㅼ젙?⑸땲??',
|
||||||
setDescription: '기존 음성 채널을 생성기로 설정합니다',
|
setDescription: '기존 음성 채널을 생성기로 설정합니다.',
|
||||||
createDescription: '새 음성 채널을 만들고 생성기로 설정합니다',
|
createDescription: '새 음성 채널을 만들고 생성기로 설정합니다.',
|
||||||
channelOptionDescription: '생성기로 사용할 음성 채널',
|
channelOptionDescription: '?앹꽦湲곕줈 ?ъ슜???뚯꽦 梨꾨꼸',
|
||||||
categoryOptionDescription: '(선택) 임시 채널이 생성될 카테고리',
|
categoryOptionDescription: '(?좏깮) ?꾩떆 梨꾨꼸???앹꽦??移댄뀒怨좊━',
|
||||||
nameOptionDescription: '새 생성기 음성 채널의 이름',
|
nameOptionDescription: '???앹꽦湲??뚯꽦 梨꾨꼸???대쫫',
|
||||||
setSuccess: '{{channel}}을(를) 음성 생성기 채널로 설정했습니다!',
|
setSuccess: '{{channel}}??瑜? ?뚯꽦 ?앹꽦湲?梨꾨꼸濡??ㅼ젙?덉뒿?덈떎!',
|
||||||
createSuccess: '{{channel}}을(를) 음성 생성기 채널로 생성 및 설정했습니다!',
|
createSuccess: '{{channel}}??瑜? ?뚯꽦 ?앹꽦湲?梨꾨꼸濡??앹꽦 諛??ㅼ젙?덉뒿?덈떎!',
|
||||||
},
|
},
|
||||||
voiceConfig: {
|
voiceConfig: {
|
||||||
description: '서버의 임시 음성 채널 설정을 관리합니다.',
|
description: '?쒕쾭???꾩떆 ?뚯꽦 梨꾨꼸 ?ㅼ젙??愿由ы빀?덈떎.',
|
||||||
setNameTitle: '기본 이름 템플릿 설정',
|
setNameTitle: '湲곕낯 ?대쫫 ?쒗뵆由??ㅼ젙',
|
||||||
setNameDesc: '임시 채널 생성 시 사용할 기본 이름 형식을 설정합니다. (사용자명: {{username}})',
|
setNameDesc: '?꾩떆 梨꾨꼸 ?앹꽦 ???ъ슜??湲곕낯 ?대쫫 ?뺤떇???ㅼ젙?⑸땲?? (?ъ슜?먮챸: {{username}})',
|
||||||
setLimitTitle: '기본 인원 제한 설정',
|
setLimitTitle: '湲곕낯 ?몄썝 ?쒗븳 ?ㅼ젙',
|
||||||
setLimitDesc: '임시 채널 생성 시 적용할 기본 인원 제한을 설정합니다.',
|
setLimitDesc: '?꾩떆 梨꾨꼸 ?앹꽦 ???곸슜??湲곕낯 ?몄썝 ?쒗븳???ㅼ젙?⑸땲??',
|
||||||
statusTitle: '현재 서버 음성 설정',
|
statusTitle: '?꾩옱 ?쒕쾭 ?뚯꽦 ?ㅼ젙',
|
||||||
templateLabel: '이름 템플릿',
|
templateLabel: '이름 템플릿',
|
||||||
limitLabel: '기본 인원 제한',
|
limitLabel: '湲곕낯 ?몄썝 ?쒗븳',
|
||||||
setSuccess: '서버의 임시 채널 설정이 업데이트되었습니다.',
|
setSuccess: '?쒕쾭???꾩떆 梨꾨꼸 ?ㅼ젙???낅뜲?댄듃?섏뿀?듬땲??',
|
||||||
limitValue: '{{limit}}명 (0 = 무제한)',
|
limitValue: '{{limit}}紐?(0 = 臾댁젣??',
|
||||||
},
|
},
|
||||||
language: {
|
language: {
|
||||||
description: '봇의 언어를 설정합니다.',
|
description: '遊뉗쓽 ?몄뼱瑜??ㅼ젙?⑸땲??',
|
||||||
scopeDescription: '본인에게만 또는 서버 전체에 적용',
|
scopeDescription: '蹂몄씤?먭쾶留??먮뒗 ?쒕쾭 ?꾩껜???곸슜',
|
||||||
localeDescription: '사용할 언어',
|
localeDescription: '?ъ슜???몄뼱',
|
||||||
scopeUser: '나만 적용',
|
scopeUser: '?섎쭔 ?곸슜',
|
||||||
scopeServer: '서버 전체 (관리자 전용)',
|
scopeServer: '?쒕쾭 ?꾩껜 (愿由ъ옄 ?꾩슜)',
|
||||||
userSet: '개인 언어가 **{{locale}}**(으)로 설정되었습니다.',
|
userSet: '媛쒖씤 ?몄뼱媛 **{{locale}}**(??濡??ㅼ젙?섏뿀?듬땲??',
|
||||||
serverSet: '서버 언어가 **{{locale}}**(으)로 설정되었습니다.',
|
serverSet: '?쒕쾭 ?몄뼱媛 **{{locale}}**(??濡??ㅼ젙?섏뿀?듬땲??',
|
||||||
serverPermissionDenied: '서버 언어 변경은 서버 관리자만 가능합니다.',
|
serverPermissionDenied: '?쒕쾭 ?몄뼱 蹂寃쎌? ?쒕쾭 愿由ъ옄留?媛?ν빀?덈떎.',
|
||||||
},
|
},
|
||||||
event: {
|
event: {
|
||||||
description: '서버 이벤트 일정을 관리합니다.',
|
description: '?쒕쾭 ?대깽???쇱젙??愿由ы빀?덈떎.',
|
||||||
createDescription: '새 서버 이벤트를 생성합니다.',
|
createDescription: '???쒕쾭 ?대깽?몃? ?앹꽦?⑸땲??',
|
||||||
listDescription: '예정된 서버 이벤트 목록을 조회합니다.',
|
listDescription: '?덉젙???쒕쾭 ?대깽??紐⑸줉??議고쉶?⑸땲??',
|
||||||
cancelDescription: '예약된 서버 이벤트를 취소합니다.',
|
cancelDescription: '?덉빟???쒕쾭 ?대깽?몃? 痍⑥냼?⑸땲??',
|
||||||
announceDescription: '이벤트 공지 Embed를 다시 게시합니다.',
|
announceDescription: '?대깽??怨듭? Embed瑜??ㅼ떆 寃뚯떆?⑸땲??',
|
||||||
titleDescription: '이벤트 제목',
|
titleDescription: '?대깽???쒕ぉ',
|
||||||
dateDescription: 'YYYY-MM-DD 형식의 날짜',
|
dateDescription: 'YYYY-MM-DD ?뺤떇???좎쭨',
|
||||||
timeDescription: 'HH:mm 형식의 시간 (24시간제, Asia/Seoul 기준)',
|
timeDescription: 'HH:mm ?뺤떇???쒓컙 (24?쒓컙?? Asia/Seoul 湲곗?)',
|
||||||
descriptionOptionDescription: '선택 사항인 이벤트 설명',
|
descriptionOptionDescription: '?좏깮 ?ы빆???대깽???ㅻ챸',
|
||||||
channelDescription: '선택 사항인 공지 채널',
|
channelDescription: '?좏깮 ?ы빆??怨듭? 梨꾨꼸',
|
||||||
reminderDescription: '리마인더 메시지 사용 여부',
|
reminderDescription: '由щ쭏?몃뜑 硫붿떆吏 ?ъ슜 ?щ?',
|
||||||
remindersDescription: '분 단위 리마인더 목록, 예: 0,10,60',
|
remindersDescription: '遺??⑥쐞 由щ쭏?몃뜑 紐⑸줉, ?? 0,10,60',
|
||||||
idDescription: '취소할 이벤트 ID',
|
idDescription: '痍⑥냼???대깽??ID',
|
||||||
createSuccessTitle: '이벤트 생성 완료',
|
createSuccessTitle: '?대깽???앹꽦 ?꾨즺',
|
||||||
createSuccessBody: '**{{title}}** 이벤트가 예약되었습니다.',
|
createSuccessBody: '**{{title}}** ?대깽?멸? ?덉빟?섏뿀?듬땲??',
|
||||||
listTitle: '예정된 이벤트 목록',
|
listTitle: '?덉젙???대깽??紐⑸줉',
|
||||||
listEmpty: '예정된 이벤트가 없습니다.',
|
listEmpty: '?덉젙???대깽?멸? ?놁뒿?덈떎.',
|
||||||
listItemValue: '**시작 시각:** {{startsAt}}\n**남은 시간:** {{relative}}\n**상태:** {{status}}\n**리마인더:** {{reminder}}\n**채널:** {{channel}}',
|
listItemValue: '**?쒖옉 ?쒓컖:** {{startsAt}}\n**?⑥? ?쒓컙:** {{relative}}\n**?곹깭:** {{status}}\n**由щ쭏?몃뜑:** {{reminder}}\n**梨꾨꼸:** {{channel}}',
|
||||||
cancelSuccess: '`{{id}}` 이벤트를 취소했습니다.',
|
cancelSuccess: '`{{id}}` ?대깽?몃? 痍⑥냼?덉뒿?덈떎.',
|
||||||
cancelNotFound: 'ID가 `{{id}}`인 예약 이벤트를 찾지 못했습니다.',
|
cancelNotFound: 'ID媛 `{{id}}`???덉빟 ?대깽?몃? 李얠? 紐삵뻽?듬땲??',
|
||||||
announceSuccess: '`{{id}}` 이벤트를 {{channel}} 채널에 공지했습니다.',
|
announceSuccess: '`{{id}}` ?대깽?몃? {{channel}} 梨꾨꼸??怨듭??덉뒿?덈떎.',
|
||||||
announceNotAvailable: '이 이벤트에는 사용할 수 있는 공지 채널이 설정되어 있지 않습니다.',
|
announceNotAvailable: '???대깽?몄뿉???ъ슜?????덈뒗 怨듭? 梨꾨꼸???ㅼ젙?섏뼱 ?덉? ?딆뒿?덈떎.',
|
||||||
startAnnouncementTitle: '이벤트 시작',
|
startAnnouncementTitle: '?대깽???쒖옉',
|
||||||
startAnnouncementLead: '이 이벤트가 지금 시작됩니다.',
|
startAnnouncementLead: '???대깽?멸? 吏湲??쒖옉?⑸땲??',
|
||||||
invalidDateTime: '이벤트 날짜 또는 시간 형식이 올바르지 않습니다.',
|
invalidDateTime: '?대깽???좎쭨 ?먮뒗 ?쒓컙 ?뺤떇???щ컮瑜댁? ?딆뒿?덈떎.',
|
||||||
invalidDateTimeResolution: '날짜는 `YYYY-MM-DD`, 시간은 `HH:mm` 24시간 형식으로 입력해주세요.',
|
invalidDateTimeResolution: '?좎쭨??`YYYY-MM-DD`, ?쒓컙? `HH:mm` 24?쒓컙 ?뺤떇?쇰줈 ?낅젰?댁<?몄슂.',
|
||||||
invalidReminderOffsets: '리마인더 분 입력 형식이 올바르지 않습니다.',
|
invalidReminderOffsets: '由щ쭏?몃뜑 遺??낅젰 ?뺤떇???щ컮瑜댁? ?딆뒿?덈떎.',
|
||||||
invalidReminderOffsetsResolution: '`0,10,60`처럼 0 이상의 분을 쉼표로 구분해 입력해주세요. 비워두면 자동 공지는 하지 않습니다.',
|
invalidReminderOffsetsResolution: '`0,10,60`泥섎읆 0 ?댁긽??遺꾩쓣 ?쇳몴濡?援щ텇???낅젰?댁<?몄슂. 鍮꾩썙?먮㈃ ?먮룞 怨듭????섏? ?딆뒿?덈떎.',
|
||||||
invalidPastDateTime: '과거 시각으로 이벤트를 예약할 수 없습니다.',
|
invalidPastDateTime: '怨쇨굅 ?쒓컖?쇰줈 ?대깽?몃? ?덉빟?????놁뒿?덈떎.',
|
||||||
invalidPastDateTimeResolution: '미래 시각을 선택한 뒤 다시 시도해주세요.',
|
invalidPastDateTimeResolution: '誘몃옒 ?쒓컖???좏깮?????ㅼ떆 ?쒕룄?댁<?몄슂.',
|
||||||
statusScheduled: '예약됨',
|
statusScheduled: '예약됨',
|
||||||
statusCancelled: '취소됨',
|
statusCancelled: '취소됨',
|
||||||
statusCompleted: '완료됨',
|
statusCompleted: '완료됨',
|
||||||
reminderOn: '사용',
|
reminderOn: '?ъ슜',
|
||||||
reminderOff: '사용 안 함',
|
reminderOff: '사용 안 함',
|
||||||
reminderNone: '자동 공지 없음',
|
reminderNone: '?먮룞 怨듭? ?놁쓬',
|
||||||
announcementChannelNone: '미설정',
|
announcementChannelNone: '미설정',
|
||||||
fields: {
|
fields: {
|
||||||
eventId: '이벤트 ID',
|
eventId: '?대깽??ID',
|
||||||
startsAt: '시작 시각',
|
startsAt: '?쒖옉 ?쒓컖',
|
||||||
reminder: '리마인더',
|
reminder: '由щ쭏?몃뜑',
|
||||||
announcementChannel: '공지 채널',
|
announcementChannel: '怨듭? 梨꾨꼸',
|
||||||
status: '상태',
|
status: '?곹깭',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
music: {
|
music: {
|
||||||
|
|
@ -258,118 +258,157 @@ export const ko: TranslationSchema = {
|
||||||
leave: 'Leave',
|
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: {
|
permissionAudit: {
|
||||||
title: '봇 권한 진단 보고서',
|
title: '봇 권한 진단 보고서',
|
||||||
channel: '채널',
|
channel: '梨꾨꼸',
|
||||||
noResults: '진단할 기능이 없습니다. 봇이 아직 설정되지 않았을 수 있습니다.',
|
noResults: '吏꾨떒??湲곕뒫???놁뒿?덈떎. 遊뉗씠 ?꾩쭅 ?ㅼ젙?섏? ?딆븯?????덉뒿?덈떎.',
|
||||||
summaryLabel: '진단 결과 요약',
|
summaryLabel: '吏꾨떒 寃곌낵 ?붿빟',
|
||||||
summaryOk: '✅ 모든 항목 정상. 문제가 없습니다.',
|
summaryOk: '??紐⑤뱺 ??ぉ ?뺤긽. 臾몄젣媛 ?놁뒿?덈떎.',
|
||||||
summaryIssue: '❌ {{fail}}개 실패 · ⚠️ {{warn}}개 경고 감지됨.',
|
summaryIssue: '??{{fail}}媛??ㅽ뙣 쨌 ?좑툘 {{warn}}媛?寃쎄퀬 媛먯???',
|
||||||
hierarchyWarning: "봇 역할(순위: {{botPos}})이 '{{role}}'(순위: {{targetPos}})보다 위에 있어야 관리할 수 있습니다.",
|
hierarchyWarning: "遊???븷(?쒖쐞: {{botPos}})??'{{role}}'(?쒖쐞: {{targetPos}})蹂대떎 ?꾩뿉 ?덉뼱??愿由ы븷 ???덉뒿?덈떎.",
|
||||||
features: {
|
features: {
|
||||||
BASIC: '기본 봇 기능',
|
BASIC: '湲곕낯 遊?湲곕뒫',
|
||||||
VOICE_GLOBAL: '임시 음성 채널 (전역)',
|
VOICE_GLOBAL: '?꾩떆 ?뚯꽦 梨꾨꼸 (?꾩뿭)',
|
||||||
VOICE_GENERATOR_CHANNEL: '음성 생성기 채널',
|
VOICE_GENERATOR_CHANNEL: '?뚯꽦 ?앹꽦湲?梨꾨꼸',
|
||||||
VOICE_GENERATOR_CATEGORY: '음성 생성기 카테고리',
|
VOICE_GENERATOR_CATEGORY: '?뚯꽦 ?앹꽦湲?移댄뀒怨좊━',
|
||||||
INVITE_TRACKING: '초대 추적',
|
INVITE_TRACKING: '珥덈? 異붿쟻',
|
||||||
INVITE_ROLE_HIERARCHY: '초대 역할 부여 (계층 검사)',
|
INVITE_ROLE_HIERARCHY: '珥덈? ??븷 遺??(怨꾩링 寃??',
|
||||||
MIMIC_WEBHOOK: '메시지 흉내 (Webhook)',
|
MIMIC_WEBHOOK: '硫붿떆吏 ?됰궡 (Webhook)',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
setup: {
|
setup: {
|
||||||
description: '설정 마법사를 실행하여 봇의 필수 기능들을 단계별로 설정합니다.',
|
description: '?ㅼ젙 留덈쾿?щ? ?ㅽ뻾?섏뿬 遊뉗쓽 ?꾩닔 湲곕뒫?ㅼ쓣 ?④퀎蹂꾨줈 ?ㅼ젙?⑸땲??',
|
||||||
step0: {
|
step0: {
|
||||||
title: '✨ 봇 설정 마법사 시작',
|
title: '??遊??ㅼ젙 留덈쾿???쒖옉',
|
||||||
desc: '환영합니다! 이 마법사를 통해 아래 4가지 항목을 설정합니다.\n\n1️⃣ **언어 설정**\n2️⃣ **필수 권한 점검**\n3️⃣ **감사 채널 설정**\n4️⃣ **임시 음성 채널 설정**',
|
desc: '?섏쁺?⑸땲?? ??留덈쾿?щ? ?듯빐 ?꾨옒 4媛吏 ??ぉ???ㅼ젙?⑸땲??\n\n1截뤴깵 **?몄뼱 ?ㅼ젙**\n2截뤴깵 **?꾩닔 沅뚰븳 ?먭?**\n3截뤴깵 **媛먯궗 梨꾨꼸 ?ㅼ젙**\n4截뤴깵 **?꾩떆 ?뚯꽦 梨꾨꼸 ?ㅼ젙**',
|
||||||
startBtn: '설정 시작하기'
|
startBtn: '?ㅼ젙 ?쒖옉?섍린'
|
||||||
},
|
},
|
||||||
step1: {
|
step1: {
|
||||||
title: '1️⃣ 언어 설정',
|
title: '1截뤴깵 ?몄뼱 ?ㅼ젙',
|
||||||
desc: '서버 전체에 적용될 봇의 기본 언어를 선택하세요. (현재: **{{locale}}**)',
|
desc: '?쒕쾭 ?꾩껜???곸슜??遊뉗쓽 湲곕낯 ?몄뼱瑜??좏깮?섏꽭?? (?꾩옱: **{{locale}}**)',
|
||||||
placeholder: '언어를 선택하세요',
|
placeholder: '언어를 선택하세요',
|
||||||
nextBtn: '다음 단계',
|
nextBtn: '?ㅼ쓬 ?④퀎',
|
||||||
skipBtn: '건너뛰기'
|
skipBtn: '嫄대꼫?곌린'
|
||||||
},
|
},
|
||||||
step2: {
|
step2: {
|
||||||
title: '2️⃣ 필수 권한 점검',
|
title: '2截뤴깵 ?꾩닔 沅뚰븳 ?먭?',
|
||||||
descOk: '✅ **모든 필수 권한이 정상적으로 부여되어 있습니다.**',
|
descOk: '??**紐⑤뱺 ?꾩닔 沅뚰븳???뺤긽?곸쑝濡?遺?щ릺???덉뒿?덈떎.**',
|
||||||
descFail: '⚠️ **일부 권한이 부족합니다.**\n결과를 확인하고 봇 역할에 필요한 기능 권한을 부여해주세요.',
|
descFail: '?좑툘 **?쇰? 沅뚰븳??遺議깊빀?덈떎.**\n寃곌낵瑜??뺤씤?섍퀬 遊???븷???꾩슂??湲곕뒫 沅뚰븳??遺?ы빐二쇱꽭??',
|
||||||
recheckBtn: '다시 검사하기',
|
recheckBtn: '다시 검사하기',
|
||||||
nextBtn: '다음 단계'
|
nextBtn: '?ㅼ쓬 ?④퀎'
|
||||||
},
|
},
|
||||||
step3: {
|
step3: {
|
||||||
title: '3️⃣ 감사 채널 설정',
|
title: '3截뤴깵 媛먯궗 梨꾨꼸 ?ㅼ젙',
|
||||||
desc: '봇의 주요 이벤트와 에러 통보를 받을 채널을 선택해주세요.',
|
desc: '遊뉗쓽 二쇱슂 ?대깽?몄? ?먮윭 ?듬낫瑜?諛쏆쓣 梨꾨꼸???좏깮?댁<?몄슂.',
|
||||||
placeholder: '감사 통보 채널 선택',
|
placeholder: '媛먯궗 ?듬낫 梨꾨꼸 ?좏깮',
|
||||||
disableBtn: '감사 채널 끄기/해제',
|
disableBtn: '媛먯궗 梨꾨꼸 ?꾧린/?댁젣',
|
||||||
nextBtn: '다음 단계'
|
nextBtn: '?ㅼ쓬 ?④퀎'
|
||||||
},
|
},
|
||||||
step4: {
|
step4: {
|
||||||
title: '감사 로그 카테고리 설정',
|
title: '媛먯궗 濡쒓렇 移댄뀒怨좊━ ?ㅼ젙',
|
||||||
desc: '로그를 수신할 카테고리를 선택해주세요.',
|
desc: '濡쒓렇瑜??섏떊??移댄뀒怨좊━瑜??좏깮?댁<?몄슂.',
|
||||||
nextBtn: '다음 단계',
|
nextBtn: '?ㅼ쓬 ?④퀎',
|
||||||
},
|
},
|
||||||
step5: {
|
step5: {
|
||||||
title: '4️⃣ 임시 음성 채널 설정',
|
title: '4截뤴깵 ?꾩떆 ?뚯꽦 梨꾨꼸 ?ㅼ젙',
|
||||||
desc: '임시 음성 채널을 생성할 "생성기 채널"을 선택해주세요.\n기존의 채널을 고르거나 카테고리/채널을 봇이 **자동 생성**하게 할 수도 있습니다.',
|
desc: '?꾩떆 ?뚯꽦 梨꾨꼸???앹꽦??"?앹꽦湲?梨꾨꼸"???좏깮?댁<?몄슂.\n湲곗〈??梨꾨꼸??怨좊Ⅴ嫄곕굹 移댄뀒怨좊━/梨꾨꼸??遊뉗씠 **?먮룞 ?앹꽦**?섍쾶 ???섎룄 ?덉뒿?덈떎.',
|
||||||
placeholder: '생성기로 쓸 음성 채널 선택',
|
placeholder: '?앹꽦湲곕줈 ???뚯꽦 梨꾨꼸 ?좏깮',
|
||||||
autoBtn: '🚀 자동 생성하기',
|
autoBtn: '?? ?먮룞 ?앹꽦?섍린',
|
||||||
skipBtn: '임시 음성 사용 안함',
|
skipBtn: '?꾩떆 ?뚯꽦 ?ъ슜 ?덊븿',
|
||||||
nextBtn: '설정 완료'
|
nextBtn: '?ㅼ젙 ?꾨즺'
|
||||||
},
|
},
|
||||||
step6: {
|
step6: {
|
||||||
title: '🎉 설정 완료 요약',
|
title: '?럦 ?ㅼ젙 ?꾨즺 ?붿빟',
|
||||||
desc: '**1. 언어**: {{lang}}\n**2. 감사 채널**: {{audit}}\n**3. 감사 카테고리**: {{categories}}\n**4. 임시 음성 채널**: {{voice}}',
|
desc: '**1. ?몄뼱**: {{lang}}\n**2. 媛먯궗 梨꾨꼸**: {{audit}}\n**3. 媛먯궗 移댄뀒怨좊━**: {{categories}}\n**4. ?꾩떆 ?뚯꽦 梨꾨꼸**: {{voice}}',
|
||||||
finishBtn: '마치기'
|
finishBtn: '마치기'
|
||||||
},
|
},
|
||||||
finished: '✅ 설정 마법사를 종료했습니다.',
|
finished: '???ㅼ젙 留덈쾿?щ? 醫낅즺?덉뒿?덈떎.',
|
||||||
expired: '⏳ 시간이 만료되었습니다. `/setup`을 다시 실행해주세요.',
|
expired: '???쒓컙??留뚮즺?섏뿀?듬땲?? `/setup`???ㅼ떆 ?ㅽ뻾?댁<?몄슂.',
|
||||||
defaultCategoryName: '음성 채널',
|
defaultCategoryName: '?뚯꽦 梨꾨꼸',
|
||||||
defaultGeneratorName: '➕ 채널 생성하기',
|
defaultGeneratorName: '??梨꾨꼸 ?앹꽦?섍린',
|
||||||
auditCategories: {
|
auditCategories: {
|
||||||
SYSTEM: '시스템',
|
SYSTEM: '시스템',
|
||||||
BOOT: '부팅',
|
BOOT: '부팅',
|
||||||
VOICE: '음성',
|
VOICE: '?뚯꽦',
|
||||||
PERMISSION: '권한',
|
PERMISSION: '沅뚰븳',
|
||||||
INVITE: '초대',
|
INVITE: '珥덈?',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
config: {
|
config: {
|
||||||
title: '기능 설정 변경 결과',
|
title: '湲곕뒫 ?ㅼ젙 蹂寃?寃곌낵',
|
||||||
noOptions: '변경할 옵션을 하나 이상 선택해주세요.',
|
noOptions: '蹂寃쏀븷 ?듭뀡???섎굹 ?댁긽 ?좏깮?댁<?몄슂.',
|
||||||
mimic: {
|
mimic: {
|
||||||
label: '미믹(Mimic)',
|
label: '誘몃?(Mimic)',
|
||||||
enabled: '활성화',
|
enabled: '활성화',
|
||||||
disabled: '비활성화',
|
disabled: '鍮꾪솢?깊솕',
|
||||||
},
|
},
|
||||||
emoji: {
|
emoji: {
|
||||||
label: '이모지 확대(Big Emoji)',
|
label: '?대え吏 ?뺣?(Big Emoji)',
|
||||||
enabled: '활성화',
|
enabled: '활성화',
|
||||||
disabled: '비활성화',
|
disabled: '鍮꾪솢?깊솕',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── 모달 ────────────────────────────────────────────────
|
// ?? 紐⑤떖 ????????????????????????????????????????????????
|
||||||
modals: {
|
modals: {
|
||||||
renameTitle: '음성 채널 이름 변경',
|
renameTitle: '음성 채널 이름 변경',
|
||||||
renameLabel: '새 채널 이름',
|
renameLabel: '??梨꾨꼸 ?대쫫',
|
||||||
limitTitle: '인원 제한 설정',
|
limitTitle: '?몄썝 ?쒗븳 ?ㅼ젙',
|
||||||
limitLabel: '인원 제한 (0 = 무제한, 1-99)',
|
limitLabel: '?몄썝 ?쒗븳 (0 = 臾댁젣?? 1-99)',
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── 셀렉트 메뉴 플레이스홀더 ────────────────────────────
|
// ?? ??됲듃 硫붾돱 ?뚮젅?댁뒪???????????????????????????????
|
||||||
selects: {
|
selects: {
|
||||||
kickUser: '추방할 유저를 선택하세요',
|
kickUser: '추방할 유저를 선택하세요',
|
||||||
banUser: '차단할 유저를 선택하세요',
|
banUser: '차단할 유저를 선택하세요',
|
||||||
transferOwner: '소유권을 이전할 유저를 선택하세요',
|
transferOwner: '소유권을 이전할 유저를 선택하세요',
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── 상태 메시지 ──────────────────────────────────────────
|
// ?? ?곹깭 硫붿떆吏 ??????????????????????????????????????????
|
||||||
presence: {
|
presence: {
|
||||||
servers: '{{guildCount}}개의 서버에서 활동 중',
|
servers: '{{guildCount}}개의 서버에서 작동 중',
|
||||||
help: '/help 명령어를 확인하세요',
|
help: '/help 명령어를 확인하세요',
|
||||||
managing: '임시 음성 채널 관리 중',
|
managing: '임시 음성 채널 관리 중',
|
||||||
version: 'Kord v1.0.0',
|
version: 'Kord v1.0.0',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -212,6 +212,42 @@ export interface TranslationSchema {
|
||||||
leave: string;
|
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: {
|
permissionAudit: {
|
||||||
title: string;
|
title: string;
|
||||||
channel: string;
|
channel: string;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
import { ButtonInteraction, EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle, Colors } from 'discord.js';
|
||||||
|
import { RefinementService } from '../../services/RefinementService';
|
||||||
|
import { FeverService } from '../../services/FeverService';
|
||||||
|
import { prisma } from '../../database';
|
||||||
|
import { SupportedLocale } from '../../i18n';
|
||||||
|
|
||||||
|
export const handleRefinementInteraction = async (interaction: ButtonInteraction, locale: SupportedLocale) => {
|
||||||
|
const customId = interaction.customId;
|
||||||
|
|
||||||
|
if (customId.startsWith('refine_try_again:')) {
|
||||||
|
const parts = customId.split(':');
|
||||||
|
const ownerId = parts[1];
|
||||||
|
|
||||||
|
if (interaction.user.id !== ownerId) {
|
||||||
|
return interaction.reply({ content: '❌ 본인의 재련 결과만 다시 시도할 수 있습니다.', ephemeral: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 미니게임 활성화 및 채널 체크
|
||||||
|
const config = await prisma.miniGameConfig.findUnique({
|
||||||
|
where: { guildId_gameKey: { guildId: interaction.guildId!, gameKey: 'refinement' } }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!config || !config.enabled) {
|
||||||
|
return interaction.reply({ content: '❌ 이 서버에서는 재련 미니게임이 비활성화되어 있습니다.', ephemeral: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.channelId && config.channelId !== interaction.channelId) {
|
||||||
|
return interaction.reply({ content: `❌ 재련 미니게임은 <#${config.channelId}> 채널에서만 이용 가능합니다.`, ephemeral: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await RefinementService.tryRefine(interaction.user.id, interaction.guildId!);
|
||||||
|
const fever = await FeverService.getFeverBonus(interaction.guildId!);
|
||||||
|
|
||||||
|
const embed = new EmbedBuilder()
|
||||||
|
.setTitle(result.success ? '✨ 재련 성공!' : (result.destroyed ? '🧨 무기 파괴!' : '❌ 재련 실패...'))
|
||||||
|
.setColor(result.success ? Colors.Green : (result.destroyed ? Colors.Red : Colors.Grey))
|
||||||
|
.addFields(
|
||||||
|
{ name: '강화 단계', value: `${result.levelBefore} → **${result.levelAfter}**`, inline: true },
|
||||||
|
{ name: '비용', value: `${result.cost} G`, inline: true },
|
||||||
|
{ name: '잔액', value: `${result.remainingGold} G`, inline: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (fever.active) {
|
||||||
|
embed.setFooter({ text: `🔥 피버 타임 활성화 중! (성공률 +${fever.bonusRate * 100}%)` });
|
||||||
|
}
|
||||||
|
|
||||||
|
const retryBtn = new ButtonBuilder()
|
||||||
|
.setCustomId(`refine_try_again:${interaction.user.id}`)
|
||||||
|
.setLabel('다시 시도')
|
||||||
|
.setStyle(ButtonStyle.Primary);
|
||||||
|
|
||||||
|
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(retryBtn);
|
||||||
|
|
||||||
|
// 이미 사용된 버튼 메시지를 업데이트
|
||||||
|
return interaction.update({ embeds: [embed], components: [row] });
|
||||||
|
} catch (err: any) {
|
||||||
|
return interaction.reply({ content: `❌ 오류: ${err.message}`, ephemeral: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
import { prisma } from '../database';
|
||||||
|
import { logger } from '../utils/logger';
|
||||||
|
|
||||||
|
export class ActivityTrackerService {
|
||||||
|
/**
|
||||||
|
* 메시지 활동을 기록합니다.
|
||||||
|
* @param guildId 서버 ID
|
||||||
|
*/
|
||||||
|
public static async recordActivity(guildId: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const now = new Date();
|
||||||
|
const hour = now.getUTCHours();
|
||||||
|
const dayOfWeek = now.getUTCDay();
|
||||||
|
|
||||||
|
// 이번 주의 시작일 (일요일 00:00:00)
|
||||||
|
const weekStart = new Date(now);
|
||||||
|
weekStart.setUTCDate(now.getUTCDate() - dayOfWeek);
|
||||||
|
weekStart.setUTCHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
await prisma.activityLog.upsert({
|
||||||
|
where: {
|
||||||
|
guildId_hour_dayOfWeek_weekStart: {
|
||||||
|
guildId,
|
||||||
|
hour,
|
||||||
|
dayOfWeek,
|
||||||
|
weekStart,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
count: { increment: 1 },
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
guildId,
|
||||||
|
hour,
|
||||||
|
dayOfWeek,
|
||||||
|
weekStart,
|
||||||
|
count: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(`Failed to record activity for guild ${guildId}:`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 최근 활동 데이터를 기반으로 피크 시간대를 분석합니다. (FeverService에서 사용)
|
||||||
|
*/
|
||||||
|
public static async getPeakHour(guildId: string): Promise<number | null> {
|
||||||
|
const logs = await prisma.activityLog.findMany({
|
||||||
|
where: { guildId },
|
||||||
|
orderBy: { count: 'desc' },
|
||||||
|
take: 1
|
||||||
|
});
|
||||||
|
|
||||||
|
return logs.length > 0 ? logs[0].hour : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,82 @@
|
||||||
|
import { prisma } from '../database';
|
||||||
|
import { logger } from '../utils/logger';
|
||||||
|
import { ActivityTrackerService } from './ActivityTrackerService';
|
||||||
|
|
||||||
|
export class FeverService {
|
||||||
|
private static BONUS_RATE = 0.1; // +10% 성공률
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 봇 시작 시 피버 스케줄러를 가동합니다. (1시간마다 체크)
|
||||||
|
*/
|
||||||
|
public static startScheduler() {
|
||||||
|
// 1시간마다 전 서버의 피버 상태를 갱신하거나 분석을 시도하는 스케줄러 (간단히 setInterval)
|
||||||
|
setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const guilds = await prisma.guildConfig.findMany({ select: { guildId: true } });
|
||||||
|
for (const guild of guilds) {
|
||||||
|
await this.updateFeverState(guild.guildId);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Fever scheduler error:', err);
|
||||||
|
}
|
||||||
|
}, 1000 * 60 * 60); // 1 hour
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 개별 서버의 피버 상태를 분석하고 갱신합니다.
|
||||||
|
*/
|
||||||
|
public static async updateFeverState(guildId: string): Promise<void> {
|
||||||
|
const peakHour = await ActivityTrackerService.getPeakHour(guildId);
|
||||||
|
if (peakHour === null) return;
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const currentHour = now.getUTCHours();
|
||||||
|
|
||||||
|
// 현재 시간이 피크 시간대라면 피버 활성화 (1시간)
|
||||||
|
const isActive = currentHour === peakHour;
|
||||||
|
const expiresAt = isActive ? new Date(now.getTime() + 1000 * 60 * 60) : null;
|
||||||
|
|
||||||
|
await prisma.feverState.upsert({
|
||||||
|
where: { guildId },
|
||||||
|
update: {
|
||||||
|
isActive,
|
||||||
|
peakHour,
|
||||||
|
expiresAt,
|
||||||
|
bonusRate: this.BONUS_RATE,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
guildId,
|
||||||
|
isActive,
|
||||||
|
peakHour,
|
||||||
|
expiresAt,
|
||||||
|
bonusRate: this.BONUS_RATE,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isActive) {
|
||||||
|
logger.info(`[Fever] Activated for guild ${guildId} (Peak Hour: ${peakHour})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 현재 피버 보너스 정보를 조회합니다.
|
||||||
|
*/
|
||||||
|
public static async getFeverBonus(guildId: string): Promise<{ active: boolean; bonusRate: number }> {
|
||||||
|
const fever = await prisma.feverState.findUnique({ where: { guildId } });
|
||||||
|
|
||||||
|
if (!fever || !fever.isActive || !fever.expiresAt) {
|
||||||
|
return { active: false, bonusRate: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 만료 체크
|
||||||
|
if (new Date() > fever.expiresAt) {
|
||||||
|
await prisma.feverState.update({
|
||||||
|
where: { guildId },
|
||||||
|
data: { isActive: false }
|
||||||
|
});
|
||||||
|
return { active: false, bonusRate: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { active: true, bonusRate: fever.bonusRate };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
export interface MiniGame {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
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: '재련',
|
||||||
|
description: '무기를 강화하고 다른 유저와 전투하며 골드를 모으는 미니게임입니다.',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getMiniGame = (key: string): MiniGame | undefined => {
|
||||||
|
return MINI_GAMES[key];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getAllMiniGames = (): MiniGame[] => {
|
||||||
|
return Object.values(MINI_GAMES);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,330 @@
|
||||||
|
import { prisma } from '../database';
|
||||||
|
import { logger } from '../utils/logger';
|
||||||
|
import { FeverService } from './FeverService';
|
||||||
|
import { RefinementLevelConfig, RefinementBattleConfig, RefinementSystemConfig } from '@prisma/client';
|
||||||
|
|
||||||
|
export interface RefineResult {
|
||||||
|
success: boolean;
|
||||||
|
destroyed: boolean;
|
||||||
|
levelBefore: number;
|
||||||
|
levelAfter: number;
|
||||||
|
cost: number;
|
||||||
|
remainingGold: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BattleResult {
|
||||||
|
winnerId: string;
|
||||||
|
loserId: string;
|
||||||
|
attackerId: string;
|
||||||
|
targetId: string;
|
||||||
|
reward: number;
|
||||||
|
attackerLevel: number;
|
||||||
|
targetLevel: number;
|
||||||
|
attackerDurability: number;
|
||||||
|
targetDurability: number;
|
||||||
|
destroyed: boolean; // 공격자 무기 파괴 여부 (내구도 0에서 공격 시)
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RefinementService {
|
||||||
|
// 캐시 필드
|
||||||
|
private static levelConfigs = new Map<number, RefinementLevelConfig>();
|
||||||
|
private static battleConfigs = new Map<number, number>(); // Gap -> WinRate
|
||||||
|
private static systemConfigs = new Map<string, string>(); // Key -> Value
|
||||||
|
private static isInitialized = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 레벨에 따른 최대 내구도 계산 (10 + level)
|
||||||
|
*/
|
||||||
|
public static getMaxDurability(level: number): number {
|
||||||
|
return 10 + level;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DB에서 밸런스 설정을 로드하여 메모리에 캐싱
|
||||||
|
*/
|
||||||
|
public static async loadConfigs(force: boolean = false) {
|
||||||
|
if (this.isInitialized && !force) return;
|
||||||
|
|
||||||
|
const [levels, battles, systems] = await Promise.all([
|
||||||
|
prisma.refinementLevelConfig.findMany(),
|
||||||
|
prisma.refinementBattleConfig.findMany(),
|
||||||
|
prisma.refinementSystemConfig.findMany(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
this.levelConfigs.clear();
|
||||||
|
levels.forEach((c: RefinementLevelConfig) => this.levelConfigs.set(c.level, c));
|
||||||
|
|
||||||
|
this.battleConfigs.clear();
|
||||||
|
battles.forEach((c: RefinementBattleConfig) => this.battleConfigs.set(c.levelGap, c.winRate));
|
||||||
|
|
||||||
|
this.systemConfigs.clear();
|
||||||
|
systems.forEach((c: RefinementSystemConfig) => this.systemConfigs.set(c.key, c.value));
|
||||||
|
|
||||||
|
this.isInitialized = true;
|
||||||
|
logger.info(`[Refinement] Loaded ${levels.length} levels, ${battles.length} battle gaps, and ${systems.length} system configs.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static getSysConfig(key: string, defaultValue: string): string {
|
||||||
|
return this.systemConfigs.get(key) ?? defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static getSysConfigNum(key: string, defaultValue: number): number {
|
||||||
|
const val = this.systemConfigs.get(key);
|
||||||
|
return val ? Number.parseFloat(val) : defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 레벨에 따른 강화 비용 가져오기
|
||||||
|
*/
|
||||||
|
public static getCost(level: number): number {
|
||||||
|
return this.levelConfigs.get(level)?.cost ?? 999999999;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 재련 시도
|
||||||
|
*/
|
||||||
|
public static async tryRefine(userId: string, guildId: string): Promise<RefineResult> {
|
||||||
|
await this.loadConfigs();
|
||||||
|
const profile = await this.getOrCreateProfile(userId, guildId);
|
||||||
|
|
||||||
|
const maxLevel = this.getSysConfigNum('MAX_LEVEL', 25);
|
||||||
|
if (profile.weaponLevel >= maxLevel) {
|
||||||
|
throw new Error(`이미 최대 단계(${maxLevel}강)에 도달했습니다. 무기를 판매하고 다시 시작하세요!`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const levelConfig = this.levelConfigs.get(profile.weaponLevel);
|
||||||
|
if (!levelConfig) throw new Error('강화 정보를 불러올 수 없습니다.');
|
||||||
|
|
||||||
|
const cost = levelConfig.cost;
|
||||||
|
|
||||||
|
if (profile.gold < cost) {
|
||||||
|
throw new Error('골드가 부족합니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const fever = await FeverService.getFeverBonus(guildId);
|
||||||
|
const successRate = levelConfig.successRate + (fever.active ? fever.bonusRate : 0);
|
||||||
|
|
||||||
|
const random = Math.random();
|
||||||
|
const success = random <= successRate;
|
||||||
|
let destroyed = false;
|
||||||
|
let newLevel = profile.weaponLevel;
|
||||||
|
let newDurability = profile.durability;
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
newLevel = Math.min(maxLevel, profile.weaponLevel + 1);
|
||||||
|
newDurability = this.getMaxDurability(newLevel);
|
||||||
|
} else {
|
||||||
|
const destroyRate = levelConfig.destroyRate;
|
||||||
|
if (Math.random() <= destroyRate) {
|
||||||
|
destroyed = true;
|
||||||
|
newLevel = 0;
|
||||||
|
newDurability = 10;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedProfile = await prisma.refinementProfile.update({
|
||||||
|
where: { userId_guildId: { userId, guildId } },
|
||||||
|
data: {
|
||||||
|
gold: { decrement: cost },
|
||||||
|
weaponLevel: newLevel,
|
||||||
|
durability: newDurability,
|
||||||
|
isDisabled: newDurability > 0 ? false : undefined,
|
||||||
|
maxWeaponLevel: { set: Math.max(profile.maxWeaponLevel, newLevel) },
|
||||||
|
tryCount: { increment: 1 },
|
||||||
|
successCount: success ? { increment: 1 } : undefined,
|
||||||
|
failCount: !success ? { increment: 1 } : undefined,
|
||||||
|
destroyCount: destroyed ? { increment: 1 } : undefined,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success,
|
||||||
|
destroyed,
|
||||||
|
levelBefore: profile.weaponLevel,
|
||||||
|
levelAfter: newLevel,
|
||||||
|
cost,
|
||||||
|
remainingGold: updatedProfile.gold,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 전투 수행 (일방적)
|
||||||
|
*/
|
||||||
|
public static async startBattle(attackerId: string, targetId: string, guildId: string): Promise<BattleResult> {
|
||||||
|
await this.loadConfigs();
|
||||||
|
const attacker = await this.getOrCreateProfile(attackerId, guildId);
|
||||||
|
const target = await this.getOrCreateProfile(targetId, guildId);
|
||||||
|
|
||||||
|
// 0. 일일 공격 횟수 체크 및 리셋 (KST 기준 자정 리셋)
|
||||||
|
const now = new Date();
|
||||||
|
const lastReset = attacker.lastBattleReset || new Date(0);
|
||||||
|
const isNewDay = now.toDateString() !== lastReset.toDateString();
|
||||||
|
|
||||||
|
const dailyLimit = this.getSysConfigNum('DAILY_BATTLE_LIMIT', 10);
|
||||||
|
let currentDailyCount = isNewDay ? 0 : attacker.dailyBattleCount;
|
||||||
|
if (currentDailyCount >= dailyLimit) {
|
||||||
|
throw new Error(`일일 공격 제한(${dailyLimit}회)에 도달했습니다. 내일 다시 도전하세요!`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attacker.weaponLevel === 0 || target.weaponLevel === 0) {
|
||||||
|
throw new Error('0강 무기 소유자는 전투에 참여할 수 없습니다. 최소 1강 이상 강화하세요.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attacker.isDisabled) throw new Error('현재 전투 불능 상태입니다. 무기 수리(재련 시도 등)가 필요합니다.');
|
||||||
|
|
||||||
|
// 1. 승패 확률 가져오기
|
||||||
|
const diff = attacker.weaponLevel - target.weaponLevel;
|
||||||
|
const winRate = this.battleConfigs.get(diff) ?? (diff > 0 ? 0.99 : 0);
|
||||||
|
|
||||||
|
const isAttackerWin = Math.random() < winRate;
|
||||||
|
|
||||||
|
const winnerId = isAttackerWin ? attackerId : targetId;
|
||||||
|
const loserId = isAttackerWin ? targetId : attackerId;
|
||||||
|
|
||||||
|
// 보상 계산: 승리자 역배율 적용 (TargetCost × TargetLevel/WinnerLevel × Multiplier)
|
||||||
|
const levelDiff = Math.abs(attacker.weaponLevel - target.weaponLevel);
|
||||||
|
const winnerLevel = isAttackerWin ? attacker.weaponLevel : target.weaponLevel;
|
||||||
|
const loserLevel = isAttackerWin ? target.weaponLevel : attacker.weaponLevel;
|
||||||
|
const loserCost = this.getCost(loserLevel);
|
||||||
|
|
||||||
|
const inverseMultiplier = this.getSysConfigNum('INVERSE_REWARD_MULTIPLIER', 2.0);
|
||||||
|
const randomMin = this.getSysConfigNum('REWARD_RANDOM_MIN', 0.8);
|
||||||
|
const randomMax = this.getSysConfigNum('REWARD_RANDOM_MAX', 1.2);
|
||||||
|
|
||||||
|
const ratio = Math.min(1.0, loserLevel / winnerLevel);
|
||||||
|
const baseReward = Math.floor(loserCost * ratio * inverseMultiplier);
|
||||||
|
|
||||||
|
const randomFactor = randomMin + Math.random() * (randomMax - randomMin);
|
||||||
|
const reward = Math.floor(baseReward * randomFactor) + (levelDiff * 100);
|
||||||
|
|
||||||
|
// 내구도 감소 (양쪽 모두 -1)
|
||||||
|
const newAttackerDurability = Math.max(0, attacker.durability - 1);
|
||||||
|
const newTargetDurability = Math.max(0, target.durability - 1);
|
||||||
|
|
||||||
|
// 무기 파괴/불능 처리
|
||||||
|
const attackerDestroyed = false;
|
||||||
|
|
||||||
|
await prisma.$transaction([
|
||||||
|
prisma.refinementProfile.update({
|
||||||
|
where: { userId_guildId: { userId: attackerId, guildId } },
|
||||||
|
data: {
|
||||||
|
gold: attacker.gold + (isAttackerWin ? reward : 0),
|
||||||
|
durability: newAttackerDurability,
|
||||||
|
isDisabled: newAttackerDurability <= 0,
|
||||||
|
battleWin: isAttackerWin ? attacker.battleWin + 1 : attacker.battleWin,
|
||||||
|
battleLoss: isAttackerWin ? attacker.battleLoss : attacker.battleLoss + 1,
|
||||||
|
dailyBattleCount: currentDailyCount + 1,
|
||||||
|
lastBattleReset: now,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
prisma.refinementProfile.update({
|
||||||
|
where: { userId_guildId: { userId: targetId, guildId } },
|
||||||
|
data: {
|
||||||
|
gold: target.gold + (!isAttackerWin ? reward : 0),
|
||||||
|
durability: newTargetDurability,
|
||||||
|
isDisabled: newTargetDurability <= 0,
|
||||||
|
battleWin: !isAttackerWin ? target.battleWin + 1 : target.battleWin,
|
||||||
|
battleLoss: !isAttackerWin ? target.battleLoss : target.battleLoss + 1,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
winnerId,
|
||||||
|
loserId,
|
||||||
|
attackerId,
|
||||||
|
targetId,
|
||||||
|
reward,
|
||||||
|
attackerLevel: attacker.weaponLevel,
|
||||||
|
targetLevel: target.weaponLevel,
|
||||||
|
attackerDurability: newAttackerDurability,
|
||||||
|
targetDurability: newTargetDurability,
|
||||||
|
destroyed: attackerDestroyed,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 일일 출석
|
||||||
|
*/
|
||||||
|
public static async checkIn(userId: string, guildId: string): Promise<{ goldAdded: number; totalGold: number }> {
|
||||||
|
const profile = await this.getOrCreateProfile(userId, guildId);
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
if (profile.lastCheckIn) {
|
||||||
|
const last = new Date(profile.lastCheckIn);
|
||||||
|
if (last.getUTCFullYear() === now.getUTCFullYear() &&
|
||||||
|
last.getUTCMonth() === now.getUTCMonth() &&
|
||||||
|
last.getUTCDate() === now.getUTCDate()) {
|
||||||
|
throw new Error('오늘은 이미 출석했습니다.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkInGold = this.getSysConfigNum('CHECKIN_GOLD', 500);
|
||||||
|
const updated = await prisma.refinementProfile.update({
|
||||||
|
where: { userId_guildId: { userId, guildId } },
|
||||||
|
data: {
|
||||||
|
gold: { increment: checkInGold },
|
||||||
|
lastCheckIn: now,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { goldAdded: checkInGold, totalGold: updated.gold };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 무기 판매
|
||||||
|
*/
|
||||||
|
public static async sellWeapon(userId: string, guildId: string): Promise<{ level: number; price: number; gold: number }> {
|
||||||
|
await this.loadConfigs();
|
||||||
|
const profile = await this.getOrCreateProfile(userId, guildId);
|
||||||
|
const level = profile.weaponLevel;
|
||||||
|
const cost = this.getCost(level);
|
||||||
|
|
||||||
|
const multiplier = this.levelConfigs.get(level)?.sellMultiplier ?? 1;
|
||||||
|
const price = Math.floor(cost * multiplier);
|
||||||
|
|
||||||
|
const updated = await prisma.refinementProfile.update({
|
||||||
|
where: { userId_guildId: { userId, guildId } },
|
||||||
|
data: {
|
||||||
|
gold: { increment: price },
|
||||||
|
weaponLevel: 0,
|
||||||
|
durability: 10, // 판매 후 초기화 시 내구도 복구
|
||||||
|
isDisabled: false,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { level: profile.weaponLevel, price, gold: updated.gold };
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async getProfile(userId: string, guildId: string) {
|
||||||
|
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 } }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!profile) {
|
||||||
|
await this.loadConfigs();
|
||||||
|
const startGold = this.getSysConfigNum('START_GOLD', 1000);
|
||||||
|
profile = await prisma.refinementProfile.create({
|
||||||
|
data: { userId, guildId, gold: startGold }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return profile;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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('· · 🐟');
|
||||||
|
});
|
||||||
|
});
|
||||||