Compare commits

...

8 Commits

30 changed files with 2457 additions and 83 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,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

@ -16,14 +16,11 @@
## 기íš<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)
- [다국어 ì§€ì<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)
- [재련 미니게임 기획서 (Refinement Mini-Game Plan)](Plans/MiniGame_Refinement_Plan.md)
## ì•„í¤í…<C3AD>처 ë°<C3AB> ì •ì±… ê²°ì • (Decisions)
@ -57,3 +54,4 @@
- [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)

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

@ -1,3 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"
provider = "postgresql"

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

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

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

@ -35,6 +35,13 @@ export default {
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

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

View File

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

1062
yarn.lock

File diff suppressed because it is too large Load Diff