Compare commits

...

11 Commits

51 changed files with 4373 additions and 329 deletions

53
Docs/.!97170!index.md Normal file
View File

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

View File

@ -0,0 +1,367 @@
# 낚시 미니게임 구현 기획안
이 문서는 Kord의 `낚시(Fishing)` 미니게임 시스템에 대한 설계 방향과 구현 범위를 정리한 기획서입니다.
## 개요
낚시 미니게임은 기존 미니게임 경제 시스템에서 사용할 재화를 공급하는, 실시간 이모지 조작형 미니게임입니다.
사용자는 1초마다 갱신되는 상태 메시지를 보며 입력을 미리 선택하고, 물고기가 특정 위치에 일정 시간 머문 뒤 그 입력이 맞았는지 판정받습니다. 핵심 목표는 물고기를 끌어와 거리를 줄이는 동시에, 낚시줄 끊어짐 게이지가 가득 차지 않도록 관리하는 것입니다.
이 게임은 단순 슬래시 명령 기반이 아니라, 버튼 조작과 실시간 게이지 변화, 물고기 이모지 표시를 통해 더 직접적인 조작감을 주는 것을 목표로 합니다.
## 핵심 게임 루프
### 1. 스레드 입장과 세션 시작
- 사용자는 먼저 `/fishing enter` 명령으로 낚시 전용 스레드에 입장합니다.
- 봇은 명령을 실행한 채널에 `UserName's Fishing Spot` 형식의 전용 스레드를 생성합니다.
- 이미 같은 사용자의 낚시 스레드가 있으면 기존 스레드를 재사용합니다.
- 실제 낚시 세션 시작은 전용 스레드 안에서 `/fishing cast`를 입력했을 때만 가능합니다.
- 즉, 스레드 생성과 게임 시작은 별도 단계로 분리합니다.
- 봇은 실시간 게임 메시지를 생성합니다.
- 메시지에는 다음 요소가 포함됩니다.
- 중앙에 표시되는 물고기 이모지
- 거리 게이지
- 낚시줄 끊어짐 게이지
- 네 개의 조작 버튼
- 현재 라운드에 예약된 입력
- 다음 판정까지 남은 시간
### 1-1. 전용 스레드 규칙
- 스레드 이름은 영문 기준 `UserName's Fishing Spot` 형식을 사용합니다.
- 낚시 게임 메시지, 버튼 입력, 결과 메시지는 모두 해당 스레드 안에서만 처리합니다.
- 이렇게 하면 일반 채팅이 밀려도 낚시 UI가 섞이지 않고 유지됩니다.
- 한 사용자는 동시에 하나의 낚시 스레드만 활성화할 수 있습니다.
- `/fishing cast`는 자신의 낚시 스레드 안에서만 사용할 수 있습니다.
- 성공이나 실패로 게임이 끝나더라도 스레드는 즉시 삭제하지 않습니다.
- 같은 스레드 안에서 다시 `/fishing cast`를 입력해 연속 플레이할 수 있어야 합니다.
- `/fishing end`를 실행했을 때만 세션을 정리하고 스레드를 삭제합니다.
### 2. 조작 버튼
사용자는 아래 네 가지 버튼 중 하나를 선택할 수 있습니다.
- `⬅️` 왼쪽
- `⏺️` 중앙
- `➡️` 오른쪽
- `🛌` 휴식
### 3. 물고기 위치
- 물고기는 텍스트가 아니라 이모지로 표시됩니다.
- 물고기 이모지는 메시지의 시각적 중앙 라인에 위치해야 합니다.
- 내부 상태상 물고기 위치는 왼쪽, 중앙, 오른쪽 중 하나를 가집니다.
- 물고기는 한 라운드 동안 같은 위치에 일정 시간 머뭅니다.
- 사용자는 그 라운드 안에서 방향 버튼을 미리 입력합니다.
- 물고기가 해당 위치에 일정 시간 이상 머문 뒤 입력이 일치하면 성공 판정이 납니다.
- 입력이 없거나 다른 방향이면 `타이밍 빗나감`으로 처리됩니다.
### 4. 거리 게이지
- 물고기를 얼마나 더 끌어와야 하는지를 나타내는 게이지입니다.
- 시작값은 `100` 같은 양수로 설정합니다.
- 방향을 맞춘 입력이 성공 판정되면 거리가 감소합니다.
- 휴식을 선택하면 거리가 다시 증가합니다.
- 거리가 `0`이 되면 낚시에 성공합니다.
### 5. 낚시줄 끊어짐 게이지
- 낚시줄이 얼마나 끊어질 위기에 가까운지를 나타내는 게이지입니다.
- 방향을 맞춘 입력이 성공 판정되면 이 게이지가 증가합니다.
- 타이밍을 놓치면 소량 증가합니다.
- 휴식을 선택하면 이 게이지가 회복됩니다.
- 게이지가 최대치에 도달하면 즉시 낚시에 실패합니다.
### 6. 휴식 행동
- `🛌` 휴식은 해당 라운드에서 즉시 적용됩니다.
- 낚시줄 끊어짐 게이지를 회복합니다.
- 대신 물고기와의 거리가 다시 증가합니다.
- 즉, 휴식은 실패 위험을 낮추지만 진행도를 되돌리는 안전 선택지입니다.
### 7. 틱 업데이트
- 낚시 상태는 1초마다 갱신됩니다.
- 메시지도 1초 주기로 갱신됩니다.
- 이 갱신 루프는 아래 요소를 처리합니다.
- 현재 라운드 경과 시간 계산
- 입력 예약 상태 반영
- 성공 / 타이밍 빗나감 판정
- 다음 물고기 위치로 라운드 전환
- 게이지 렌더링 갱신
- 세션 종료 판정
## UI / UX 요구사항
### 실시간 메시지 구성
낚시 메시지는 최소한 아래 요소를 포함해야 합니다.
- 물고기 이모지로 표시되는 현재 위치
- 거리 게이지
- 낚시줄 끊어짐 게이지
- 간단한 상태 텍스트
- 현재 예약 입력
- 다음 판정까지 남은 시간
- 예: `입질 중`, `타이밍 빗나감`, `휴식 중`, `성공`, `실패`
### 게이지 디자인
항상 아래 두 개의 게이지가 동시에 보여야 합니다.
1. 거리 게이지
- 성공을 향해 감소하는 게이지
- 휴식 시 다시 증가할 수 있음
- 채워진 블록 / 빈 블록 형태로 표현 가능
2. 낚시줄 끊어짐 게이지
- 실패를 향해 증가하는 게이지
- 거리 게이지와 시각적으로 구분되어야 함
예시:
```text
위치: ⬅️ 🐟 ➡️
입력: ⏺️
남은 시간: 2s
거리: ████████░░ 20 / 100
끊어짐: ████░░░░░░ 40 / 100
```
최종 렌더링은 더 다듬을 수 있지만, 핵심은 물고기 자체를 반드시 이모지로 유지하는 것입니다.
### 조작 버튼
버튼은 직관적이고 빠르게 눌릴 수 있어야 하므로, 이모지 중심으로 구성합니다.
- `⬅️`
- `⏺️`
- `➡️`
- `🛌`
## 성공 / 실패 조건
### 성공
- 거리 게이지가 `0`이 됩니다.
- 보상을 지급합니다.
- 세션을 종료하고 버튼을 비활성화합니다.
### 실패
- 낚시줄 끊어짐 게이지가 최대치에 도달합니다.
- 세션을 종료하고 버튼을 비활성화합니다.
### 선택적 시간 제한
- 일정 시간 동안 행동하지 않으면 실패하도록 설계할 수도 있습니다.
- 다만 이 기능은 MVP에는 필수가 아니며, 템포가 느릴 때만 후속 단계에서 검토합니다.
### 종료 명령
- `/fishing end` 명령을 제공해 사용자가 직접 낚시 스레드를 종료할 수 있어야 합니다.
- 종료 명령이 실행되면 진행 중인 낚시 세션이 있다면 즉시 정리됩니다.
- 전용 스레드는 `/fishing end`에서만 삭제됩니다.
- 성공/실패 후에는 버튼만 비활성화하고 스레드는 유지합니다.
- 사용자는 같은 스레드 안에서 다시 `/fishing cast`를 입력해 새 게임을 시작할 수 있어야 합니다.
## 경제 시스템 연동
낚시는 기존 미니게임 경제를 보조하는 수단으로 설계합니다.
### 권장 보상 모델
- 낚시에 성공하면 `gold`를 지급합니다.
- 지급된 `gold`는 현재 `refinement` 게임이 사용하는 경제 프로필에 반영합니다.
- 이렇게 하면 낚시는 정련 게임을 보조하는 재화 수급형 미니게임이 됩니다.
### 확장 가능성
후속 버전에서는 아래 요소를 추가할 수 있습니다.
- 물고기 희귀도
- 개별 물고기 인벤토리
- 미끼 종류
- 낚싯대 및 업그레이드
- 물고기 판매 시스템
Phase 1에서는 직접 `gold`를 주는 방식이 가장 단순하고 호환성이 좋습니다.
## 설정 모델
낚시는 공용 미니게임 레지스트리에 등록되는 구조로 가야 합니다.
### MiniGame Registry
추가할 키:
- `fishing`
이를 통해 다음이 가능해집니다.
- 길드별 활성화 / 비활성화
- 전용 채널 제한
- `/minigame`을 통한 상태 조회
낚시의 경우 전용 채널 안에서 다시 개별 스레드를 생성하는 방식으로 운영합니다.
## 기술 구조
### 서비스
권장 신규 서비스:
- `FishingService`
주요 책임:
- 낚시 전용 스레드 생성 / 삭제
- 세션 생성 / 종료
- 사용자별 활성 낚시 세션 추적
- 틱 업데이트 루프 관리
- 물고기 위치 생성
- 입력 예약 및 라운드 판정
- 보상 정산
### 인터랙션 처리
권장 커스텀 ID prefix:
- `fishing_`
기존 구조와 일관성을 유지합니다.
- `music_`
- `refine_`
### 영속 데이터
권장 초기 모델:
- `FishingProfile`
- `userId`
- `guildId`
- `totalCatchCount`
- `successCount`
- `failCount`
- `bestCatchReward`
- `lastCastAt`
- `createdAt`
- `updatedAt`
이 정도면 초반 통계 추적에 충분하고, 너무 이르게 인벤토리를 과설계하지 않을 수 있습니다.
### 세션 모델
낚시 플레이 자체는 즉각적인 반응을 위해 메모리 세션 기반이 적합합니다.
권장 런타임 필드:
- `guildId`
- `userId`
- `threadId`
- `messageId`
- `fishPosition`
- `selectedAction`
- `phaseStartedAt`
- `distance`
- `lineTension`
- `tickInterval`
- `expiresAt`
## 입력 판정 규칙
권장 MVP 규칙은 아래와 같습니다.
- 각 라운드마다 물고기는 하나의 위치에 일정 시간 머뭅니다.
- 플레이어는 그 라운드에서 방향 입력을 미리 예약합니다.
- 물고기가 해당 위치에 `특정 시간 이상` 머문 시점에 입력이 일치하면
- 거리 감소
- 낚시줄 끊어짐 게이지 증가
- 성공 판정 후 다음 라운드로 이동
- 라운드 시간이 끝날 때까지 올바른 입력이 없으면
- `타이밍 빗나감`
- 거리 감소 없음
- 소량의 끊어짐 증가
- 휴식을 선택하면
- 낚시줄 끊어짐 게이지 감소
- 대신 거리 증가
- 휴식 적용 후 즉시 다음 라운드로 이동
이 구조는 다음 긴장감을 만듭니다.
- 방향을 잘 예약해 두면 안정적으로 거리 감소를 만들 수 있음
- 너무 공격적으로 당기면 줄이 끊어질 위험이 커짐
- 휴식은 실패를 막아주지만 물고기가 다시 멀어짐
## 단계별 계획
### Phase 1
- `fishing`을 미니게임 레지스트리에 등록
- `/fishing enter` 추가
- `/fishing cast` 추가
- `/fishing end` 추가
- `/fishing enter` 실행 시 `UserName's Fishing Spot` 스레드 생성
- `/fishing cast`는 전용 스레드 안에서만 허용
- 메모리 기반 낚시 세션 루프 구현
- 1초 주기 메시지 갱신
- 입력 예약형 라운드 판정 구현
- 두 개의 게이지 표시
- 네 개의 이모지 조작 버튼 추가
- 성공 시 `gold` 지급
- 성공/실패 후 스레드 유지
- `/fishing end` 실행 시 스레드 삭제
### Phase 2
- `/fishing status` 추가
- 사용자별 낚시 통계 추가
- 낚시 프로필 영속화
- 물고기 이동 패턴 개선
- 보상 밸런스 조정
### Phase 3
- 희귀도 체계 추가
- 인벤토리 / 도감 추가
- 미끼 / 낚싯대 보정치 추가
- 리더보드 지원
## 검증 / 테스트
### 수동 테스트 시나리오
1. 허용된 채널에서 `/fishing enter` 실행
2. `UserName's Fishing Spot` 스레드가 자동 생성되는지 확인
3. 자신의 낚시 스레드 밖에서는 `/fishing cast`가 거부되는지 확인
4. 낚시 관련 메시지가 스레드 안에서만 진행되는지 확인
5. 실시간 메시지에 물고기 이모지와 두 개의 게이지가 보이는지 확인
6. 방향 버튼을 미리 눌러 예약 입력이 반영되는지 확인
7. 일정 시간 뒤 방향이 맞았을 때 거리 감소가 적용되는지 확인
8. 타이밍을 놓치면 `타이밍 빗나감`으로 처리되는지 확인
9. 휴식을 눌렀을 때 끊어짐 게이지가 회복되고 거리가 증가하는지 확인
10. 거리 `0` 도달 시 성공 처리되는지 확인
11. 끊어짐 게이지 최대 도달 시 실패 처리되는지 확인
12. 세션 종료 후 버튼이 비활성화되고 스레드는 유지되는지 확인
13. 같은 스레드 안에서 다시 `/fishing cast`가 가능한지 확인
14. `/fishing end` 실행 시 스레드가 자동 삭제되는지 확인
15. 보상이 정상 지급되는지 확인
16. `/minigame` 설정 기반 채널 제한이 적용되는지 확인
### 엣지 케이스
- 같은 사용자가 중복으로 세션 시작
- 스레드 삭제 권한이 없을 때 종료 처리
- 세션 종료 후 오래된 버튼 클릭
- 낚시 세션 중 봇 재시작
- 인터랙션 응답 지연
- 1초 주기 메시지 갱신 시 Discord rate limit 영향
## 참고 사항
- 1초 갱신 루프는 이 게임의 손맛을 좌우하므로 메시지 갱신 성능을 꼭 확인해야 합니다.
- Discord 메시지는 게임 엔진이 아니므로, 순간 반응보다는 명확한 판정 윈도우가 더 중요합니다.
- 최종 설계는 화려함보다 반응성, 명확성, 실패 상태의 가독성을 우선해야 합니다.

View File

@ -0,0 +1,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

View File

@ -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`의 모든 서브커맨드 응답 및 데이터 반영을 확인했습니다.
---
**보고서 끝.**

View File

@ -0,0 +1,64 @@
# 2026-03-31 낚시 미니게임 Phase 1 완료
## 개요
낚시 미니게임의 Phase 1 범위를 완료했습니다. 이번 완료 단계에서는 전용 스레드 기반 플레이 흐름, 입력 예약형 판정, 물고기 데이터 기반 난이도/보상 시스템, 성공 시 아트 리소스 출력까지 포함해 실제 플레이 가능한 MVP를 마무리했습니다.
## 완료 범위
- `/fishing enter`, `/fishing cast`, `/fishing end` 명령 흐름 확정
- `UserName's Fishing Spot` 형식의 전용 스레드 생성 및 재사용
- 낚시 스레드 안에서만 `/fishing cast` 가능하도록 제한
- 사용자당 1개 활성 낚시 세션 유지
- `⬅️`, `⏺️`, `➡️`, `🛌` 이모지 버튼 조작
- 거리 게이지 / 끊어짐 게이지 렌더링
- 입력 예약형 판정 구조 적용
- 휴식 시 끊어짐 회복 + 거리 증가 규칙 반영
- 성공/실패 후 스레드 유지, `/fishing end`에서만 삭제되도록 흐름 개선
- `resource/data/fishing/fish_catalog.json` 기반 물고기 데이터 연결
- 물고기별 출현 확률, 골드 범위, 반응 시간, 위치 약점, 아트 경로 적용
- 성공 시 물고기 PNG 첨부 및 결과 메시지 전송
- 대상 물고기와 상세 판정 시간은 플레이 중 UI에서 비공개 처리
- 메시지 갱신 빈도 축소 및 판정 유예 시간 추가로 반응성 개선
## 주요 파일
- `src/commands/fishing.ts`
- `src/services/FishingService.ts`
- `src/events/interactionCreate.ts`
- `src/services/MiniGameRegistry.ts`
- `src/services/RefinementService.ts`
- `src/i18n/types.ts`
- `src/i18n/locales/en.ts`
- `src/i18n/locales/ko.ts`
- `resource/data/fishing/fish_catalog.json`
- `resource/art/fishing/*`
- `tests/services/FishingService.test.ts`
## 플레이 흐름
1. 사용자가 허용된 텍스트 채널에서 `/fishing enter`
2. 봇이 낚시 전용 스레드를 생성하거나 기존 스레드를 재사용
3. 사용자가 자신의 낚시 스레드 안에서 `/fishing cast`
4. 버튼 입력으로 거리와 끊어짐 게이지를 관리
5. 성공 시 물고기 결과 메시지와 PNG 출력, 골드 지급
6. 같은 스레드에서 다시 `/fishing cast`로 재도전 가능
7. `/fishing end`로 스레드 종료 및 삭제
## 밸런스 / UX 정리
- 라운드 길이와 반응 시간은 Discord 버튼 지연을 고려해 완화했습니다.
- 세션 틱은 더 촘촘하게 돌리되, 메시지 수정은 필요한 시점에만 수행하도록 조정했습니다.
- 반복 플레이 피로를 줄이기 위해 물고기별 위치 약점과 거리 감소량은 JSON 데이터 기반 랜덤 범위를 사용합니다.
## 검증
- `yarn build`
- `yarn test --runInBand`
두 명령 모두 정상 통과했습니다.
## 비고
- 낚시 세션은 메모리 기반이므로 봇 재시작 시 유지되지 않습니다.
- Phase 2에서는 통계 조회, 프로필 영속화, 도감/인벤토리 같은 확장 기능을 고려할 수 있습니다.

View File

@ -0,0 +1,44 @@
# 2026-03-31 낚시 미니게임 Phase 1 구현
## 개요
낚시 미니게임의 1차 구현을 추가했습니다. 이번 단계에서는 전용 스레드 기반 세션, 실시간 버튼 조작, 거리/끊어짐 게이지, 성공 시 골드 지급, 종료 시 스레드 정리까지 MVP 범위를 구현했습니다.
## 구현 내용
- `fishing` 미니게임을 공용 미니게임 레지스트리에 등록
- `/fishing cast` 명령 추가
- `/fishing end` 명령 추가
- 낚시 시작 시 `UserName's Fishing Spot` 형식의 전용 스레드 생성
- 사용자당 1개 세션만 허용하는 메모리 기반 세션 관리 추가
- `⬅️`, `⏺️`, `➡️`, `🛌` 버튼 조작 추가
- 1초 주기 상태 갱신 루프 추가
- 거리 게이지와 끊어짐 게이지 렌더링 추가
- 중앙 라인에 물고기 이모지 위치 표시 추가
- 방향 일치 시 거리 감소 및 끊어짐 게이지 증가 로직 구현
- 휴식 선택 시 끊어짐 게이지 회복 로직 구현
- 성공 시 `RefinementProfile.gold` 기준 골드 지급 연동
- 성공/실패/강제 종료 시 버튼 비활성화 및 스레드 자동 정리 추가
## 주요 파일
- `src/commands/fishing.ts`
- `src/services/FishingService.ts`
- `src/events/interactionCreate.ts`
- `src/services/MiniGameRegistry.ts`
- `src/services/RefinementService.ts`
- `src/i18n/types.ts`
- `src/i18n/locales/en.ts`
- `src/i18n/locales/ko.ts`
- `tests/services/FishingService.test.ts`
## 검증
- `tsc`
- `jest tests/services/FishingService.test.ts --runInBand`
## 비고
- Prisma Client는 최신 스키마 기준으로 다시 생성해 타입을 맞췄습니다.
- `ko.ts`에 존재하던 일부 깨진 문자열은 빌드를 막는 구간 위주로 우선 복구했습니다.
- 세션은 메모리 기반이므로 봇 재시작 시 진행 중 낚시 세션은 유지되지 않습니다.

View File

@ -1,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)
- [다국어 ì§€ì<C3AC> 개발 ê°€ì<E282AC>´ë“œë<C593>¼ì<C2BC>¸ (i18n Development Guidelines)](Rules/i18n_guidelines.md)
- [蹂댁븞 媛€?대뱶?쇱씤 (Security Rules)](Rules/security_guidelines.md)
- [?ㅺ뎅??吏€??媛쒕컻 媛€?대뱶?쇱씤 (i18n Development Guidelines)](Rules/i18n_guidelines.md)
## 기능 명세 (Features)
## 湲곕뒫 紐낆꽭 (Features)
- [임시 ì<>Œì„± 채ë„<C3AB> ìž<C3AC>ë<EFBFBD>™í™” (Temp Voice Channels)](Features/temp_voice_channels.md)
- [?꾩떆 ?뚯꽦 梨꾨꼸 ?먮룞??(Temp Voice Channels)](Features/temp_voice_channels.md)
## 기íš<EFBFBD>서 (Plans)
## 湲고쉷??(Plans)
- [기능 로드맵 (Feature Roadmap)](Plans/Feature_Roadmap.md)
- [임시 ì<>Œì„± 채ë„<C3AB> 기능 기íš<C3AD>서 (Temp Voice Channel Plan)](Plans/Temp_Voice_Channel_Plan.md)
- [ì—<EFBFBD>러 안내 기능 기íš<C3AD>서 (Error Guidance Plan)](Plans/Error_Guidance_Plan.md)
- [ë´‡ ìƒ<C3AC>태 메시지 기íš<C3AD> (Bot Presence Plan)](Plans/Bot_Presence_Plan.md)
- [ì—<EFBFBD>러 안내 기능 기íš<C3AD>서 (Error Guidance Plan)](Plans/Error_Guidance_Plan.md)
- [다국어 ì§€ì<C3AC> 기íš<C3AD>서 (i18n Plan)](Plans/i18n_Plan.md)
- [서버 ì<>´ë²¤íЏ ì<>¼ì • 관리 기능 기íš<C3AD>안 (Event Schedule Management Plan)](Plans/Event_Schedule_Management_Plan.md)
- [YouTube ì<>Œì•… 재ìƒ<C3AC> 기능 기íš<C3AD>안 (YouTube Music Playback Plan)](Plans/YouTube_Music_Playback_Plan.md)
- [?먮윭 ?덈궡 湲곕뒫 湲고쉷??(Error Guidance Plan)](Plans/Error_Guidance_Plan.md)
- [?ㅺ뎅??吏€??湲고쉷??(i18n Plan)](Plans/i18n_Plan.md)
- [?쒕쾭 ?대깽???쇱젙 愿€由?湲곕뒫 湲고쉷??(Event Schedule Management Plan)](Plans/Event_Schedule_Management_Plan.md)
- [YouTube ?뚯븙 ?ъ깮 湲곕뒫 湲고쉷??(YouTube Music Playback Plan)](Plans/YouTube_Music_Playback_Plan.md)
- [?щ젴 誘몃땲寃뚯엫 湲고쉷??(Refinement Mini-Game Plan)](Plans/MiniGame_Refinement_Plan.md)
## ì•„í¤í…<EFBFBD>처 ë°<C3AB> ì •ì±… ê²°ì • (Decisions)
## ?꾪궎?띿쿂 諛??뺤콉 寃곗젙 (Decisions)
- [구ë<EFBFBD>… í‹°ì–´ 시스템 설계 (Subscription Tiers)](Decisions/subscription_tiers.md)
- [援щ룆 ?곗뼱 ?쒖뒪???ㅺ퀎 (Subscription Tiers)](Decisions/subscription_tiers.md)
## 트러블슈팅 (Troubleshooting)
## ?몃윭釉붿뒋??(Troubleshooting)
- [Voice Channel Missing Permissions (50013) í•´ê²°ê±´](Troubleshooting/50013_Missing_Permissions.md)
- [Temp Voice 유령 채ë„<C3AB> 미삭제 버그 í•´ê²°ê±´](Troubleshooting/handleLeave_ghost_channel.md)
- [Voice Channel Missing Permissions (50013) ?닿껐嫄?(Troubleshooting/50013_Missing_Permissions.md)
- [Temp Voice ?좊졊 梨꾨꼸 誘몄궘??踰꾧렇 ?닿껐嫄?(Troubleshooting/handleLeave_ghost_channel.md)
## 진행/완료 내역 (Work Done)
## 吏꾪뻾/?꾨즺 ?댁뿭 (Work Done)
- [2026-03-27: ë´‡ ìƒ<C3AC>태 메시지 기능 구현 (Bot Presence Implementation)](WorkDone/2026-03-27_Presence_Implementation.md)
- [2026-03-27: 임시 ì<>Œì„± 채ë„<C3AB> 기능 구현 (Temp Voice Channels Implementation)](WorkDone/2026-03-27_Voice_Channels_Implementation.md)
- [2026-03-27: 임시 ì<>Œì„± 채ë„<C3AB> ê³ ë<C2A0>„í™” (Voice Channels Improvements)](WorkDone/2026-03-27_Voice_Channels_Improvements.md)
- [2026-03-27: 다국어 ì§€ì<C3AC> 구현 (i18n Implementation)](WorkDone/2026-03-27_i18n_Implementation.md)
- [2026-03-27: i18n 테스트 코드 검사 ë<>„구 구현 (i18n Check Tool Implementation)](WorkDone/2026-03-27_i18n_Check_Tool_Implementation.md)
- [2026-03-27: /config 명령어 ë°<C3AB> 기능 관리 리팩토ë§<C3AB> (Config & Feature Refactoring)](WorkDone/2026-03-27_Config_And_Feature_Refactoring.md)
- [2026-03-27: ê°<C3AA>ì¬ ì±„ë„<C3AB> 구현 (Audit Log Channel Implementation)](WorkDone/2026-03-27_Audit_Log_Channel_Implementation.md)
- [2026-03-27: 권한 진단 기능 구현 (Permission Audit Implementation)](WorkDone/2026-03-27_Permission_Audit_Implementation.md)
- [2026-03-27: ì—<C3AC>러 안내 UX 개선 ë°<C3AB> 통합 (Error Guidance UX Implementation)](WorkDone/2026-03-27_Error_Guidance_UX_Implementation.md)
- [2026-03-27: Kord 프로ì <C3AC>트 초기 설정 (Project Initial Setup)](WorkDone/2026-03-27_Project_Initial_Setup.md)
- [2026-03-30: 서버 ì<>´ë²¤íЏ ì<>¼ì • 관리 Phase 1 구현 (Event Schedule Phase 1 Implementation)](WorkDone/2026-03-30_Event_Schedule_Phase1_Implementation.md)
- [2026-03-30: 서버 ì<>´ë²¤íЏ ì<>¼ì • 관리 Phase 2 구현 (Event Schedule Phase 2 Implementation)](WorkDone/2026-03-30_Event_Schedule_Phase2_Implementation.md)
- [2026-03-30: 서버 ì<>´ë²¤íЏ 시작 ìœì <C3AC> 공지 구현 (Event Schedule Start Announcement Implementation)](WorkDone/2026-03-30_Event_Schedule_Start_Announcement_Implementation.md)
- [2026-03-30: ì<>´ë²¤íЏ 리마ì<CB86>¸ë<C2B8>” ë¶„ 단위 옵션 구현 (Event Reminder Offsets Implementation)](WorkDone/2026-03-30_Event_Reminder_Offsets_Implementation.md)
- [2026-03-30: 명령어 계층 구조 리팩토ë§<C3AB> (Hierarchical Command Refactoring)](WorkDone/2026-03-30_HierarchicalRefactor.md)
- [2026-03-30: YouTube À½¾Ç Àç»ý Phase 1 ±¸Çö (YouTube Music Playback Phase 1 Implementation)](WorkDone/2026-03-30_YouTube_Music_Playback_Phase1_Implementation.md)
- [2026-03-31: YouTube À½¾Ç Àç»ý Phase 2 ±¸Çö (YouTube Music Playback Phase 2 Implementation)](WorkDone/2026-03-31_YouTube_Music_Playback_Phase2_Implementation.md)
- [2026-03-31: YouTube À½¾Ç Àç»ý Phase 3 ±¸Çö (YouTube Music Playback Phase 3 Implementation)](WorkDone/2026-03-31_YouTube_Music_Playback_Phase3_Implementation.md)
- [2026-03-27: 遊??곹깭 硫붿떆吏€ 湲곕뒫 援ы쁽 (Bot Presence Implementation)](WorkDone/2026-03-27_Presence_Implementation.md)
- [2026-03-27: ?꾩떆 ?뚯꽦 梨꾨꼸 湲곕뒫 援ы쁽 (Temp Voice Channels Implementation)](WorkDone/2026-03-27_Voice_Channels_Implementation.md)
- [2026-03-27: ?꾩떆 ?뚯꽦 梨꾨꼸 怨좊룄??(Voice Channels Improvements)](WorkDone/2026-03-27_Voice_Channels_Improvements.md)
- [2026-03-27: ?ㅺ뎅??吏€??援ы쁽 (i18n Implementation)](WorkDone/2026-03-27_i18n_Implementation.md)
- [2026-03-27: i18n ?뚯뒪??肄붾뱶 寃€???꾧뎄 援ы쁽 (i18n Check Tool Implementation)](WorkDone/2026-03-27_i18n_Check_Tool_Implementation.md)
- [2026-03-27: /config 紐낅졊??諛?湲곕뒫 愿€由?由ы뙥?좊쭅 (Config & Feature Refactoring)](WorkDone/2026-03-27_Config_And_Feature_Refactoring.md)
- [2026-03-27: 媛먯궗 梨꾨꼸 援ы쁽 (Audit Log Channel Implementation)](WorkDone/2026-03-27_Audit_Log_Channel_Implementation.md)
- [2026-03-27: 沅뚰븳 吏꾨떒 湲곕뒫 援ы쁽 (Permission Audit Implementation)](WorkDone/2026-03-27_Permission_Audit_Implementation.md)
- [2026-03-27: ?먮윭 ?덈궡 UX 媛쒖꽑 諛??듯빀 (Error Guidance UX Implementation)](WorkDone/2026-03-27_Error_Guidance_UX_Implementation.md)
- [2026-03-27: Kord ?꾨줈?앺듃 珥덇린 ?ㅼ젙 (Project Initial Setup)](WorkDone/2026-03-27_Project_Initial_Setup.md)
- [2026-03-30: ?쒕쾭 ?대깽???쇱젙 愿€由?Phase 1 援ы쁽 (Event Schedule Phase 1 Implementation)](WorkDone/2026-03-30_Event_Schedule_Phase1_Implementation.md)
- [2026-03-30: ?쒕쾭 ?대깽???쇱젙 愿€由?Phase 2 援ы쁽 (Event Schedule Phase 2 Implementation)](WorkDone/2026-03-30_Event_Schedule_Phase2_Implementation.md)
- [2026-03-30: ?쒕쾭 ?대깽???쒖옉 ?쒖젏 怨듭? 援ы쁽 (Event Schedule Start Announcement Implementation)](WorkDone/2026-03-30_Event_Schedule_Start_Announcement_Implementation.md)
- [2026-03-30: ?대깽??由щ쭏?몃뜑 遺??⑥쐞 ?듭뀡 援ы쁽 (Event Reminder Offsets Implementation)](WorkDone/2026-03-30_Event_Reminder_Offsets_Implementation.md)
- [2026-03-30: 紐낅졊??怨꾩링 援ъ“ 由ы뙥?좊쭅 (Hierarchical Command Refactoring)](WorkDone/2026-03-30_HierarchicalRefactor.md)
- [2026-03-30: YouTube 음악 재생 Phase 1 구현 (YouTube Music Playback Phase 1 Implementation)](WorkDone/2026-03-30_YouTube_Music_Playback_Phase1_Implementation.md)
- [2026-03-31: YouTube 음악 재생 Phase 2 구현 (YouTube Music Playback Phase 2 Implementation)](WorkDone/2026-03-31_YouTube_Music_Playback_Phase2_Implementation.md)
- [2026-03-31: YouTube 음악 재생 Phase 3 구현 (YouTube Music Playback Phase 3 Implementation)](WorkDone/2026-03-31_YouTube_Music_Playback_Phase3_Implementation.md)
- [2026-03-30: 誘몃땲寃뚯엫 ?쒖뒪??諛??щ젴 寃뚯엫 援ы쁽 (Mini-Game System & Refinement Implementation)](WorkDone/2026-03-30_RefinementImplementation.md)
- [낚시 미니게임 기획안 (Fishing Mini-Game Plan)](Plans/Fishing_MiniGame_Plan.md)
- [2026-03-31: 낚시 미니게임 Phase 1 구현 (Fishing Mini-Game Phase 1 Implementation)](WorkDone/2026-03-31_Fishing_MiniGame_Phase1_Implementation.md)
- [2026-03-31: 낚시 미니게임 Phase 1 완료 (Fishing Mini-Game Phase 1 Completion)](WorkDone/2026-03-31_Fishing_MiniGame_Phase1_Completion.md)
- [낚시 미니게임 기획안 (Fishing Mini-Game Plan)](Plans/Fishing_MiniGame_Plan.md)

View File

@ -68,4 +68,7 @@ Kord는 Discord 서버 관리를 돕는 강력하고 유연한 다기능 봇입
- **서버 설정 (Config)**: 따라하기(Mimic), 큰 이모지(Big Emoji) 등 봇의 기능을 서버별 환경에 맞게 토글하거나 설정할 수 있습니다.
- **권한 감사 (Permission Audit)**: 봇의 정상 작동을 방해하는 권한 문제를 즉시 진단하고 해결 방법을 안내합니다.
- **초기 설정 마법사 (Setup Wizard)**: 봇 도입 초기 기능을 한눈에 설정할 수 있는 직관적인 UI를 제공합니다.
- **미니게임 시스템 (Mini-Games)**: 서버 내에서 즐길 수 있는 다양한 미니게임을 제공하며, 관리자가 게임별 활성화 및 전용 채널을 설정할 수 있습니다.
- **재련 (Refinement)**: 무기를 강화하고 다른 유저와 전투하며 골드를 획득하는 성장형 미니게임입니다.
- **피버 타임 (Fever Time)**: 서버 활동량을 자동으로 분석하여 가장 활발한 시간대에 재련 성공 확률 보너스를 제공합니다.
- **다국어 지원 (i18n)**: 한국어와 영어를 포함한 다국어 환경을 지원합니다.

View File

@ -4,11 +4,14 @@
"dependencies": {
"@discordjs/opus": "^0.10.0",
"@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",
"dotenv": "^17.3.1",
"ffmpeg-static": "^5.3.0",
"ioredis": "^5.10.1",
"pg": "^8.20.0",
"prism-media": "^1.3.5",
"youtubei.js": "^17.0.1"
},
@ -20,7 +23,7 @@
"eslint": "^10.1.0",
"jest": "^30.3.0",
"prettier": "^3.8.1",
"prisma": "6.4.1",
"prisma": "7.6.0",
"ts-jest": "^29.4.6",
"tsx": "^4.21.0",
"typescript": "^6.0.2"
@ -31,5 +34,8 @@
"start": "node dist/index.js",
"test": "jest",
"check-i18n": "tsx scripts/check-i18n-tests.ts"
},
"prisma": {
"seed": "tsx prisma/seed.ts"
}
}

0
package_feature.json Normal file
View File

0
package_main.json Normal file
View File

13
prisma.config.ts Normal file
View File

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

View File

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

View File

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

View File

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

View File

@ -4,7 +4,6 @@ generator client {
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model GuildConfig {
@ -140,3 +139,93 @@ enum EventStatus {
CANCELLED
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
}

116
prisma/seed.ts Normal file
View File

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

View File

@ -0,0 +1 @@

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

View File

@ -0,0 +1,277 @@
{
"version": 1,
"description": "Fishing mini-game fish catalog and balance data.",
"positionKeys": ["left", "center", "right"],
"fish": [
{
"id": "clownfish",
"displayName": "Clownfish",
"spawnRate": 20,
"rewardGold": {
"min": 120,
"max": 180
},
"reactionWindowSec": 2.0,
"distanceReductionByPosition": {
"left": {
"min": 15,
"max": 21
},
"center": {
"min": 10,
"max": 14
},
"right": {
"min": 8,
"max": 12
}
},
"artResourcePaths": [
"resource/art/fishing/01_clownfish.png"
]
},
{
"id": "bluefish",
"displayName": "Bluefish",
"spawnRate": 17,
"rewardGold": {
"min": 150,
"max": 220
},
"reactionWindowSec": 1.8,
"distanceReductionByPosition": {
"left": {
"min": 8,
"max": 12
},
"center": {
"min": 15,
"max": 21
},
"right": {
"min": 10,
"max": 14
}
},
"artResourcePaths": [
"resource/art/fishing/02_bluefish.png"
]
},
{
"id": "pufferfish",
"displayName": "Pufferfish",
"spawnRate": 13,
"rewardGold": {
"min": 180,
"max": 260
},
"reactionWindowSec": 1.6,
"distanceReductionByPosition": {
"left": {
"min": 9,
"max": 13
},
"center": {
"min": 10,
"max": 14
},
"right": {
"min": 17,
"max": 23
}
},
"artResourcePaths": [
"resource/art/fishing/03_pufferfish.png"
]
},
{
"id": "greenfish",
"displayName": "Greenfish",
"spawnRate": 12,
"rewardGold": {
"min": 200,
"max": 300
},
"reactionWindowSec": 1.5,
"distanceReductionByPosition": {
"left": {
"min": 17,
"max": 23
},
"center": {
"min": 9,
"max": 13
},
"right": {
"min": 11,
"max": 15
}
},
"artResourcePaths": [
"resource/art/fishing/04_greenfish.png"
]
},
{
"id": "octopus",
"displayName": "Octopus",
"spawnRate": 10,
"rewardGold": {
"min": 230,
"max": 340
},
"reactionWindowSec": 1.4,
"distanceReductionByPosition": {
"left": {
"min": 10,
"max": 14
},
"center": {
"min": 19,
"max": 25
},
"right": {
"min": 12,
"max": 16
}
},
"artResourcePaths": [
"resource/art/fishing/05_octopus.png"
]
},
{
"id": "starfish",
"displayName": "Starfish",
"spawnRate": 9,
"rewardGold": {
"min": 260,
"max": 380
},
"reactionWindowSec": 1.3,
"distanceReductionByPosition": {
"left": {
"min": 12,
"max": 16
},
"center": {
"min": 11,
"max": 15
},
"right": {
"min": 19,
"max": 25
}
},
"artResourcePaths": [
"resource/art/fishing/06_starfish.png"
]
},
{
"id": "jellyfish",
"displayName": "Jellyfish",
"spawnRate": 7,
"rewardGold": {
"min": 320,
"max": 460
},
"reactionWindowSec": 1.25,
"distanceReductionByPosition": {
"left": {
"min": 20,
"max": 26
},
"center": {
"min": 12,
"max": 16
},
"right": {
"min": 13,
"max": 17
}
},
"artResourcePaths": [
"resource/art/fishing/07_jellyfish.png"
]
},
{
"id": "crab",
"displayName": "Crab",
"spawnRate": 6,
"rewardGold": {
"min": 380,
"max": 540
},
"reactionWindowSec": 1.2,
"distanceReductionByPosition": {
"left": {
"min": 13,
"max": 17
},
"center": {
"min": 21,
"max": 27
},
"right": {
"min": 14,
"max": 18
}
},
"artResourcePaths": [
"resource/art/fishing/08_crab.png"
]
},
{
"id": "swordfish",
"displayName": "Swordfish",
"spawnRate": 4,
"rewardGold": {
"min": 520,
"max": 720
},
"reactionWindowSec": 1.2,
"distanceReductionByPosition": {
"left": {
"min": 14,
"max": 18
},
"center": {
"min": 15,
"max": 19
},
"right": {
"min": 24,
"max": 32
}
},
"artResourcePaths": [
"resource/art/fishing/09_swordfish.png"
]
},
{
"id": "shark",
"displayName": "Shark",
"spawnRate": 2,
"rewardGold": {
"min": 800,
"max": 1200
},
"reactionWindowSec": 1.2,
"distanceReductionByPosition": {
"left": {
"min": 26,
"max": 34
},
"center": {
"min": 17,
"max": 23
},
"right": {
"min": 15,
"max": 21
}
},
"artResourcePaths": [
"resource/art/fishing/10_shark.png"
]
}
]
}

0
schema_base.prisma Normal file
View File

0
schema_feature.prisma Normal file
View File

0
schema_main.prisma Normal file
View File

View File

@ -6,6 +6,7 @@ import { loadEvents } from '../handlers/EventLoader';
import { handleGlobalExceptions } from '../utils/errorHandler';
import { connectDB } from '../database';
import { connectRedis } from '../cache';
import { FeverService } from '../services/FeverService';
export class KordClient extends Client {
public commands: Collection<string, any> = new Collection();

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

@ -0,0 +1,127 @@
import {
ChannelType,
ChatInputCommandInteraction,
SlashCommandBuilder,
} from 'discord.js';
import { prisma } from '../database';
import { SupportedLocale, t } from '../i18n';
import { FishingService } from '../services/FishingService';
export default {
data: new SlashCommandBuilder()
.setName('fishing')
.setDescription('Play the fishing mini-game.')
.setDescriptionLocalizations({
ko: '낚시 미니게임을 플레이합니다.',
})
.addSubcommand((subcommand) =>
subcommand
.setName('enter')
.setDescription('Create or reopen your fishing thread.')
.setDescriptionLocalizations({
ko: '낚시 전용 스레드를 생성하거나 다시 엽니다.',
}),
)
.addSubcommand((subcommand) =>
subcommand
.setName('cast')
.setDescription('Start a fishing session inside your fishing thread.')
.setDescriptionLocalizations({
ko: '자신의 낚시 스레드 안에서 낚시 세션을 시작합니다.',
}),
)
.addSubcommand((subcommand) =>
subcommand
.setName('end')
.setDescription('End your active fishing session and delete the thread.')
.setDescriptionLocalizations({
ko: '진행 중인 낚시 세션을 종료하고 스레드를 삭제합니다.',
}),
),
async execute(interaction: ChatInputCommandInteraction, locale: SupportedLocale) {
if (!interaction.guildId) return;
const config = await prisma.miniGameConfig.findUnique({
where: { guildId_gameKey: { guildId: interaction.guildId, gameKey: 'fishing' } },
});
const subcommand = interaction.options.getSubcommand();
if (subcommand === 'enter') {
await interaction.deferReply({ ephemeral: true });
if (!config || !config.enabled) {
await interaction.editReply({
content: t(locale, 'commands.fishing.disabled'),
});
return;
}
if (config.channelId && config.channelId !== interaction.channelId) {
await interaction.editReply({
content: t(locale, 'commands.fishing.restrictedChannel', {
channel: `<#${config.channelId}>`,
}),
});
return;
}
if (!interaction.channel || interaction.channel.type !== ChannelType.GuildText) {
await interaction.editReply({
content: t(locale, 'commands.fishing.enterTextChannelOnly'),
});
return;
}
const result = await FishingService.enterThread(interaction);
await interaction.editReply({
content: result.existed
? t(locale, 'commands.fishing.enterExistingThread', { thread: `<#${result.thread.id}>` })
: t(locale, 'commands.fishing.enterCreated', { thread: `<#${result.thread.id}>` }),
});
return;
}
if (subcommand === 'cast') {
await interaction.deferReply({ ephemeral: true });
if (!config || !config.enabled) {
await interaction.editReply({
content: t(locale, 'commands.fishing.disabled'),
});
return;
}
if (!FishingService.isOwnedFishingThread(interaction.channel, interaction.user.username)) {
await interaction.editReply({
content: t(locale, 'commands.fishing.castThreadOnly'),
});
return;
}
const result = await FishingService.startSessionInThread(interaction, locale);
await interaction.editReply({
content: result.existed
? t(locale, 'commands.fishing.startExistingSession', { thread: `<#${result.thread.id}>` })
: t(locale, 'commands.fishing.startCreated', { thread: `<#${result.thread.id}>` }),
});
return;
}
if (subcommand === 'end') {
await interaction.deferReply({ ephemeral: true });
const ended = await FishingService.endThreadByUser(interaction, locale);
if (!ended) {
await interaction.editReply({
content: t(locale, 'commands.fishing.noActiveSession'),
});
return;
}
await interaction.editReply({
content: t(locale, 'commands.fishing.endDeleted'),
});
}
},
};

133
src/commands/minigame.ts Normal file
View File

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

277
src/commands/refine.ts Normal file
View File

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

View File

@ -1,14 +1,24 @@
import { Pool } from 'pg';
import { PrismaPg } from '@prisma/adapter-pg';
import { PrismaClient } from '@prisma/client';
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({
adapter,
log: ['warn', 'error'],
});
export const connectDB = async () => {
try {
await prisma.$connect();
logger.info('Connected to PostgreSQL successfully.');
// Adapter-based client connects when first used,
// 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) {
logger.error('Failed to connect to PostgreSQL:', error);
process.exit(1);

View File

@ -29,12 +29,26 @@ export default {
await MusicService.handleControlInteraction(interaction, locale);
}, locale);
}
else if (interaction.isButton() && interaction.customId.startsWith('fishing_')) {
const { FishingService } = require('../services/FishingService');
const locale = await getInteractionLocale(interaction);
await withErrorHandler(interaction, async () => {
await FishingService.handleButton(interaction, locale);
}, locale);
}
else if (interaction.isMessageComponent() && interaction.customId.startsWith('setup_')) {
const locale = await getInteractionLocale(interaction);
await withErrorHandler(interaction, async () => {
await handleSetupWizardInteraction(interaction, 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()) {
const customId = interaction.customId;

View File

@ -2,6 +2,7 @@ import { Events, Message } from 'discord.js';
import { MimicService } from '../services/MimicService';
import { BigEmojiService } from '../services/BigEmojiService';
import { prisma } from '../database';
import { ActivityTrackerService } from '../services/ActivityTrackerService';
export default {
name: Events.MessageCreate,
@ -9,6 +10,9 @@ export default {
async execute(message: Message) {
if (!message.guildId || message.author.bot) return;
// 활동 추적 기록
await ActivityTrackerService.recordActivity(message.guildId);
const config = await prisma.guildConfig.findUnique({
where: { guildId: message.guildId }
});

View File

@ -1,11 +1,11 @@
import { TranslationSchema } from '../types';
import { TranslationSchema } from '../types';
/**
* English translations the DEFAULT and FALLBACK locale.
* English translations ??the DEFAULT and FALLBACK locale.
* All keys MUST be present here. Other locales can omit keys to fallback to English.
*/
export const en: TranslationSchema = {
// ── Error Messages ──────────────────────────────────────
// ?€?€ Error Messages ?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€
errors: {
E1001: {
userMessage: 'The user limit value is invalid.',
@ -71,7 +71,7 @@ export const en: TranslationSchema = {
},
},
// ── Error Category Titles ───────────────────────────────
// ?€?€ Error Category Titles ?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€
errorTitles: {
USER_INPUT: 'Please check your input',
PERMISSION: 'Insufficient permissions',
@ -79,17 +79,17 @@ export const en: TranslationSchema = {
DISCORD_API: 'Temporary issue',
},
// ── Error Embed Field Labels ────────────────────────────
// ?€?€ Error Embed Field Labels ?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€
errorFields: {
resolution: '💡 How to resolve',
},
// ── Voice Channel ───────────────────────────────────────
// ?€?€ Voice Channel ?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€
voice: {
channelReady: '{{owner}}, your temporary channel is ready! Use the dropdown menu below to manage it.',
defaultRoomName: "{{username}}'s Room",
controlPanel: {
placeholder: '⚙️ Manage Channel Settings',
placeholder: '?숋툘 Manage Channel Settings',
rename: 'Rename Channel',
limit: 'Set User Limit',
lock: 'Lock / Unlock',
@ -111,7 +111,7 @@ export const en: TranslationSchema = {
},
},
// ── Commands ────────────────────────────────────────────
// ?€?€ Commands ?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€
commands: {
voiceSetup: {
description: 'Setup a generator voice channel for temporary channels.',
@ -251,11 +251,47 @@ export const en: TranslationSchema = {
statusPaused: 'Paused',
unknownDuration: 'Unknown',
buttons: {
pause: '⏸ Pause',
resume: '▶ Resume',
skip: '⏭ Skip',
stop: '⏹ Stop',
leave: '👋 Leave',
pause: '??Pause',
resume: '??Resume',
skip: '??Skip',
stop: '??Stop',
leave: '?몝 Leave',
},
},
fishing: {
description: 'Play the fishing mini-game.',
enterDescription: 'Create or reopen your fishing thread.',
castDescription: 'Start a fishing session inside your fishing thread.',
endDescription: 'End your fishing thread and delete it.',
disabled: 'The fishing mini-game is disabled in this server.',
restrictedChannel: 'Fishing can only be started in {{channel}}.',
enterTextChannelOnly: 'Fishing threads can only be opened from a regular text channel.',
enterExistingThread: 'Your fishing thread is already available in {{thread}}.',
enterCreated: 'Your fishing thread has been created in {{thread}}.',
castThreadOnly: 'You can only use /fishing cast inside your own fishing thread.',
startExistingSession: 'You already have an active fishing session in {{thread}}.',
startCreated: 'Your fishing session has started in {{thread}}.',
noActiveSession: 'There is no fishing session or thread to close.',
ownerOnly: 'Only the owner of this fishing session can use these controls.',
wrongThread: 'This fishing control can only be used inside your fishing thread.',
endDeleted: 'Your fishing thread has been closed and is being deleted.',
titleActive: 'Fishing Session',
titleEnded: 'Fishing Session Ended',
status: 'Status',
targetFish: 'Target Fish',
distance: 'Distance',
tension: 'Line Tension',
reward: 'Reward',
threadHint: 'Use /fishing cast to play again, or /fishing end to delete the thread.',
catchResultTitle: 'Big Catch!',
catchResultBody: 'You caught **{{fish}}** and earned **{{reward}} G**.',
states: {
hooked: 'Hooked',
resting: 'Resting',
tense: 'Pulling',
missed: 'Missed',
success: 'Caught',
failed: 'Line Snapped',
},
},
permissionAudit: {
@ -263,8 +299,8 @@ export const en: TranslationSchema = {
channel: 'Channel',
noResults: 'No features to audit. The bot may not be configured yet.',
summaryLabel: 'Summary',
summaryOk: 'All checks passed. No issues found.',
summaryIssue: '❌ {{fail}} failure(s) · ⚠️ {{warn}} warning(s) detected.',
summaryOk: '??All checks passed. No issues found.',
summaryIssue: '??{{fail}} failure(s) 쨌 ?좑툘 {{warn}} warning(s) detected.',
hierarchyWarning: "Bot role (pos: {{botPos}}) must be above '{{role}}' (pos: {{targetPos}}) to manage it.",
features: {
BASIC: 'Basic Bot Functionality',
@ -279,53 +315,53 @@ export const en: TranslationSchema = {
setup: {
description: 'Run the setup wizard to configure the bot step by step.',
step0: {
title: 'Bot Setup Wizard',
desc: 'Welcome! This wizard will help you configure the following 4 features:\n\n1️⃣ **Language Settings**\n2⃣ **Permission Check**\n3⃣ **Audit Channel Setup**\n4 **Temporary Voice Channel Setup**',
title: '??Bot Setup Wizard',
desc: 'Welcome! This wizard will help you configure the following 4 features:\n\n1截뤴깵 **Language Settings**\n2截뤴깵 **Permission Check**\n3截뤴깵 **Audit Channel Setup**\n4截뤴깵 **Temporary Voice Channel Setup**',
startBtn: 'Start Setup'
},
step1: {
title: '1️⃣ Language Settings',
title: '1截뤴깵 Language Settings',
desc: 'Set the default language for the bot in this server. (Current: **{{locale}}**)',
placeholder: 'Select a language',
nextBtn: 'Next Step',
skipBtn: 'Skip'
},
step2: {
title: '2️⃣ Permission Check',
descOk: '**All required permissions are granted.**',
descFail: '⚠️ **Some permissions are missing.**\nPlease check the results and grant the necessary permissions to the bot role.',
title: '2截뤴깵 Permission Check',
descOk: '??**All required permissions are granted.**',
descFail: '?좑툘 **Some permissions are missing.**\nPlease check the results and grant the necessary permissions to the bot role.',
recheckBtn: 'Re-check',
nextBtn: 'Next Step'
},
step3: {
title: '3️⃣ Audit Channel Setup',
title: '3截뤴깵 Audit Channel Setup',
desc: 'Select a channel to receive bot events and error logs.',
placeholder: 'Select Audit Channel',
disableBtn: 'Disable Audit Logs',
nextBtn: 'Next Step'
},
step4: {
title: '3-1️⃣ Audit Log Categories',
title: '3-1截뤴깵 Audit Log Categories',
desc: 'Select which log categories to receive. **Green** buttons are enabled, **Red** buttons are disabled.',
nextBtn: 'Next Step',
},
step5: {
title: '4️⃣ Temporary Voice Channel Setup',
title: '4截뤴깵 Temporary Voice Channel Setup',
desc: 'Select the "Generator Channel" for temporary voice channels.\nYou can choose an existing channel or have the bot **auto-create** a new category and channel.',
placeholder: 'Select Generator Channel',
autoBtn: '🚀 Auto Create',
autoBtn: '?? Auto Create',
skipBtn: 'Disable Temp Voice',
nextBtn: 'Finish Setup'
},
step6: {
title: '🎉 Setup Summary',
title: '?럦 Setup Summary',
desc: '**1. Language**: {{lang}}\n**2. Audit Channel**: {{audit}}\n**3. Audit Categories**: {{categories}}\n**4. Temp Voice**: {{voice}}',
finishBtn: 'Done'
},
finished: 'The setup wizard has been finished.',
expired: 'The session has expired. Please run `/setup` again.',
finished: '??The setup wizard has been finished.',
expired: '??The session has expired. Please run `/setup` again.',
defaultCategoryName: 'Voice Channels',
defaultGeneratorName: ' Create Channel',
defaultGeneratorName: '??Create Channel',
auditCategories: {
SYSTEM: 'System',
BOOT: 'Boot',
@ -350,7 +386,7 @@ export const en: TranslationSchema = {
},
},
// ── Modals ──────────────────────────────────────────────
// ?€?€ Modals ?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€
modals: {
renameTitle: 'Rename Voice Channel',
renameLabel: 'New Channel Name',
@ -358,14 +394,14 @@ export const en: TranslationSchema = {
limitLabel: 'User Limit (0 for unlimited, 1-99)',
},
// ── Select Menu Placeholders ────────────────────────────
// ?€?€ Select Menu Placeholders ?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€
selects: {
kickUser: 'Select a user to kick',
banUser: 'Select a user to ban/hide',
transferOwner: 'Select a user to transfer ownership to',
},
// ── Presence (Bot Status) ──
// ?€?€ Presence (Bot Status) ?€?€
presence: {
servers: 'Monitoring {{guildCount}} servers',
help: 'Check out the /help command',
@ -373,3 +409,6 @@ export const en: TranslationSchema = {
version: 'Kord v1.0.0',
},
};

View File

@ -1,194 +1,194 @@
import { TranslationSchema } from '../types';
import { TranslationSchema } from '../types';
/**
* .
* en.ts와 1:1 .
* ??? ?.
* ?? en.ts?€ 1:1 ?€?? ???
*/
export const ko: TranslationSchema = {
// ── 에러 메시지 ─────────────────────────────────────────
// ?€?€ ?먮윭 硫붿떆吏€ ?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€
errors: {
E1001: {
userMessage: '사용자 제한 값이 올바르지 않습니다.',
resolution: '0에서 99 사이의 숫자를 입력해주세요. (0 = 무제한)',
resolution: '0?먯꽌 99 ?ъ씠???レ옄瑜??낅젰?댁<?몄슂. (0 = 臾댁젣??',
},
E1002: {
userMessage: '채널 이름 형식이 올바르지 않습니다.',
resolution: '유효한 채널 이름을 입력해주세요. (최대 100자)',
userMessage: '梨꾨꼸 ?대쫫 ?뺤떇???щ컮瑜댁? ?딆뒿?덈떎.',
resolution: '?좏슚??梨꾨꼸 ?대쫫???낅젰?댁<?몄슂. (理쒕? 100??',
},
E1003: {
userMessage: '자기 자신에게는 이 작업을 수행할 수 없습니다.',
userMessage: '?먭린 ?먯떊?먭쾶?????묒뾽???섑뻾?????놁뒿?덈떎.',
},
E1004: {
userMessage: '선택한 유저가 음성 채널에 없습니다.',
resolution: '작업을 수행하기 전에 해당 유저가 채널에 있는지 확인해주세요.',
userMessage: '?좏깮???좎?媛€ ?뚯꽦 梨꾨꼸???놁뒿?덈떎.',
resolution: '?묒뾽???섑뻾?섍린 ?꾩뿉 ?대떦 ?좎?媛€ 梨꾨꼸???덈뒗吏€ ?뺤씤?댁<?몄슂.',
},
E2001: {
userMessage: '봇에 채널을 관리할 권한이 부족합니다.',
resolution: '서버 관리자에게 봇의 "채널 관리" 권한을 확인해달라고 요청하세요.',
userMessage: '遊뉗뿉 梨꾨꼸??愿€由ы븷 沅뚰븳??遺€議깊빀?덈떎.',
resolution: '?쒕쾭 愿€由ъ옄?먭쾶 遊뉗쓽 "梨꾨꼸 愿€由? 沅뚰븳???뺤씤?대떖?쇨퀬 ?붿껌?섏꽭??',
},
E2002: {
userMessage: '봇에 음성 채널 관련 권한이 부족합니다.',
resolution: '서버 관리자에게 봇의 "채널 관리", "역할 관리", "멤버 이동" 권한을 확인해달라고 요청하세요.',
userMessage: '遊뉗뿉 ?뚯꽦 梨꾨꼸 愿€??沅뚰븳??遺€議깊빀?덈떎.',
resolution: '?쒕쾭 愿€由ъ옄?먭쾶 遊뉗쓽 "梨꾨꼸 愿€由?, "??븷 愿€由?, "硫ㅻ쾭 ?대룞" 沅뚰븳???뺤씤?대떖?쇨퀬 ?붿껌?섏꽭??',
},
E2003: {
userMessage: '이 명령어를 사용할 권한이 없습니다.',
resolution: '이 명령어는 관리자 권한이 필요합니다.',
userMessage: '??紐낅졊?대? ?ъ슜??沅뚰븳???놁뒿?덈떎.',
resolution: '??紐낅졊?대뒗 愿€由ъ옄 沅뚰븳???꾩슂?⑸땲??',
},
E2004: {
userMessage: '채널 소유자만 이 기능을 사용할 수 있습니다.',
userMessage: '梨꾨꼸 ?뚯쑀?먮쭔 ??湲곕뒫???ъ슜?????덉뒿?덈떎.',
},
E2005: {
userMessage: '활성된 임시 음성 채널에 참여 중이어야 사용할 수 있습니다.',
resolution: '임시 음성 채널에 참여한 뒤 다시 시도해주세요.',
userMessage: '?쒖꽦???꾩떆 ?뚯꽦 梨꾨꼸??李몄뿬 以묒씠?댁빞 ?ъ슜?????덉뒿?덈떎.',
resolution: '?꾩떆 ?뚯꽦 梨꾨꼸??李몄뿬?????ㅼ떆 ?쒕룄?댁<?몄슂.',
},
E3001: {
userMessage: '요청을 처리하는 중 내부 오류가 발생했습니다.',
resolution: '잠시 후 다시 시도해주세요. 문제가 지속되면 봇 관리자에게 문의하세요.',
userMessage: '?붿껌??泥섎━?섎뒗 以??대? ?ㅻ쪟媛€ 諛쒖깮?덉뒿?덈떎.',
resolution: '?좎떆 ???ㅼ떆 ?쒕룄?댁<?몄슂. 臾몄젣媛€ 吏€?띾릺硫?遊?愿€由ъ옄?먭쾶 臾몄쓽?섏꽭??',
},
E3002: {
userMessage: '요청을 처리하는 중 내부 오류가 발생했습니다.',
resolution: '잠시 후 다시 시도해주세요.',
userMessage: '?붿껌??泥섎━?섎뒗 以??대? ?ㅻ쪟媛€ 諛쒖깮?덉뒿?덈떎.',
resolution: '?좎떆 ???ㅼ떆 ?쒕룄?댁<?몄슂.',
},
E3003: {
userMessage: '명령어를 실행하는 중 오류가 발생했습니다.',
resolution: '다시 시도해주세요. 문제가 지속되면 봇 관리자에게 문의하세요.',
userMessage: '紐낅졊?대? ?ㅽ뻾?섎뒗 以??ㅻ쪟媛€ 諛쒖깮?덉뒿?덈떎.',
resolution: '?ㅼ떆 ?쒕룄?댁<?몄슂. 臾몄젣媛€ 吏€?띾릺硫?遊?愿€由ъ옄?먭쾶 臾몄쓽?섏꽭??',
},
E3999: {
userMessage: '예상치 못한 오류가 발생했습니다.',
resolution: '나중에 다시 시도해주세요. 문제가 계속되면 봇 관리자에게 문의하세요.',
userMessage: '?덉긽移?紐삵븳 ?ㅻ쪟媛€ 諛쒖깮?덉뒿?덈떎.',
resolution: '?섏쨷???ㅼ떆 ?쒕룄?댁<?몄슂. 臾몄젣媛€ 怨꾩냽?섎㈃ 遊?愿€由ъ옄?먭쾶 臾몄쓽?섏꽭??',
},
E4001: {
userMessage: 'Discord에 의해 요청이 제한되었습니다.',
resolution: '잠시 기다린 후 다시 시도해주세요.',
userMessage: 'Discord???섑빐 ?붿껌???쒗븳?섏뿀?듬땲??',
resolution: '?좎떆 湲곕떎由????ㅼ떆 ?쒕룄?댁<?몄슂.',
},
E4002: {
userMessage: '권한 부족으로 Discord가 작업을 거부했습니다.',
resolution: '서버 관리자에게 봇의 역할 및 채널 권한을 확인해달라고 요청하세요.',
userMessage: '沅뚰븳 遺€議깆쑝濡?Discord媛€ ?묒뾽??嫄곕??덉뒿?덈떎.',
resolution: '?쒕쾭 愿€由ъ옄?먭쾶 遊뉗쓽 ??븷 諛?梨꾨꼸 沅뚰븳???뺤씤?대떖?쇨퀬 ?붿껌?섏꽭??',
},
E4003: {
userMessage: 'Discord에 일시적인 문제가 발생했습니다.',
resolution: '잠시 후 다시 시도해주세요. 문제가 지속되면 https://discordstatus.com 에서 상태를 확인해주세요.',
userMessage: 'Discord???쇱떆?곸씤 臾몄젣媛€ 諛쒖깮?덉뒿?덈떎.',
resolution: '?좎떆 ???ㅼ떆 ?쒕룄?댁<?몄슂. 臾몄젣媛€ 吏€?띾릺硫?https://discordstatus.com ?먯꽌 ?곹깭瑜??뺤씤?댁<?몄슂.',
},
},
// ── 에러 카테고리 타이틀 ────────────────────────────────
// ?€?€ ?먮윭 移댄뀒怨좊━ ?€?댄? ?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€
errorTitles: {
USER_INPUT: '입력을 확인해주세요',
PERMISSION: '권한이 부족합니다',
BOT_INTERNAL: '문제가 발생했습니다',
DISCORD_API: '일시적인 문제입니다',
PERMISSION: '沅뚰븳??遺€議깊빀?덈떎',
BOT_INTERNAL: '臾몄젣媛€ 諛쒖깮?덉뒿?덈떎',
DISCORD_API: '일시적인 문제입니다.',
},
// ── 에러 Embed 필드 라벨 ────────────────────────────────
// ?€?€ ?먮윭 Embed ?꾨뱶 ?쇰꺼 ?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€
errorFields: {
resolution: '💡 해결 방법',
},
// ── 음성 채널 ───────────────────────────────────────────
// ?€?€ ?뚯꽦 梨꾨꼸 ?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€
voice: {
channelReady: '{{owner}}, 임시 채널이 준비되었습니다! 아래 드롭다운 메뉴로 관리하세요.',
defaultRoomName: '{{username}}의 방',
controlPanel: {
placeholder: '⚙️ 채널 설정 관리',
placeholder: '채널 설정 관리',
rename: '채널 이름 변경',
limit: '인원 제한 설정',
lock: '채널 잠금 / 해제',
kick: '유저 추방',
limit: '?몄썝 ?쒗븳 ?ㅼ젙',
lock: '梨꾨꼸 ?좉툑 / ?댁젣',
kick: '?좎? 異붾갑',
ban: '유저 차단 / 숨기기',
transfer: '소유권 이전',
transfer: '?뚯쑀沅??댁쟾',
},
responses: {
channelLocked: '채널이 잠겼습니다! 초대된 멤버만 참여할 수 있습니다.',
channelUnlocked: '채널이 해제되었습니다! 누구나 참여할 수 있습니다.',
channelRenamed: '채널 이름이 **{{name}}**(으)로 변경되었습니다!',
limitSet: '인원 제한이 **{{limit}}**명으로 설정되었습니다!',
channelUnlocked: '梨꾨꼸???댁젣?섏뿀?듬땲?? ?꾧뎄??李몄뿬?????덉뒿?덈떎.',
channelRenamed: '梨꾨꼸 ?대쫫??**{{name}}**(??濡?蹂€寃쎈릺?덉뒿?덈떎!',
limitSet: '?몄썝 ?쒗븳??**{{limit}}**紐낆쑝濡??ㅼ젙?섏뿀?듬땲??',
limitUnlimited: '무제한',
kicked: '{{user}}을(를) 채널에서 추방했습니다.',
banned: '{{user}}에게 채널을 숨기고 차단했습니다.',
transferPrompt: '채널의 새 소유자를 선택하세요.',
transferDone: '소유권이 {{user}}에게 이전되었습니다.',
banPrompt: '차단하면 해당 유저에게 채널이 보이지 않게 됩니다.',
kicked: '{{user}}??瑜? 梨꾨꼸?먯꽌 異붾갑?덉뒿?덈떎.',
banned: '{{user}}?먭쾶 梨꾨꼸???④린怨?李⑤떒?덉뒿?덈떎.',
transferPrompt: '梨꾨꼸?????뚯쑀?먮? ?좏깮?섏꽭??',
transferDone: '?뚯쑀沅뚯씠 {{user}}?먭쾶 ?댁쟾?섏뿀?듬땲??',
banPrompt: '李⑤떒?섎㈃ ?대떦 ?좎??먭쾶 梨꾨꼸??蹂댁씠吏€ ?딄쾶 ?⑸땲??',
},
},
// ── 명령어 ──────────────────────────────────────────────
// ?€?€ 紐낅졊???€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€
commands: {
voiceSetup: {
description: '임시 음성 채널을 위한 생성기 채널을 설정합니다.',
setDescription: '기존 음성 채널을 생성기로 설정합니다',
createDescription: '새 음성 채널을 만들고 생성기로 설정합니다',
channelOptionDescription: '생성기로 사용할 음성 채널',
categoryOptionDescription: '(선택) 임시 채널이 생성될 카테고리',
nameOptionDescription: '새 생성기 음성 채널의 이름',
setSuccess: '{{channel}}을(를) 음성 생성기 채널로 설정했습니다!',
createSuccess: '{{channel}}을(를) 음성 생성기 채널로 생성 및 설정했습니다!',
description: '?꾩떆 ?뚯꽦 梨꾨꼸???꾪븳 ?앹꽦湲?梨꾨꼸???ㅼ젙?⑸땲??',
setDescription: '기존 음성 채널을 생성기로 설정합니다.',
createDescription: '새 음성 채널을 만들고 생성기로 설정합니다.',
channelOptionDescription: '?앹꽦湲곕줈 ?ъ슜???뚯꽦 梨꾨꼸',
categoryOptionDescription: '(?좏깮) ?꾩떆 梨꾨꼸???앹꽦??移댄뀒怨좊━',
nameOptionDescription: '???앹꽦湲??뚯꽦 梨꾨꼸???대쫫',
setSuccess: '{{channel}}??瑜? ?뚯꽦 ?앹꽦湲?梨꾨꼸濡??ㅼ젙?덉뒿?덈떎!',
createSuccess: '{{channel}}??瑜? ?뚯꽦 ?앹꽦湲?梨꾨꼸濡??앹꽦 諛??ㅼ젙?덉뒿?덈떎!',
},
voiceConfig: {
description: '서버의 임시 음성 채널 설정을 관리합니다.',
setNameTitle: '기본 이름 템플릿 설정',
setNameDesc: '임시 채널 생성 시 사용할 기본 이름 형식을 설정합니다. (사용자명: {{username}})',
setLimitTitle: '기본 인원 제한 설정',
setLimitDesc: '임시 채널 생성 시 적용할 기본 인원 제한을 설정합니다.',
statusTitle: '현재 서버 음성 설정',
description: '?쒕쾭???꾩떆 ?뚯꽦 梨꾨꼸 ?ㅼ젙??愿€由ы빀?덈떎.',
setNameTitle: '湲곕낯 ?대쫫 ?쒗뵆由??ㅼ젙',
setNameDesc: '?꾩떆 梨꾨꼸 ?앹꽦 ???ъ슜??湲곕낯 ?대쫫 ?뺤떇???ㅼ젙?⑸땲?? (?ъ슜?먮챸: {{username}})',
setLimitTitle: '湲곕낯 ?몄썝 ?쒗븳 ?ㅼ젙',
setLimitDesc: '?꾩떆 梨꾨꼸 ?앹꽦 ???곸슜??湲곕낯 ?몄썝 ?쒗븳???ㅼ젙?⑸땲??',
statusTitle: '?꾩옱 ?쒕쾭 ?뚯꽦 ?ㅼ젙',
templateLabel: '이름 템플릿',
limitLabel: '기본 인원 제한',
setSuccess: '서버의 임시 채널 설정이 업데이트되었습니다.',
limitValue: '{{limit}}명 (0 = 무제한)',
limitLabel: '湲곕낯 ?몄썝 ?쒗븳',
setSuccess: '?쒕쾭???꾩떆 梨꾨꼸 ?ㅼ젙???낅뜲?댄듃?섏뿀?듬땲??',
limitValue: '{{limit}}紐?(0 = 臾댁젣??',
},
language: {
description: '봇의 언어를 설정합니다.',
scopeDescription: '본인에게만 또는 서버 전체에 적용',
localeDescription: '사용할 언어',
scopeUser: '나만 적용',
scopeServer: '서버 전체 (관리자 전용)',
userSet: '개인 언어가 **{{locale}}**(으)로 설정되었습니다.',
serverSet: '서버 언어가 **{{locale}}**(으)로 설정되었습니다.',
serverPermissionDenied: '서버 언어 변경은 서버 관리자만 가능합니다.',
description: '遊뉗쓽 ?몄뼱瑜??ㅼ젙?⑸땲??',
scopeDescription: '蹂몄씤?먭쾶留??먮뒗 ?쒕쾭 ?꾩껜???곸슜',
localeDescription: '?ъ슜???몄뼱',
scopeUser: '?섎쭔 ?곸슜',
scopeServer: '?쒕쾭 ?꾩껜 (愿€由ъ옄 ?꾩슜)',
userSet: '媛쒖씤 ?몄뼱媛€ **{{locale}}**(??濡??ㅼ젙?섏뿀?듬땲??',
serverSet: '?쒕쾭 ?몄뼱媛€ **{{locale}}**(??濡??ㅼ젙?섏뿀?듬땲??',
serverPermissionDenied: '?쒕쾭 ?몄뼱 蹂€寃쎌? ?쒕쾭 愿€由ъ옄留?媛€?ν빀?덈떎.',
},
event: {
description: '서버 이벤트 일정을 관리합니다.',
createDescription: '새 서버 이벤트를 생성합니다.',
listDescription: '예정된 서버 이벤트 목록을 조회합니다.',
cancelDescription: '예약된 서버 이벤트를 취소합니다.',
announceDescription: '이벤트 공지 Embed를 다시 게시합니다.',
titleDescription: '이벤트 제목',
dateDescription: 'YYYY-MM-DD 형식의 날짜',
timeDescription: 'HH:mm 형식의 시간 (24시간제, Asia/Seoul 기준)',
descriptionOptionDescription: '선택 사항인 이벤트 설명',
channelDescription: '선택 사항인 공지 채널',
reminderDescription: '리마인더 메시지 사용 여부',
remindersDescription: '분 단위 리마인더 목록, 예: 0,10,60',
idDescription: '취소할 이벤트 ID',
createSuccessTitle: '이벤트 생성 완료',
createSuccessBody: '**{{title}}** 이벤트가 예약되었습니다.',
listTitle: '예정된 이벤트 목록',
listEmpty: '예정된 이벤트가 없습니다.',
listItemValue: '**시작 시각:** {{startsAt}}\n**남은 시간:** {{relative}}\n**상태:** {{status}}\n**리마인더:** {{reminder}}\n**채널:** {{channel}}',
cancelSuccess: '`{{id}}` 이벤트를 취소했습니다.',
cancelNotFound: 'ID가 `{{id}}`인 예약 이벤트를 찾지 못했습니다.',
announceSuccess: '`{{id}}` 이벤트를 {{channel}} 채널에 공지했습니다.',
announceNotAvailable: '이 이벤트에는 사용할 수 있는 공지 채널이 설정되어 있지 않습니다.',
startAnnouncementTitle: '이벤트 시작',
startAnnouncementLead: '이 이벤트가 지금 시작됩니다.',
invalidDateTime: '이벤트 날짜 또는 시간 형식이 올바르지 않습니다.',
invalidDateTimeResolution: '날짜는 `YYYY-MM-DD`, 시간은 `HH:mm` 24시간 형식으로 입력해주세요.',
invalidReminderOffsets: '리마인더 분 입력 형식이 올바르지 않습니다.',
invalidReminderOffsetsResolution: '`0,10,60`처럼 0 이상의 분을 쉼표로 구분해 입력해주세요. 비워두면 자동 공지는 하지 않습니다.',
invalidPastDateTime: '과거 시각으로 이벤트를 예약할 수 없습니다.',
invalidPastDateTimeResolution: '미래 시각을 선택한 뒤 다시 시도해주세요.',
description: '?쒕쾭 ?대깽???쇱젙??愿€由ы빀?덈떎.',
createDescription: '???쒕쾭 ?대깽?몃? ?앹꽦?⑸땲??',
listDescription: '?덉젙???쒕쾭 ?대깽??紐⑸줉??議고쉶?⑸땲??',
cancelDescription: '?덉빟???쒕쾭 ?대깽?몃? 痍⑥냼?⑸땲??',
announceDescription: '?대깽??怨듭? Embed瑜??ㅼ떆 寃뚯떆?⑸땲??',
titleDescription: '?대깽???쒕ぉ',
dateDescription: 'YYYY-MM-DD ?뺤떇???좎쭨',
timeDescription: 'HH:mm ?뺤떇???쒓컙 (24?쒓컙?? Asia/Seoul 湲곗?)',
descriptionOptionDescription: '?좏깮 ?ы빆???대깽???ㅻ챸',
channelDescription: '?좏깮 ?ы빆??怨듭? 梨꾨꼸',
reminderDescription: '由щ쭏?몃뜑 硫붿떆吏€ ?ъ슜 ?щ?',
remindersDescription: '遺??⑥쐞 由щ쭏?몃뜑 紐⑸줉, ?? 0,10,60',
idDescription: '痍⑥냼???대깽??ID',
createSuccessTitle: '?대깽???앹꽦 ?꾨즺',
createSuccessBody: '**{{title}}** ?대깽?멸? ?덉빟?섏뿀?듬땲??',
listTitle: '?덉젙???대깽??紐⑸줉',
listEmpty: '?덉젙???대깽?멸? ?놁뒿?덈떎.',
listItemValue: '**?쒖옉 ?쒓컖:** {{startsAt}}\n**?⑥? ?쒓컙:** {{relative}}\n**?곹깭:** {{status}}\n**由щ쭏?몃뜑:** {{reminder}}\n**梨꾨꼸:** {{channel}}',
cancelSuccess: '`{{id}}` ?대깽?몃? 痍⑥냼?덉뒿?덈떎.',
cancelNotFound: 'ID媛€ `{{id}}`???덉빟 ?대깽?몃? 李얠? 紐삵뻽?듬땲??',
announceSuccess: '`{{id}}` ?대깽?몃? {{channel}} 梨꾨꼸??怨듭??덉뒿?덈떎.',
announceNotAvailable: '???대깽?몄뿉???ъ슜?????덈뒗 怨듭? 梨꾨꼸???ㅼ젙?섏뼱 ?덉? ?딆뒿?덈떎.',
startAnnouncementTitle: '?대깽???쒖옉',
startAnnouncementLead: '???대깽?멸? 吏€湲??쒖옉?⑸땲??',
invalidDateTime: '?대깽???좎쭨 ?먮뒗 ?쒓컙 ?뺤떇???щ컮瑜댁? ?딆뒿?덈떎.',
invalidDateTimeResolution: '?좎쭨??`YYYY-MM-DD`, ?쒓컙?€ `HH:mm` 24?쒓컙 ?뺤떇?쇰줈 ?낅젰?댁<?몄슂.',
invalidReminderOffsets: '由щ쭏?몃뜑 遺??낅젰 ?뺤떇???щ컮瑜댁? ?딆뒿?덈떎.',
invalidReminderOffsetsResolution: '`0,10,60`泥섎읆 0 ?댁긽??遺꾩쓣 ?쇳몴濡?援щ텇???낅젰?댁<?몄슂. 鍮꾩썙?먮㈃ ?먮룞 怨듭????섏? ?딆뒿?덈떎.',
invalidPastDateTime: '怨쇨굅 ?쒓컖?쇰줈 ?대깽?몃? ?덉빟?????놁뒿?덈떎.',
invalidPastDateTimeResolution: '誘몃옒 ?쒓컖???좏깮?????ㅼ떆 ?쒕룄?댁<?몄슂.',
statusScheduled: '예약됨',
statusCancelled: '취소됨',
statusCompleted: '완료됨',
reminderOn: '사용',
reminderOn: '?ъ슜',
reminderOff: '사용 안 함',
reminderNone: '자동 공지 없음',
reminderNone: '?먮룞 怨듭? ?놁쓬',
announcementChannelNone: '미설정',
fields: {
eventId: '이벤트 ID',
startsAt: '시작 시각',
reminder: '리마인더',
announcementChannel: '공지 채널',
status: '상태',
eventId: '?대깽??ID',
startsAt: '?쒖옉 ?쒓컖',
reminder: '由щ쭏?몃뜑',
announcementChannel: '怨듭? 梨꾨꼸',
status: '?곹깭',
},
},
music: {
@ -258,118 +258,157 @@ export const ko: TranslationSchema = {
leave: 'Leave',
},
},
fishing: {
description: '낚시 미니게임을 플레이합니다.',
enterDescription: '낚시 전용 스레드를 생성하거나 다시 엽니다.',
castDescription: '자신의 낚시 스레드 안에서 낚시 세션을 시작합니다.',
endDescription: '낚시 스레드를 종료하고 삭제합니다.',
disabled: '이 서버에서는 낚시 미니게임이 비활성화되어 있습니다.',
restrictedChannel: '낚시는 {{channel}} 채널에서만 시작할 수 있습니다.',
enterTextChannelOnly: '낚시 스레드는 일반 텍스트 채널에서만 열 수 있습니다.',
enterExistingThread: '이미 {{thread}}에 자신의 낚시 스레드가 열려 있습니다.',
enterCreated: '{{thread}}에 낚시 스레드를 만들었습니다.',
castThreadOnly: '/fishing cast는 자신의 낚시 스레드 안에서만 사용할 수 있습니다.',
startExistingSession: '이미 {{thread}}에서 진행 중인 낚시 세션이 있습니다.',
startCreated: '{{thread}}에서 낚시 세션을 시작했습니다.',
noActiveSession: '종료할 낚시 세션이나 스레드가 없습니다.',
ownerOnly: '이 낚시 세션의 소유자만 조작할 수 있습니다.',
wrongThread: '이 조작은 자신의 낚시 스레드 안에서만 사용할 수 있습니다.',
endDeleted: '낚시 스레드를 종료했습니다. 스레드를 삭제합니다.',
titleActive: '낚시 세션',
titleEnded: '낚시 세션 종료',
status: '상태',
targetFish: '대상 물고기',
distance: '거리',
tension: '끊어짐 게이지',
reward: '보상',
threadHint: '/fishing cast로 다시 시작하거나 /fishing end로 스레드를 삭제할 수 있습니다.',
catchResultTitle: '낚시 성공!',
catchResultBody: '**{{fish}}**를 낚았습니다. **{{reward}} G**를 획득했습니다.',
states: {
hooked: '입질 중',
resting: '휴식 중',
tense: '당기는 중',
missed: '타이밍 빗나감',
success: '낚시 성공',
failed: '줄이 끊어짐',
},
},
permissionAudit: {
title: '봇 권한 진단 보고서',
channel: '채널',
noResults: '진단할 기능이 없습니다. 봇이 아직 설정되지 않았을 수 있습니다.',
summaryLabel: '진단 결과 요약',
summaryOk: '✅ 모든 항목 정상. 문제가 없습니다.',
summaryIssue: '❌ {{fail}}개 실패 · ⚠️ {{warn}}개 경고 감지됨.',
hierarchyWarning: "봇 역할(순위: {{botPos}})이 '{{role}}'(순위: {{targetPos}})보다 위에 있어야 관리할 수 있습니다.",
channel: '梨꾨꼸',
noResults: '吏꾨떒??湲곕뒫???놁뒿?덈떎. 遊뉗씠 ?꾩쭅 ?ㅼ젙?섏? ?딆븯?????덉뒿?덈떎.',
summaryLabel: '吏꾨떒 寃곌낵 ?붿빟',
summaryOk: '??紐⑤뱺 ??ぉ ?뺤긽. 臾몄젣媛€ ?놁뒿?덈떎.',
summaryIssue: '??{{fail}}媛??ㅽ뙣 쨌 ?좑툘 {{warn}}媛?寃쎄퀬 媛먯???',
hierarchyWarning: "遊???븷(?쒖쐞: {{botPos}})??'{{role}}'(?쒖쐞: {{targetPos}})蹂대떎 ?꾩뿉 ?덉뼱??愿€由ы븷 ???덉뒿?덈떎.",
features: {
BASIC: '기본 봇 기능',
VOICE_GLOBAL: '임시 음성 채널 (전역)',
VOICE_GENERATOR_CHANNEL: '음성 생성기 채널',
VOICE_GENERATOR_CATEGORY: '음성 생성기 카테고리',
INVITE_TRACKING: '초대 추적',
INVITE_ROLE_HIERARCHY: '초대 역할 부여 (계층 검사)',
MIMIC_WEBHOOK: '메시지 흉내 (Webhook)',
BASIC: '湲곕낯 遊?湲곕뒫',
VOICE_GLOBAL: '?꾩떆 ?뚯꽦 梨꾨꼸 (?꾩뿭)',
VOICE_GENERATOR_CHANNEL: '?뚯꽦 ?앹꽦湲?梨꾨꼸',
VOICE_GENERATOR_CATEGORY: '?뚯꽦 ?앹꽦湲?移댄뀒怨좊━',
INVITE_TRACKING: '珥덈? 異붿쟻',
INVITE_ROLE_HIERARCHY: '珥덈? ??븷 遺€??(怨꾩링 寃€??',
MIMIC_WEBHOOK: '硫붿떆吏€ ?됰궡 (Webhook)',
},
},
setup: {
description: '설정 마법사를 실행하여 봇의 필수 기능들을 단계별로 설정합니다.',
description: '?ㅼ젙 留덈쾿?щ? ?ㅽ뻾?섏뿬 遊뉗쓽 ?꾩닔 湲곕뒫?ㅼ쓣 ?④퀎蹂꾨줈 ?ㅼ젙?⑸땲??',
step0: {
title: '✨ 봇 설정 마법사 시작',
desc: '환영합니다! 이 마법사를 통해 아래 4가지 항목을 설정합니다.\n\n1⃣ **언어 설정**\n2⃣ **필수 권한 점검**\n3⃣ **감사 채널 설정**\n4⃣ **임시 음성 채널 설정**',
startBtn: '설정 시작하기'
title: '??遊??ㅼ젙 留덈쾿???쒖옉',
desc: '?섏쁺?⑸땲?? ??留덈쾿?щ? ?듯빐 ?꾨옒 4媛€€ ??ぉ???ㅼ젙?⑸땲??\n\n1截뤴깵 **?몄뼱 ?ㅼ젙**\n2截뤴깵 **?꾩닔 沅뚰븳 ?먭?**\n3截뤴깵 **媛먯궗 梨꾨꼸 ?ㅼ젙**\n4截뤴깵 **?꾩떆 ?뚯꽦 梨꾨꼸 ?ㅼ젙**',
startBtn: '?ㅼ젙 ?쒖옉?섍린'
},
step1: {
title: '1⃣ 언어 설정',
desc: '서버 전체에 적용될 봇의 기본 언어를 선택하세요. (현재: **{{locale}}**)',
title: '1截뤴깵 ?몄뼱 ?ㅼ젙',
desc: '?쒕쾭 ?꾩껜???곸슜??遊뉗쓽 湲곕낯 ?몄뼱瑜??좏깮?섏꽭?? (?꾩옱: **{{locale}}**)',
placeholder: '언어를 선택하세요',
nextBtn: '다음 단계',
skipBtn: '건너뛰기'
nextBtn: '?ㅼ쓬 ?④퀎',
skipBtn: '嫄대꼫?곌린'
},
step2: {
title: '2⃣ 필수 권한 점검',
descOk: '✅ **모든 필수 권한이 정상적으로 부여되어 있습니다.**',
descFail: '⚠️ **일부 권한이 부족합니다.**\n결과를 확인하고 봇 역할에 필요한 기능 권한을 부여해주세요.',
title: '2截뤴깵 ?꾩닔 沅뚰븳 ?먭?',
descOk: '??**紐⑤뱺 ?꾩닔 沅뚰븳???뺤긽?곸쑝濡?遺€?щ릺???덉뒿?덈떎.**',
descFail: '?좑툘 **?쇰? 沅뚰븳??遺€議깊빀?덈떎.**\n寃곌낵瑜??뺤씤?섍퀬 遊???븷???꾩슂??湲곕뒫 沅뚰븳??遺€?ы빐二쇱꽭??',
recheckBtn: '다시 검사하기',
nextBtn: '다음 단계'
nextBtn: '?ㅼ쓬 ?④퀎'
},
step3: {
title: '3️⃣ 감사 채널 설정',
desc: '봇의 주요 이벤트와 에러 통보를 받을 채널을 선택해주세요.',
placeholder: '감사 통보 채널 선택',
disableBtn: '감사 채널 끄기/해제',
nextBtn: '다음 단계'
title: '3截뤴깵 媛먯궗 梨꾨꼸 ?ㅼ젙',
desc: '遊뉗쓽 二쇱슂 ?대깽?몄? ?먮윭 ?듬낫瑜?諛쏆쓣 梨꾨꼸???좏깮?댁<?몄슂.',
placeholder: '媛먯궗 ?듬낫 梨꾨꼸 ?좏깮',
disableBtn: '媛먯궗 梨꾨꼸 ?꾧린/?댁젣',
nextBtn: '?ㅼ쓬 ?④퀎'
},
step4: {
title: '감사 로그 카테고리 설정',
desc: '로그를 수신할 카테고리를 선택해주세요.',
nextBtn: '다음 단계',
title: '媛먯궗 濡쒓렇 移댄뀒怨좊━ ?ㅼ젙',
desc: '濡쒓렇瑜??섏떊??移댄뀒怨좊━瑜??좏깮?댁<?몄슂.',
nextBtn: '?ㅼ쓬 ?④퀎',
},
step5: {
title: '4️⃣ 임시 음성 채널 설정',
desc: '임시 음성 채널을 생성할 "생성기 채널"을 선택해주세요.\n기존의 채널을 고르거나 카테고리/채널을 봇이 **자동 생성**하게 할 수도 있습니다.',
placeholder: '생성기로 쓸 음성 채널 선택',
autoBtn: '🚀 자동 생성하기',
skipBtn: '임시 음성 사용 안함',
nextBtn: '설정 완료'
title: '4截뤴깵 ?꾩떆 ?뚯꽦 梨꾨꼸 ?ㅼ젙',
desc: '?꾩떆 ?뚯꽦 梨꾨꼸???앹꽦??"?앹꽦湲?梨꾨꼸"???좏깮?댁<?몄슂.\n湲곗〈??梨꾨꼸??怨좊Ⅴ嫄곕굹 移댄뀒怨좊━/梨꾨꼸??遊뉗씠 **?먮룞 ?앹꽦**?섍쾶 ???섎룄 ?덉뒿?덈떎.',
placeholder: '?앹꽦湲곕줈 ???뚯꽦 梨꾨꼸 ?좏깮',
autoBtn: '?? ?먮룞 ?앹꽦?섍린',
skipBtn: '?꾩떆 ?뚯꽦 ?ъ슜 ?덊븿',
nextBtn: '?ㅼ젙 ?꾨즺'
},
step6: {
title: '🎉 설정 완료 요약',
desc: '**1. 언어**: {{lang}}\n**2. 감사 채널**: {{audit}}\n**3. 감사 카테고리**: {{categories}}\n**4. 임시 음성 채널**: {{voice}}',
title: '?럦 ?ㅼ젙 ?꾨즺 ?붿빟',
desc: '**1. ?몄뼱**: {{lang}}\n**2. 媛먯궗 梨꾨꼸**: {{audit}}\n**3. 媛먯궗 移댄뀒怨좊━**: {{categories}}\n**4. ?꾩떆 ?뚯꽦 梨꾨꼸**: {{voice}}',
finishBtn: '마치기'
},
finished: '✅ 설정 마법사를 종료했습니다.',
expired: '⏳ 시간이 만료되었습니다. `/setup`을 다시 실행해주세요.',
defaultCategoryName: '음성 채널',
defaultGeneratorName: ' 채널 생성하기',
finished: '???ㅼ젙 留덈쾿?щ? 醫낅즺?덉뒿?덈떎.',
expired: '???쒓컙??留뚮즺?섏뿀?듬땲?? `/setup`???ㅼ떆 ?ㅽ뻾?댁<?몄슂.',
defaultCategoryName: '?뚯꽦 梨꾨꼸',
defaultGeneratorName: '??梨꾨꼸 ?앹꽦?섍린',
auditCategories: {
SYSTEM: '시스템',
BOOT: '부팅',
VOICE: '음성',
PERMISSION: '권한',
INVITE: '초대',
VOICE: '?뚯꽦',
PERMISSION: '沅뚰븳',
INVITE: '珥덈?',
},
},
config: {
title: '기능 설정 변경 결과',
noOptions: '변경할 옵션을 하나 이상 선택해주세요.',
title: '湲곕뒫 ?ㅼ젙 蹂€寃?寃곌낵',
noOptions: '蹂€寃쏀븷 ?듭뀡???섎굹 ?댁긽 ?좏깮?댁<?몄슂.',
mimic: {
label: '미믹(Mimic)',
label: '誘몃?(Mimic)',
enabled: '활성화',
disabled: '비활성화',
disabled: '鍮꾪솢?깊솕',
},
emoji: {
label: '이모지 확대(Big Emoji)',
label: '?대え吏€ ?뺣?(Big Emoji)',
enabled: '활성화',
disabled: '비활성화',
disabled: '鍮꾪솢?깊솕',
},
},
},
// ── 모달 ────────────────────────────────────────────────
// ?€?€ 紐⑤떖 ?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€
modals: {
renameTitle: '음성 채널 이름 변경',
renameLabel: '새 채널 이름',
limitTitle: '인원 제한 설정',
limitLabel: '인원 제한 (0 = 무제한, 1-99)',
renameLabel: '??梨꾨꼸 ?대쫫',
limitTitle: '?몄썝 ?쒗븳 ?ㅼ젙',
limitLabel: '?몄썝 ?쒗븳 (0 = 臾댁젣?? 1-99)',
},
// ── 셀렉트 메뉴 플레이스홀더 ────────────────────────────
// ?€?€ ?€?됲듃 硫붾돱 ?뚮젅?댁뒪?€???€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€
selects: {
kickUser: '추방할 유저를 선택하세요',
banUser: '차단할 유저를 선택하세요',
transferOwner: '소유권을 이전할 유저를 선택하세요',
},
// ── 상태 메시지 ──────────────────────────────────────────
// ?€?€ ?곹깭 硫붿떆吏€ ?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€
presence: {
servers: '{{guildCount}}개의 서버에서 동 중',
servers: '{{guildCount}}개의 서버에서 동 중',
help: '/help 명령어를 확인하세요',
managing: '임시 음성 채널 관리 중',
version: 'Kord v1.0.0',
},
};

View File

@ -212,6 +212,42 @@ export interface TranslationSchema {
leave: string;
};
};
fishing: {
description: string;
enterDescription: string;
castDescription: string;
endDescription: string;
disabled: string;
restrictedChannel: string;
enterTextChannelOnly: string;
enterExistingThread: string;
enterCreated: string;
castThreadOnly: string;
startExistingSession: string;
startCreated: string;
noActiveSession: string;
ownerOnly: string;
wrongThread: string;
endDeleted: string;
titleActive: string;
titleEnded: string;
status: string;
targetFish: string;
distance: string;
tension: string;
reward: string;
threadHint: string;
catchResultTitle: string;
catchResultBody: string;
states: {
hooked: string;
resting: string;
tense: string;
missed: string;
success: string;
failed: string;
};
};
permissionAudit: {
title: string;
channel: string;

View File

@ -0,0 +1,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 });
}
}
};

View File

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

View File

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

View File

@ -0,0 +1,635 @@
import {
ActionRowBuilder,
AttachmentBuilder,
ButtonBuilder,
ButtonInteraction,
ButtonStyle,
ChannelType,
ChatInputCommandInteraction,
EmbedBuilder,
Message,
TextChannel,
ThreadAutoArchiveDuration,
ThreadChannel,
} from 'discord.js';
import fs from 'node:fs';
import path from 'node:path';
import { SupportedLocale, t } from '../i18n';
import { RefinementService } from './RefinementService';
import { logger } from '../utils/logger';
export type FishingDirection = 'left' | 'center' | 'right';
type FishingAction = FishingDirection | 'rest';
type FishingState = 'hooked' | 'resting' | 'tense' | 'missed' | 'success' | 'failed';
interface FishingRange {
min: number;
max: number;
}
interface FishingCatalogEntry {
id: string;
displayName: string;
spawnRate: number;
rewardGold: FishingRange;
reactionWindowSec: number;
distanceReductionByPosition: Record<FishingDirection, FishingRange>;
artResourcePaths: string[];
}
interface FishingCatalogFile {
fish: FishingCatalogEntry[];
}
interface FishingSession {
guildId: string;
userId: string;
userName: string;
locale: SupportedLocale;
threadId: string;
thread: ThreadChannel;
controlMessage: Message;
currentFish: FishingCatalogEntry;
fishPosition: FishingDirection;
selectedAction: FishingAction | null;
phaseStartedAt: number;
distance: number;
lineTension: number;
status: FishingState;
reward: number | null;
tickInterval: NodeJS.Timeout | null;
isRendering: boolean;
needsRender: boolean;
}
const MAX_DISTANCE = 100;
const MAX_TENSION = 100;
const MATCH_TENSION_INCREASE = 12;
const MISS_TENSION_INCREASE = 6;
const REST_TENSION_RECOVERY = 20;
const REST_DISTANCE_INCREASE = 8;
const DISTANCE_BAR_WIDTH = 12;
const TENSION_BAR_WIDTH = 12;
const ROUND_DURATION_MS = 2500;
const REACTION_MIN_MS = 1200;
const REACTION_GRACE_MS = 300;
const SESSION_TICK_MS = 250;
export class FishingService {
private static sessionsByUser = new Map<string, FishingSession>();
private static sessionsByThread = new Map<string, FishingSession>();
private static threadEnterPromises = new Map<string, Promise<{ thread: ThreadChannel; existed: boolean }>>();
private static fishingCatalog = this.loadFishingCatalog();
static async enterThread(interaction: ChatInputCommandInteraction) {
if (!interaction.guildId || !interaction.channel || interaction.channel.type !== ChannelType.GuildText) {
throw new Error('ENTER_TEXT_ONLY');
}
const userKey = this.getUserKey(interaction.guildId, interaction.user.id);
const pending = this.threadEnterPromises.get(userKey);
if (pending) {
return pending;
}
const enterPromise = this.findOrCreateThread(
interaction.channel as TextChannel,
interaction.user.username,
interaction.user.tag,
);
this.threadEnterPromises.set(userKey, enterPromise);
try {
return await enterPromise;
} finally {
this.threadEnterPromises.delete(userKey);
}
}
static isOwnedFishingThread(channel: unknown, userName: string): channel is ThreadChannel {
return !!channel
&& typeof channel === 'object'
&& 'isThread' in channel
&& typeof (channel as { isThread: () => boolean }).isThread === 'function'
&& (channel as { isThread: () => boolean }).isThread()
&& 'name' in channel
&& (channel as { name: string }).name === this.buildThreadName(userName);
}
static async startSessionInThread(interaction: ChatInputCommandInteraction, locale: SupportedLocale) {
if (!interaction.guildId || !this.isOwnedFishingThread(interaction.channel, interaction.user.username)) {
throw new Error('CAST_THREAD_ONLY');
}
const userKey = this.getUserKey(interaction.guildId, interaction.user.id);
const existing = this.sessionsByUser.get(userKey);
if (existing) {
return { thread: existing.thread, existed: true };
}
const thread = interaction.channel;
await this.createSessionInThread({
guildId: interaction.guildId,
userId: interaction.user.id,
userName: interaction.user.username,
locale,
thread,
});
return { thread, existed: false };
}
static async endThreadByUser(interaction: ChatInputCommandInteraction, locale: SupportedLocale) {
if (!interaction.guildId) {
return false;
}
const session = this.sessionsByUser.get(this.getUserKey(interaction.guildId, interaction.user.id));
if (!session) {
const thread = await this.findOwnedFishingThread(interaction);
if (!thread) {
return false;
}
await this.deleteThread(thread);
return true;
}
session.locale = locale;
await this.finishSession(session, 'failed', true);
return true;
}
static async handleButton(interaction: ButtonInteraction, locale: SupportedLocale) {
const [, action, ownerId] = interaction.customId.split('_');
const session = interaction.guildId
? this.sessionsByUser.get(this.getUserKey(interaction.guildId, ownerId)) ?? this.sessionsByThread.get(interaction.channelId)
: null;
if (!interaction.guildId || !session) {
await interaction.update({
components: [this.buildControlRow(ownerId || 'stale', true)],
});
await interaction.followUp({
content: t(locale, 'commands.fishing.noActiveSession'),
ephemeral: true,
});
return;
}
if (interaction.user.id !== session.userId) {
await interaction.reply({
content: t(locale, 'commands.fishing.ownerOnly'),
ephemeral: true,
});
return;
}
if (interaction.channelId !== session.threadId) {
await interaction.reply({
content: t(locale, 'commands.fishing.wrongThread'),
ephemeral: true,
});
return;
}
await interaction.deferUpdate();
session.locale = locale;
await this.queueAction(session, action as FishingAction);
}
private static async tickSession(session: FishingSession) {
if (!this.sessionsByUser.has(this.getUserKey(session.guildId, session.userId))) {
this.clearTick(session);
return;
}
const elapsed = Date.now() - session.phaseStartedAt;
const reactionWindowMs = this.getReactionWindowMs(session.currentFish);
if (
session.selectedAction
&& session.selectedAction !== 'rest'
&& session.selectedAction === session.fishPosition
&& elapsed >= reactionWindowMs
) {
await this.resolveSuccessfulPull(session);
return;
}
if (elapsed >= ROUND_DURATION_MS + REACTION_GRACE_MS) {
await this.resolveMiss(session);
}
}
private static async queueAction(session: FishingSession, action: FishingAction) {
session.selectedAction = action;
if (action === 'rest') {
session.lineTension = Math.max(0, session.lineTension - REST_TENSION_RECOVERY);
session.distance = Math.min(MAX_DISTANCE, session.distance + REST_DISTANCE_INCREASE);
session.status = 'resting';
this.startNextPhase(session);
await this.renderSession(session, false);
return;
}
session.status = 'hooked';
await this.renderSession(session, false);
}
private static async resolveSuccessfulPull(session: FishingSession) {
const distanceReduction = this.rollRange(session.currentFish.distanceReductionByPosition[session.fishPosition]);
session.distance = Math.max(0, session.distance - distanceReduction);
session.lineTension = Math.min(MAX_TENSION, session.lineTension + MATCH_TENSION_INCREASE);
session.status = session.lineTension >= MAX_TENSION ? 'failed' : 'tense';
if (session.distance <= 0) {
const reward = this.rollRange(session.currentFish.rewardGold);
session.reward = reward;
await RefinementService.addGold(session.userId, session.guildId, reward);
await this.finishSession(session, 'success', false);
return;
}
if (session.lineTension >= MAX_TENSION) {
await this.finishSession(session, 'failed', false);
return;
}
this.startNextPhase(session);
await this.renderSession(session, false);
}
private static async resolveMiss(session: FishingSession) {
session.lineTension = Math.min(MAX_TENSION, session.lineTension + MISS_TENSION_INCREASE);
session.status = session.selectedAction && session.selectedAction !== 'rest' ? 'missed' : 'hooked';
if (session.lineTension >= MAX_TENSION) {
await this.finishSession(session, 'failed', false);
return;
}
this.startNextPhase(session);
await this.renderSession(session, false);
}
private static startNextPhase(session: FishingSession) {
session.selectedAction = null;
session.phaseStartedAt = Date.now();
session.fishPosition = this.randomDirection();
}
private static async finishSession(session: FishingSession, finalState: 'success' | 'failed', deleteThread: boolean) {
session.status = finalState;
this.clearTick(session);
this.sessionsByUser.delete(this.getUserKey(session.guildId, session.userId));
this.sessionsByThread.delete(session.threadId);
logger.info(`[Fishing] Finished session for ${session.userId} with state ${finalState}.`);
await this.renderSession(session, true);
if (finalState === 'success') {
await this.sendCatchResult(session);
}
if (deleteThread) {
await this.deleteThread(session.thread);
}
}
private static async deleteThread(thread: ThreadChannel) {
try {
await thread.delete();
} catch (error) {
logger.warn('Failed to delete fishing thread, archiving instead:', error);
try {
await thread.setArchived(true);
await thread.setLocked(true);
} catch (archiveError) {
logger.warn('Failed to archive fishing thread after delete failure:', archiveError);
}
}
}
private static async renderSession(session: FishingSession, disabled: boolean) {
if (session.isRendering) {
session.needsRender = true;
return;
}
session.isRendering = true;
try {
await session.controlMessage.edit({
embeds: [this.buildEmbed(session)],
components: [this.buildControlRow(session.userId, disabled)],
});
} finally {
session.isRendering = false;
}
if (session.needsRender) {
session.needsRender = false;
await this.renderSession(session, disabled);
}
}
private static buildEmbed(
session: Pick<FishingSession, 'locale' | 'currentFish' | 'fishPosition' | 'selectedAction' | 'phaseStartedAt' | 'distance' | 'lineTension' | 'status' | 'reward'>,
) {
const distanceProgress = MAX_DISTANCE - session.distance;
const distanceBar = this.buildGauge(distanceProgress, MAX_DISTANCE, DISTANCE_BAR_WIDTH);
const tensionBar = this.buildGauge(session.lineTension, MAX_TENSION, TENSION_BAR_WIDTH);
const stateKey = `commands.fishing.states.${session.status}`;
const selectedAction = this.formatSelectedAction(session.selectedAction);
const embed = new EmbedBuilder()
.setColor(session.status === 'success' ? 0x57f287 : session.status === 'failed' ? 0xed4245 : 0x5865f2)
.setTitle(
session.status === 'success' || session.status === 'failed'
? t(session.locale, 'commands.fishing.titleEnded')
: t(session.locale, 'commands.fishing.titleActive'),
)
.setDescription([
'```',
'⬅️ ⏺️ ➡️',
this.buildFishLane(session.fishPosition),
'```',
].join('\n'))
.addFields(
{
name: t(session.locale, 'commands.fishing.status'),
value: `${t(session.locale, stateKey)}\n🎯 ${selectedAction}`,
inline: true,
},
{
name: t(session.locale, 'commands.fishing.distance'),
value: `${distanceBar} ${session.distance} / ${MAX_DISTANCE}`,
inline: false,
},
{
name: t(session.locale, 'commands.fishing.tension'),
value: `${tensionBar} ${session.lineTension} / ${MAX_TENSION}`,
inline: false,
},
)
.setFooter({
text: t(session.locale, 'commands.fishing.threadHint'),
});
if (session.reward !== null) {
embed.addFields({
name: t(session.locale, 'commands.fishing.reward'),
value: `${session.reward} G`,
inline: true,
});
}
return embed;
}
private static buildControlRow(userId: string, disabled: boolean) {
return new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder()
.setCustomId(`fishing_left_${userId}`)
.setEmoji('⬅️')
.setStyle(ButtonStyle.Secondary)
.setDisabled(disabled),
new ButtonBuilder()
.setCustomId(`fishing_center_${userId}`)
.setEmoji('⏺️')
.setStyle(ButtonStyle.Secondary)
.setDisabled(disabled),
new ButtonBuilder()
.setCustomId(`fishing_right_${userId}`)
.setEmoji('➡️')
.setStyle(ButtonStyle.Secondary)
.setDisabled(disabled),
new ButtonBuilder()
.setCustomId(`fishing_rest_${userId}`)
.setEmoji('🛌')
.setStyle(ButtonStyle.Primary)
.setDisabled(disabled),
);
}
private static buildGauge(current: number, max: number, width: number) {
const ratio = max > 0 ? Math.max(0, Math.min(1, current / max)) : 0;
const filled = Math.round(ratio * width);
return `${'█'.repeat(filled)}${'░'.repeat(width - filled)}`;
}
private static async createSessionInThread(params: {
guildId: string;
userId: string;
userName: string;
locale: SupportedLocale;
thread: ThreadChannel;
}) {
const currentFish = this.pickFishByRate();
const sessionBase = {
guildId: params.guildId,
userId: params.userId,
userName: params.userName,
locale: params.locale,
threadId: params.thread.id,
thread: params.thread,
currentFish,
fishPosition: this.randomDirection(),
selectedAction: null,
phaseStartedAt: Date.now(),
distance: MAX_DISTANCE,
lineTension: 0,
status: 'hooked' as FishingState,
reward: null,
tickInterval: null,
isRendering: false,
needsRender: false,
};
const controlMessage = await params.thread.send({
embeds: [this.buildEmbed(sessionBase)],
components: [this.buildControlRow(params.userId, false)],
});
const session: FishingSession = {
...sessionBase,
controlMessage,
};
this.sessionsByUser.set(this.getUserKey(session.guildId, session.userId), session);
this.sessionsByThread.set(session.threadId, session);
logger.info(`[Fishing] Started session for ${session.userId} in thread ${session.threadId}.`);
session.tickInterval = setInterval(() => {
void this.tickSession(session);
}, SESSION_TICK_MS);
return session;
}
private static async findOrCreateThread(parentChannel: TextChannel, userName: string, userTag: string) {
const threadName = this.buildThreadName(userName);
const activeThreads = await parentChannel.threads.fetchActive();
const existingThread = activeThreads.threads.find((thread) => thread.name === threadName);
if (existingThread) {
logger.info(`[Fishing] Reusing existing thread ${existingThread.id} for ${userName}.`);
return { thread: existingThread as ThreadChannel, existed: true };
}
const thread = await parentChannel.threads.create({
name: threadName,
autoArchiveDuration: ThreadAutoArchiveDuration.OneHour,
reason: `Fishing thread for ${userTag}`,
});
return { thread, existed: false };
}
private static async findOwnedFishingThread(interaction: ChatInputCommandInteraction) {
if (this.isOwnedFishingThread(interaction.channel, interaction.user.username)) {
return interaction.channel;
}
if (!interaction.channel || interaction.channel.type !== ChannelType.GuildText) {
return null;
}
const activeThreads = await interaction.channel.threads.fetchActive();
const thread = activeThreads.threads.find((candidate) => candidate.name === this.buildThreadName(interaction.user.username));
return (thread as ThreadChannel | undefined) ?? null;
}
private static async sendCatchResult(session: FishingSession) {
const artPath = this.pickRandomArtPath(session.currentFish);
const embed = new EmbedBuilder()
.setColor(0x57f287)
.setTitle(t(session.locale, 'commands.fishing.catchResultTitle'))
.setDescription(
t(session.locale, 'commands.fishing.catchResultBody', {
fish: session.currentFish.displayName,
reward: String(session.reward ?? 0),
}),
);
if (artPath && fs.existsSync(artPath)) {
const fileName = path.basename(artPath);
embed.setImage(`attachment://${fileName}`);
await session.thread.send({
embeds: [embed],
files: [new AttachmentBuilder(artPath, { name: fileName })],
});
return;
}
await session.thread.send({ embeds: [embed] });
}
static previewFishLane(position: FishingDirection) {
return this.buildFishLane(position);
}
static previewGauge(current: number, max: number, width: number) {
return this.buildGauge(current, max, width);
}
private static buildFishLane(position: FishingDirection) {
if (position === 'left') return '🐟 · ·';
if (position === 'right') return '· · 🐟';
return '· 🐟 ·';
}
private static randomDirection(): FishingDirection {
const directions: FishingDirection[] = ['left', 'center', 'right'];
return directions[Math.floor(Math.random() * directions.length)];
}
private static buildThreadName(userName: string) {
const sanitized = userName.replace(/[^\w\s-]/g, '').trim() || 'User';
return `${sanitized}'s Fishing Spot`.slice(0, 100);
}
private static loadFishingCatalog(): FishingCatalogEntry[] {
const catalogPath = this.resolveResourcePath('resource/data/fishing/fish_catalog.json');
const raw = fs.readFileSync(catalogPath, 'utf-8');
const parsed = JSON.parse(raw) as FishingCatalogFile;
if (!parsed.fish?.length) {
throw new Error('Fishing catalog is empty.');
}
return parsed.fish;
}
private static pickFishByRate() {
const totalRate = this.fishingCatalog.reduce((sum, fish) => sum + fish.spawnRate, 0);
let roll = Math.random() * totalRate;
for (const fish of this.fishingCatalog) {
roll -= fish.spawnRate;
if (roll <= 0) {
return fish;
}
}
return this.fishingCatalog[this.fishingCatalog.length - 1];
}
private static getReactionWindowMs(fish: FishingCatalogEntry) {
const configured = Math.round(fish.reactionWindowSec * 1000);
return Math.max(REACTION_MIN_MS, Math.min(configured, ROUND_DURATION_MS));
}
private static rollRange(range: FishingRange) {
if (range.min === range.max) {
return range.min;
}
return range.min + Math.floor(Math.random() * (range.max - range.min + 1));
}
private static pickRandomArtPath(fish: FishingCatalogEntry) {
if (!fish.artResourcePaths.length) {
return null;
}
const relativePath = fish.artResourcePaths[Math.floor(Math.random() * fish.artResourcePaths.length)];
return this.resolveResourcePath(relativePath);
}
private static resolveResourcePath(relativePath: string) {
return path.resolve(__dirname, '..', '..', relativePath);
}
private static formatSelectedAction(action: FishingAction | null) {
if (action === 'left') return '⬅️';
if (action === 'center') return '⏺️';
if (action === 'right') return '➡️';
if (action === 'rest') return '🛌';
return '—';
}
private static clearTick(session: FishingSession) {
if (session.tickInterval) {
clearInterval(session.tickInterval);
session.tickInterval = null;
}
}
private static getUserKey(guildId: string, userId: string) {
return `${guildId}:${userId}`;
}
}
export function buildFishingGauge(current: number, max: number, width: number) {
return FishingService.previewGauge(current, max, width);
}
export function buildFishingLane(position: FishingDirection) {
return FishingService.previewFishLane(position);
}

View File

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

View File

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

View File

@ -0,0 +1,13 @@
import { buildFishingGauge, buildFishingLane } from '../../src/services/FishingService';
describe('FishingService helpers', () => {
it('renders a gauge with filled and empty segments', () => {
expect(buildFishingGauge(50, 100, 10)).toBe('█████░░░░░');
});
it('renders the fish lane for each position', () => {
expect(buildFishingLane('left')).toContain('🐟');
expect(buildFishingLane('center')).toBe('· 🐟 ·');
expect(buildFishingLane('right')).toBe('· · 🐟');
});
});

1062
yarn.lock

File diff suppressed because it is too large Load Diff