Compare commits
No commits in common. "57805644fbce0f62115a39db6d325391a50709f1" and "aad37ef8554cfd949ad723c6e5573002257e19fa" have entirely different histories.
57805644fb
...
aad37ef855
|
|
@ -1,53 +0,0 @@
|
||||||
# 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)
|
|
||||||
|
|
@ -1,63 +0,0 @@
|
||||||
# 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
|
|
||||||
|
|
@ -1,39 +0,0 @@
|
||||||
# 미니게임 시스템 및 재련(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`의 모든 서브커맨드 응답 및 데이터 반영을 확인했습니다.
|
|
||||||
|
|
||||||
---
|
|
||||||
**보고서 끝.**
|
|
||||||
|
|
@ -16,11 +16,14 @@
|
||||||
|
|
||||||
## 기íš<C3AD>서 (Plans)
|
## 기íš<C3AD>서 (Plans)
|
||||||
|
|
||||||
|
- [기능 로드맵 (Feature Roadmap)](Plans/Feature_Roadmap.md)
|
||||||
|
- [임시 음성 채널 기능 기획서 (Temp Voice Channel Plan)](Plans/Temp_Voice_Channel_Plan.md)
|
||||||
|
- [에러 안내 기능 기획서 (Error Guidance Plan)](Plans/Error_Guidance_Plan.md)
|
||||||
|
- [봇 상태 메시지 기획 (Bot Presence Plan)](Plans/Bot_Presence_Plan.md)
|
||||||
- [ì—<EFBFBD>러 안내 기능 기íš<C3AD>서 (Error Guidance Plan)](Plans/Error_Guidance_Plan.md)
|
- [ì—<EFBFBD>러 안내 기능 기íš<C3AD>서 (Error Guidance Plan)](Plans/Error_Guidance_Plan.md)
|
||||||
- [다êµì–´ ì§€ì›<C3AC> 기íš<C3AD>서 (i18n Plan)](Plans/i18n_Plan.md)
|
- [다êµì–´ ì§€ì›<C3AC> 기íš<C3AD>서 (i18n Plan)](Plans/i18n_Plan.md)
|
||||||
- [서버 ì<>´ë²¤íЏ ì<>¼ì • 관리 기능 기íš<C3AD>안 (Event Schedule Management Plan)](Plans/Event_Schedule_Management_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)
|
- [YouTube ì<>Œì•… 재ìƒ<C3AC> 기능 기íš<C3AD>안 (YouTube Music Playback Plan)](Plans/YouTube_Music_Playback_Plan.md)
|
||||||
- [재련 미니게임 기획서 (Refinement Mini-Game Plan)](Plans/MiniGame_Refinement_Plan.md)
|
|
||||||
|
|
||||||
|
|
||||||
## 아키í…<C3AD>처 ë°<C3AB> ì •ì±… ê²°ì • (Decisions)
|
## 아키í…<C3AD>처 ë°<C3AB> ì •ì±… ê²°ì • (Decisions)
|
||||||
|
|
@ -54,4 +57,3 @@
|
||||||
- [2026-03-30: YouTube À½¾Ç Àç»ý Phase 1 ±¸Çö (YouTube Music Playback Phase 1 Implementation)](WorkDone/2026-03-30_YouTube_Music_Playback_Phase1_Implementation.md)
|
- [2026-03-30: YouTube À½¾Ç Àç»ý Phase 1 ±¸Çö (YouTube Music Playback Phase 1 Implementation)](WorkDone/2026-03-30_YouTube_Music_Playback_Phase1_Implementation.md)
|
||||||
- [2026-03-31: YouTube À½¾Ç Àç»ý Phase 2 ±¸Çö (YouTube Music Playback Phase 2 Implementation)](WorkDone/2026-03-31_YouTube_Music_Playback_Phase2_Implementation.md)
|
- [2026-03-31: YouTube À½¾Ç Àç»ý Phase 2 ±¸Çö (YouTube Music Playback Phase 2 Implementation)](WorkDone/2026-03-31_YouTube_Music_Playback_Phase2_Implementation.md)
|
||||||
- [2026-03-31: YouTube À½¾Ç Àç»ý Phase 3 ±¸Çö (YouTube Music Playback Phase 3 Implementation)](WorkDone/2026-03-31_YouTube_Music_Playback_Phase3_Implementation.md)
|
- [2026-03-31: YouTube À½¾Ç Àç»ý Phase 3 ±¸Çö (YouTube Music Playback Phase 3 Implementation)](WorkDone/2026-03-31_YouTube_Music_Playback_Phase3_Implementation.md)
|
||||||
- [2026-03-30: 미니게임 시스템 및 재련 게임 구현 (Mini-Game System & Refinement Implementation)](WorkDone/2026-03-30_RefinementImplementation.md)
|
|
||||||
|
|
|
||||||
|
|
@ -67,7 +67,4 @@ Kord는 Discord 서버 관리를 돕는 강력하고 유연한 다기능 봇입
|
||||||
- **서버 설정 (Config)**: 따라하기(Mimic), 큰 이모지(Big Emoji) 등 봇의 기능을 서버별 환경에 맞게 토글하거나 설정할 수 있습니다.
|
- **서버 설정 (Config)**: 따라하기(Mimic), 큰 이모지(Big Emoji) 등 봇의 기능을 서버별 환경에 맞게 토글하거나 설정할 수 있습니다.
|
||||||
- **권한 감사 (Permission Audit)**: 봇의 정상 작동을 방해하는 권한 문제를 즉시 진단하고 해결 방법을 안내합니다.
|
- **권한 감사 (Permission Audit)**: 봇의 정상 작동을 방해하는 권한 문제를 즉시 진단하고 해결 방법을 안내합니다.
|
||||||
- **초기 설정 마법사 (Setup Wizard)**: 봇 도입 초기 기능을 한눈에 설정할 수 있는 직관적인 UI를 제공합니다.
|
- **초기 설정 마법사 (Setup Wizard)**: 봇 도입 초기 기능을 한눈에 설정할 수 있는 직관적인 UI를 제공합니다.
|
||||||
- **미니게임 시스템 (Mini-Games)**: 서버 내에서 즐길 수 있는 다양한 미니게임을 제공하며, 관리자가 게임별 활성화 및 전용 채널을 설정할 수 있습니다.
|
|
||||||
- **재련 (Refinement)**: 무기를 강화하고 다른 유저와 전투하며 골드를 획득하는 성장형 미니게임입니다.
|
|
||||||
- **피버 타임 (Fever Time)**: 서버 활동량을 자동으로 분석하여 가장 활발한 시간대에 재련 성공 확률 보너스를 제공합니다.
|
|
||||||
- **다국어 지원 (i18n)**: 한국어와 영어를 포함한 다국어 환경을 지원합니다.
|
- **다국어 지원 (i18n)**: 한국어와 영어를 포함한 다국어 환경을 지원합니다.
|
||||||
|
|
|
||||||
10
package.json
10
package.json
|
|
@ -4,14 +4,11 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@discordjs/opus": "^0.10.0",
|
"@discordjs/opus": "^0.10.0",
|
||||||
"@discordjs/voice": "^0.19.2",
|
"@discordjs/voice": "^0.19.2",
|
||||||
"@prisma/adapter-pg": "^7.6.0",
|
"@prisma/client": "6.4.1",
|
||||||
"@prisma/client": "7.6.0",
|
|
||||||
"@types/pg": "^8.20.0",
|
|
||||||
"discord.js": "^14.25.1",
|
"discord.js": "^14.25.1",
|
||||||
"dotenv": "^17.3.1",
|
"dotenv": "^17.3.1",
|
||||||
"ffmpeg-static": "^5.3.0",
|
"ffmpeg-static": "^5.3.0",
|
||||||
"ioredis": "^5.10.1",
|
"ioredis": "^5.10.1",
|
||||||
"pg": "^8.20.0",
|
|
||||||
"prism-media": "^1.3.5",
|
"prism-media": "^1.3.5",
|
||||||
"youtubei.js": "^17.0.1"
|
"youtubei.js": "^17.0.1"
|
||||||
},
|
},
|
||||||
|
|
@ -23,7 +20,7 @@
|
||||||
"eslint": "^10.1.0",
|
"eslint": "^10.1.0",
|
||||||
"jest": "^30.3.0",
|
"jest": "^30.3.0",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
"prisma": "7.6.0",
|
"prisma": "6.4.1",
|
||||||
"ts-jest": "^29.4.6",
|
"ts-jest": "^29.4.6",
|
||||||
"tsx": "^4.21.0",
|
"tsx": "^4.21.0",
|
||||||
"typescript": "^6.0.2"
|
"typescript": "^6.0.2"
|
||||||
|
|
@ -34,8 +31,5 @@
|
||||||
"start": "node dist/index.js",
|
"start": "node dist/index.js",
|
||||||
"test": "jest",
|
"test": "jest",
|
||||||
"check-i18n": "tsx scripts/check-i18n-tests.ts"
|
"check-i18n": "tsx scripts/check-i18n-tests.ts"
|
||||||
},
|
|
||||||
"prisma": {
|
|
||||||
"seed": "tsx prisma/seed.ts"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
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',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
@ -1,72 +0,0 @@
|
||||||
-- 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");
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
-- AlterTable
|
|
||||||
ALTER TABLE "RefinementProfile" ADD COLUMN "dailyBattleCount" INTEGER NOT NULL DEFAULT 0,
|
|
||||||
ADD COLUMN "lastBattleReset" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
-- 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")
|
|
||||||
);
|
|
||||||
116
prisma/seed.ts
116
prisma/seed.ts
|
|
@ -1,116 +0,0 @@
|
||||||
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();
|
|
||||||
});
|
|
||||||
|
|
@ -1,133 +0,0 @@
|
||||||
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 });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
@ -1,277 +0,0 @@
|
||||||
import {
|
|
||||||
SlashCommandBuilder,
|
|
||||||
ChatInputCommandInteraction,
|
|
||||||
EmbedBuilder,
|
|
||||||
Colors,
|
|
||||||
ButtonBuilder,
|
|
||||||
ButtonStyle,
|
|
||||||
ActionRowBuilder,
|
|
||||||
ComponentType
|
|
||||||
} from 'discord.js';
|
|
||||||
import { prisma } from '../database';
|
|
||||||
import { SupportedLocale } from '../i18n';
|
|
||||||
import { RefinementService } from '../services/RefinementService';
|
|
||||||
import { FeverService } from '../services/FeverService';
|
|
||||||
|
|
||||||
export default {
|
|
||||||
data: new SlashCommandBuilder()
|
|
||||||
.setName('refine')
|
|
||||||
.setDescription('Mini-game: Refinement')
|
|
||||||
.setDescriptionLocalizations({
|
|
||||||
ko: '미니게임: 재련 (무기 강화 및 전투)',
|
|
||||||
})
|
|
||||||
// --- Try Subcommand ---
|
|
||||||
.addSubcommand(subcommand =>
|
|
||||||
subcommand
|
|
||||||
.setName('try')
|
|
||||||
.setDescription('Try to refine your weapon.')
|
|
||||||
.setDescriptionLocalizations({ ko: '무기 재련을 시도합니다.' })
|
|
||||||
)
|
|
||||||
// --- Battle Subcommand ---
|
|
||||||
.addSubcommand(subcommand =>
|
|
||||||
subcommand
|
|
||||||
.setName('battle')
|
|
||||||
.setDescription('Battle with another user.')
|
|
||||||
.setDescriptionLocalizations({ ko: '다른 유저와 전투를 수행합니다.' })
|
|
||||||
.addUserOption(option =>
|
|
||||||
option.setName('target')
|
|
||||||
.setDescription('The user to attack')
|
|
||||||
.setRequired(true)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
// --- Checkin Subcommand ---
|
|
||||||
.addSubcommand(subcommand =>
|
|
||||||
subcommand
|
|
||||||
.setName('checkin')
|
|
||||||
.setDescription('Daily check-in to get gold.')
|
|
||||||
.setDescriptionLocalizations({ ko: '일일 출석 및 골드를 수령합니다.' })
|
|
||||||
)
|
|
||||||
// --- Profile Subcommand ---
|
|
||||||
.addSubcommand(subcommand =>
|
|
||||||
subcommand
|
|
||||||
.setName('profile')
|
|
||||||
.setDescription('View refinement profile.')
|
|
||||||
.setDescriptionLocalizations({ ko: '재련 프로필을 확인합니다.' })
|
|
||||||
.addUserOption(option =>
|
|
||||||
option.setName('user')
|
|
||||||
.setDescription('User to view')
|
|
||||||
)
|
|
||||||
)
|
|
||||||
// --- Ranking Subcommand ---
|
|
||||||
.addSubcommand(subcommand =>
|
|
||||||
subcommand
|
|
||||||
.setName('ranking')
|
|
||||||
.setDescription('View server rankings.')
|
|
||||||
.setDescriptionLocalizations({ ko: '서버 랭킹을 확인합니다.' })
|
|
||||||
.addStringOption(option =>
|
|
||||||
option.setName('type')
|
|
||||||
.setDescription('Ranking type')
|
|
||||||
.setRequired(true)
|
|
||||||
.addChoices(
|
|
||||||
{ name: '최대 강화 단계', value: 'max_level' },
|
|
||||||
{ name: '승률', value: 'win_rate' },
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
// --- Sell Subcommand ---
|
|
||||||
.addSubcommand(subcommand =>
|
|
||||||
subcommand
|
|
||||||
.setName('sell')
|
|
||||||
.setDescription('Sell your weapon and reset to level 0.')
|
|
||||||
.setDescriptionLocalizations({ ko: '무기를 판매하고 0단계로 회귀합니다.' })
|
|
||||||
)
|
|
||||||
// --- Help Subcommand ---
|
|
||||||
.addSubcommand(subcommand =>
|
|
||||||
subcommand
|
|
||||||
.setName('help')
|
|
||||||
.setDescription('Get help about the refinement game.')
|
|
||||||
.setDescriptionLocalizations({ ko: '재련 게임 도움말을 확인합니다.' })
|
|
||||||
),
|
|
||||||
|
|
||||||
async execute(interaction: ChatInputCommandInteraction, locale: SupportedLocale) {
|
|
||||||
if (!interaction.guildId) return;
|
|
||||||
|
|
||||||
// 1. 미니게임 활성화 및 전용 채널 체크
|
|
||||||
const config = await prisma.miniGameConfig.findUnique({
|
|
||||||
where: { guildId_gameKey: { guildId: interaction.guildId, gameKey: 'refinement' } }
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!config || !config.enabled) {
|
|
||||||
return interaction.reply({ content: '❌ 이 서버에서는 재련 미니게임이 비활성화되어 있습니다.', ephemeral: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (config.channelId && config.channelId !== interaction.channelId) {
|
|
||||||
return interaction.reply({ content: `❌ 재련 미니게임은 <#${config.channelId}> 채널에서만 이용 가능합니다.`, ephemeral: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
const subcommand = interaction.options.getSubcommand();
|
|
||||||
|
|
||||||
// --- TRY ---
|
|
||||||
if (subcommand === 'try') {
|
|
||||||
try {
|
|
||||||
const result = await RefinementService.tryRefine(interaction.user.id, interaction.guildId);
|
|
||||||
const fever = await FeverService.getFeverBonus(interaction.guildId);
|
|
||||||
|
|
||||||
const embed = new EmbedBuilder()
|
|
||||||
.setTitle(result.success ? '✨ 재련 성공!' : (result.destroyed ? '🧨 무기 파괴!' : '❌ 재련 실패...'))
|
|
||||||
.setColor(result.success ? Colors.Green : (result.destroyed ? Colors.Red : Colors.Grey))
|
|
||||||
.addFields(
|
|
||||||
{ name: '강화 단계', value: `${result.levelBefore} → **${result.levelAfter}**`, inline: true },
|
|
||||||
{ name: '비용', value: `${result.cost} G`, inline: true },
|
|
||||||
{ name: '잔액', value: `${result.remainingGold} G`, inline: true }
|
|
||||||
);
|
|
||||||
|
|
||||||
if (fever.active) {
|
|
||||||
embed.setFooter({ text: `🔥 피버 타임 활성화 중! (성공률 +${fever.bonusRate * 100}%)` });
|
|
||||||
}
|
|
||||||
|
|
||||||
const retryBtn = new ButtonBuilder()
|
|
||||||
.setCustomId(`refine_try_again:${interaction.user.id}`)
|
|
||||||
.setLabel('다시 시도')
|
|
||||||
.setStyle(ButtonStyle.Primary);
|
|
||||||
|
|
||||||
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(retryBtn);
|
|
||||||
|
|
||||||
return interaction.reply({ embeds: [embed], components: [row] });
|
|
||||||
} catch (err: any) {
|
|
||||||
return interaction.reply({ content: `❌ 오류: ${err.message}`, ephemeral: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- BATTLE ---
|
|
||||||
if (subcommand === 'battle') {
|
|
||||||
const targetUser = interaction.options.getUser('target', true);
|
|
||||||
if (targetUser.id === interaction.user.id) {
|
|
||||||
return interaction.reply({ content: '❌ 자신을 공격할 수 없습니다.', ephemeral: true });
|
|
||||||
}
|
|
||||||
if (targetUser.bot) {
|
|
||||||
return interaction.reply({ content: '❌ 봇과 전투할 수 없습니다.', ephemeral: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await RefinementService.startBattle(interaction.user.id, targetUser.id, interaction.guildId);
|
|
||||||
|
|
||||||
const embed = new EmbedBuilder()
|
|
||||||
.setTitle('⚔️ 전투 결과')
|
|
||||||
.setColor(result.winnerId === interaction.user.id ? Colors.Gold : Colors.Red)
|
|
||||||
.setDescription(`<@${result.attackerId}> 님과 <@${result.targetId}> 님의 대결!`)
|
|
||||||
.addFields(
|
|
||||||
{ name: '승자', value: `<@${result.winnerId}>`, inline: false },
|
|
||||||
{ name: '패자', value: `<@${result.loserId}>`, inline: false },
|
|
||||||
{ name: '보상', value: `${result.reward} G`, inline: true },
|
|
||||||
{ name: '내 단계', value: `${result.attackerLevel}강`, inline: true },
|
|
||||||
{ name: '상대 단계', value: `${result.targetLevel}강`, inline: true }
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result.destroyed) {
|
|
||||||
embed.addFields({ name: '🧨 무기 파괴', value: '공격자가 내구도 0인 상태에서 공격하여 무기가 완전히 파괴되었습니다!', inline: false });
|
|
||||||
} else {
|
|
||||||
embed.addFields({ name: '🛡️ 내구도', value: `나: ${result.attackerDurability} | 상대: ${result.targetDurability}`, inline: false });
|
|
||||||
if (result.attackerDurability <= 0) embed.setFooter({ text: '⚠️ [경고] 무기가 전투 불능이 되었습니다. 내구도 0에서 다시 공격하면 파괴됩니다!' });
|
|
||||||
}
|
|
||||||
|
|
||||||
return interaction.reply({ embeds: [embed] });
|
|
||||||
} catch (err: any) {
|
|
||||||
return interaction.reply({ content: `❌ 오류: ${err.message}`, ephemeral: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- CHECKIN ---
|
|
||||||
if (subcommand === 'checkin') {
|
|
||||||
try {
|
|
||||||
const res = await RefinementService.checkIn(interaction.user.id, interaction.guildId);
|
|
||||||
return interaction.reply({ content: `✅ 출석 완료! **${res.goldAdded} G**를 수령했습니다. (총: ${res.totalGold} G)`, ephemeral: true });
|
|
||||||
} catch (err: any) {
|
|
||||||
return interaction.reply({ content: `❌ 오류: ${err.message}`, ephemeral: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- PROFILE ---
|
|
||||||
if (subcommand === 'profile') {
|
|
||||||
const targetUser = interaction.options.getUser('user') || interaction.user;
|
|
||||||
const profile = await RefinementService.getProfile(targetUser.id, interaction.guildId);
|
|
||||||
|
|
||||||
const maxDurability = RefinementService.getMaxDurability(profile.weaponLevel);
|
|
||||||
const embed = new EmbedBuilder()
|
|
||||||
.setTitle(`👤 ${targetUser.username} 님의 재련 프로필`)
|
|
||||||
.setColor(Colors.Blue)
|
|
||||||
.setThumbnail(targetUser.displayAvatarURL())
|
|
||||||
.addFields(
|
|
||||||
{ name: '현재 무기', value: `${profile.weaponLevel} 단계`, inline: true },
|
|
||||||
{ name: '최대 무기', value: `${profile.maxWeaponLevel} 단계`, inline: true },
|
|
||||||
{ name: '소지 골드', value: `${profile.gold} G`, inline: true },
|
|
||||||
{ name: '내구도', value: `${profile.durability} / ${maxDurability}`, inline: true },
|
|
||||||
{ name: '전투 상태', value: profile.isDisabled ? '❌ 전투 불능' : '✅ 양호', inline: true },
|
|
||||||
{ name: '일일 공격', value: `${profile.dailyBattleCount} / 10`, inline: true },
|
|
||||||
{ name: '출석', value: profile.lastCheckIn ? profile.lastCheckIn.toLocaleDateString() : '미기록', inline: true },
|
|
||||||
{ name: '통계', value: `시도: ${profile.tryCount} | 성공: ${profile.successCount} | 실패: ${profile.failCount} | 파괴: ${profile.destroyCount}`, inline: false },
|
|
||||||
{ name: '전투', value: `승리: ${profile.battleWin} | 패배: ${profile.battleLoss}`, inline: false }
|
|
||||||
);
|
|
||||||
|
|
||||||
return interaction.reply({ embeds: [embed] });
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- RANKING ---
|
|
||||||
if (subcommand === 'ranking') {
|
|
||||||
const type = interaction.options.getString('type', true);
|
|
||||||
const ranking = await prisma.refinementProfile.findMany({
|
|
||||||
where: { guildId: interaction.guildId },
|
|
||||||
orderBy: type === 'max_level' ? { maxWeaponLevel: 'desc' } : undefined,
|
|
||||||
take: 10
|
|
||||||
});
|
|
||||||
|
|
||||||
let rankingList = ranking;
|
|
||||||
if (type === 'win_rate') {
|
|
||||||
rankingList = ranking
|
|
||||||
.sort((a, b) => {
|
|
||||||
const rateA = a.battleWin / (a.battleWin + a.battleLoss || 1);
|
|
||||||
const rateB = b.battleWin / (b.battleWin + b.battleLoss || 1);
|
|
||||||
return rateB - rateA;
|
|
||||||
})
|
|
||||||
.slice(0, 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
const embed = new EmbedBuilder()
|
|
||||||
.setTitle(type === 'max_level' ? '🏆 서버 최고 강화 랭킹' : '🏆 서버 최고 승률 랭킹')
|
|
||||||
.setColor(Colors.Gold);
|
|
||||||
|
|
||||||
const listStr = rankingList.map((p, i) => {
|
|
||||||
const val = type === 'max_level'
|
|
||||||
? `${p.maxWeaponLevel}단계`
|
|
||||||
: `${Math.round((p.battleWin / (p.battleWin + p.battleLoss || 1)) * 100)}% (${p.battleWin}승)`;
|
|
||||||
return `${i + 1}. <@${p.userId}> - **${val}**`;
|
|
||||||
}).join('\n') || '데이터가 없습니다.';
|
|
||||||
|
|
||||||
embed.setDescription(listStr);
|
|
||||||
return interaction.reply({ embeds: [embed] });
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- SELL ---
|
|
||||||
if (subcommand === 'sell') {
|
|
||||||
try {
|
|
||||||
const res = await RefinementService.sellWeapon(interaction.user.id, interaction.guildId);
|
|
||||||
return interaction.reply({ content: `💰 **${res.level}단계** 무기를 판매하고 **${res.price} G**를 받았습니다. (총: ${res.gold} G)`, ephemeral: true });
|
|
||||||
} catch (err: any) {
|
|
||||||
return interaction.reply({ content: `❌ 오류: ${err.message}`, ephemeral: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- HELP ---
|
|
||||||
if (subcommand === 'help') {
|
|
||||||
const embed = new EmbedBuilder()
|
|
||||||
.setTitle('🎮 재련 미니게임 도움말')
|
|
||||||
.setColor(Colors.White)
|
|
||||||
.setDescription('무기를 강화하고 다른 유저와 전투하며 강력한 전사가 되어보세요!')
|
|
||||||
.addFields(
|
|
||||||
{ name: '🛠️ 재련 (/refine try)', value: '골드를 소모하여 강화합니다. 5강부터 확률이 극악이며, 성공 시 **내구도가 최대치(10+레벨)**로 수리됩니다.' },
|
|
||||||
{ name: '⚔️ 전투 (/refine battle)', value: '다른 유저를 공격합니다. **일일 10회 제한**이 있으며, 5강 이상 차이나는 강자 공격 시 승률이 급락(최대 0%)합니다.' },
|
|
||||||
{ name: '🛡️ 내구도 및 수명', value: '단계가 높을수록 최대 내구도가 증가하여 더 오래 사용할 수 있습니다. 내구도 0에서 공격 시 무기가 파괴됩니다.' },
|
|
||||||
{ name: '📅 출석 (/refine checkin)', value: '매일 한 번 골드를 수령할 수 있습니다.' },
|
|
||||||
{ name: '💰 판매 (/refine sell)', value: '무기를 팔고 0단계로 돌아갑니다. 고강화 무기는 난이도(기대값)에 비례해 **수억 골드**의 가치를 가집니다.' },
|
|
||||||
{ name: '🔥 피버 타임', value: '서버가 가장 활발한 시간대에 재련 성공 확률이 10% 증가합니다!' }
|
|
||||||
);
|
|
||||||
|
|
||||||
return interaction.reply({ embeds: [embed] });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
@ -1,24 +1,14 @@
|
||||||
import { Pool } from 'pg';
|
|
||||||
import { PrismaPg } from '@prisma/adapter-pg';
|
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
import { logger } from '../utils/logger';
|
import { logger } from '../utils/logger';
|
||||||
|
|
||||||
// Prisma 7 requires a driver adapter for direct database connections.
|
|
||||||
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
|
||||||
const adapter = new PrismaPg(pool);
|
|
||||||
|
|
||||||
export const prisma = new PrismaClient({
|
export const prisma = new PrismaClient({
|
||||||
adapter,
|
|
||||||
log: ['warn', 'error'],
|
log: ['warn', 'error'],
|
||||||
});
|
});
|
||||||
|
|
||||||
export const connectDB = async () => {
|
export const connectDB = async () => {
|
||||||
try {
|
try {
|
||||||
// Adapter-based client connects when first used,
|
await prisma.$connect();
|
||||||
// but we can test the pool connection here.
|
logger.info('Connected to PostgreSQL successfully.');
|
||||||
const client = await pool.connect();
|
|
||||||
client.release();
|
|
||||||
logger.info('Connected to PostgreSQL successfully via Driver Adapter.');
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to connect to PostgreSQL:', error);
|
logger.error('Failed to connect to PostgreSQL:', error);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
|
|
|
||||||
|
|
@ -35,13 +35,6 @@ export default {
|
||||||
await handleSetupWizardInteraction(interaction, locale);
|
await handleSetupWizardInteraction(interaction, locale);
|
||||||
}, locale);
|
}, locale);
|
||||||
}
|
}
|
||||||
else if (interaction.isButton() && interaction.customId.startsWith('refine_')) {
|
|
||||||
const { handleRefinementInteraction } = require('../interactions/handlers/refinementHandler');
|
|
||||||
const locale = await getInteractionLocale(interaction);
|
|
||||||
await withErrorHandler(interaction, async () => {
|
|
||||||
await handleRefinementInteraction(interaction, locale);
|
|
||||||
}, locale);
|
|
||||||
}
|
|
||||||
else if (interaction.isStringSelectMenu()) {
|
else if (interaction.isStringSelectMenu()) {
|
||||||
const customId = interaction.customId;
|
const customId = interaction.customId;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ import { Events, Message } from 'discord.js';
|
||||||
import { MimicService } from '../services/MimicService';
|
import { MimicService } from '../services/MimicService';
|
||||||
import { BigEmojiService } from '../services/BigEmojiService';
|
import { BigEmojiService } from '../services/BigEmojiService';
|
||||||
import { prisma } from '../database';
|
import { prisma } from '../database';
|
||||||
import { ActivityTrackerService } from '../services/ActivityTrackerService';
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: Events.MessageCreate,
|
name: Events.MessageCreate,
|
||||||
|
|
@ -10,9 +9,6 @@ export default {
|
||||||
async execute(message: Message) {
|
async execute(message: Message) {
|
||||||
if (!message.guildId || message.author.bot) return;
|
if (!message.guildId || message.author.bot) return;
|
||||||
|
|
||||||
// 활동 추적 기록
|
|
||||||
await ActivityTrackerService.recordActivity(message.guildId);
|
|
||||||
|
|
||||||
const config = await prisma.guildConfig.findUnique({
|
const config = await prisma.guildConfig.findUnique({
|
||||||
where: { guildId: message.guildId }
|
where: { guildId: message.guildId }
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,61 +0,0 @@
|
||||||
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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
@ -1,57 +0,0 @@
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,82 +0,0 @@
|
||||||
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 };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
export interface MiniGame {
|
|
||||||
key: string;
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const MINI_GAMES: Record<string, MiniGame> = {
|
|
||||||
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);
|
|
||||||
};
|
|
||||||
|
|
@ -1,318 +0,0 @@
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Loading…
Reference in New Issue