diff --git a/package.json b/package.json index aa4f224..29ddc9c 100644 --- a/package.json +++ b/package.json @@ -29,5 +29,8 @@ "start": "node dist/index.js", "test": "jest", "check-i18n": "tsx scripts/check-i18n-tests.ts" + }, + "prisma": { + "seed": "tsx prisma/seed.ts" } } diff --git a/prisma.config.ts b/prisma.config.ts index fb22145..3268749 100644 --- a/prisma.config.ts +++ b/prisma.config.ts @@ -7,4 +7,7 @@ export default defineConfig({ datasource: { url: process.env.DATABASE_URL!, }, + migrations: { + seed: 'tsx ./prisma/seed.ts', + }, }); diff --git a/prisma/migrations/20260330085711_externalize_balance/migration.sql b/prisma/migrations/20260330085711_externalize_balance/migration.sql new file mode 100644 index 0000000..3203e80 --- /dev/null +++ b/prisma/migrations/20260330085711_externalize_balance/migration.sql @@ -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") +); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 5bf37f6..8c0dc50 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -150,6 +150,33 @@ model RefinementProfile { @@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()) diff --git a/prisma/seed.ts b/prisma/seed.ts new file mode 100644 index 0000000..e4bf505 --- /dev/null +++ b/prisma/seed.ts @@ -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(); + }); diff --git a/src/services/RefinementService.ts b/src/services/RefinementService.ts index 2bbd81a..38ae93b 100644 --- a/src/services/RefinementService.ts +++ b/src/services/RefinementService.ts @@ -1,6 +1,7 @@ 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; @@ -25,10 +26,12 @@ export interface BattleResult { } export class RefinementService { - private static MAX_LEVEL = 25; - private static START_GOLD = 1000; - private static CHECKIN_GOLD = 500; - + // 캐시 필드 + private static levelConfigs = new Map(); + private static battleConfigs = new Map(); // Gap -> WinRate + private static systemConfigs = new Map(); // Key -> Value + private static isInitialized = false; + /** * 레벨에 따른 최대 내구도 계산 (10 + level) */ @@ -37,48 +40,69 @@ export class RefinementService { } /** - * 레벨에 따른 강화 비용 계산 + * DB에서 밸런스 설정을 로드하여 메모리에 캐싱 */ - public static calculateCost(level: number): number { - return Math.floor(10 * Math.pow(1.6, level)); + 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 { + await this.loadConfigs(); const profile = await this.getOrCreateProfile(userId, guildId); - if (profile.weaponLevel >= this.MAX_LEVEL) { - throw new Error(`이미 최대 단계(${this.MAX_LEVEL}강)에 도달했습니다. 무기를 판매하고 다시 시작하세요!`); + const maxLevel = this.getSysConfigNum('MAX_LEVEL', 25); + if (profile.weaponLevel >= maxLevel) { + throw new Error(`이미 최대 단계(${maxLevel}강)에 도달했습니다. 무기를 판매하고 다시 시작하세요!`); } - // 비용 계산: floor(10 * 1.6^level) - const cost = this.calculateCost(profile.weaponLevel); + 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 level = profile.weaponLevel; - let baseSuccessRate = 0; - - if (level === 0) baseSuccessRate = 1.0; - else if (level === 1) baseSuccessRate = 0.98; - else if (level === 2) baseSuccessRate = 0.95; - else if (level === 3) baseSuccessRate = 0.92; - else if (level === 4) baseSuccessRate = 0.90; - else if (level === 5) baseSuccessRate = 0.05; // 급격한 하향 구간 시작 - else if (level === 6) baseSuccessRate = 0.04; - else if (level === 7) baseSuccessRate = 0.03; - else if (level === 8) baseSuccessRate = 0.02; - else if (level < 10) baseSuccessRate = 0.01; - else if (level < 20) baseSuccessRate = 0.001; // 0.1% - else baseSuccessRate = 0.0001; // 0.01% (신화적 구간: 21-25강) - - const successRate = baseSuccessRate + (fever.active ? fever.bonusRate : 0); + const successRate = levelConfig.successRate + (fever.active ? fever.bonusRate : 0); const random = Math.random(); const success = random <= successRate; @@ -87,16 +111,14 @@ export class RefinementService { let newDurability = profile.durability; if (success) { - newLevel = Math.min(this.MAX_LEVEL, profile.weaponLevel + 1); - // 내구도 회복 (단계 상승 시 새로운 최대치로) + newLevel = Math.min(maxLevel, profile.weaponLevel + 1); newDurability = this.getMaxDurability(newLevel); } else { - // 실패 시 파괴 확률: level * 1.5% - const destroyRate = profile.weaponLevel * 0.015; + const destroyRate = levelConfig.destroyRate; if (Math.random() <= destroyRate) { destroyed = true; newLevel = 0; - newDurability = 10; // 파괴 시 초기화 + newDurability = 10; } } @@ -129,6 +151,7 @@ export class RefinementService { * 전투 수행 (일방적) */ public static async startBattle(attackerId: string, targetId: string, guildId: string): Promise { + await this.loadConfigs(); const attacker = await this.getOrCreateProfile(attackerId, guildId); const target = await this.getOrCreateProfile(targetId, guildId); @@ -137,9 +160,10 @@ export class RefinementService { 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 >= 10) { - throw new Error('일일 공격 제한(10회)에 도달했습니다. 내일 다시 도전하세요!'); + if (currentDailyCount >= dailyLimit) { + throw new Error(`일일 공격 제한(${dailyLimit}회)에 도달했습니다. 내일 다시 도전하세요!`); } if (attacker.weaponLevel === 0 || target.weaponLevel === 0) { @@ -148,43 +172,29 @@ export class RefinementService { if (attacker.isDisabled) throw new Error('현재 전투 불능 상태입니다. 무기 수리(재련 시도 등)가 필요합니다.'); - // 1. 승패 확률 계산 (비선형 패널티 적용) + // 1. 승패 확률 가져오기 const diff = attacker.weaponLevel - target.weaponLevel; - let winRate = 0.5; - - if (diff >= 0) { - winRate = Math.min(0.99, 0.5 + diff * 0.05); - } else { - // 약자 공격 시 페널티 (5강 차이부터 급락) - if (diff === -1) winRate = 0.4; - else if (diff === -2) winRate = 0.3; - else if (diff === -3) winRate = 0.2; - else if (diff === -4) winRate = 0.1; - else if (diff === -5) winRate = 0.05; - else if (diff === -6) winRate = 0.04; - else if (diff === -7) winRate = 0.03; - else if (diff === -8) winRate = 0.02; - else if (diff === -9) winRate = 0.01; - else winRate = 0; // 10강 차이 이상은 절대 불가 - } + 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 × 2.0) + // 보상 계산: 승리자 역배율 적용 (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.calculateCost(loserLevel); + const loserCost = this.getCost(loserLevel); - // 승자 레벨이 높을수록 보상 삭감 계수 (1.0 ~ 0.04 사이) + 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 * 2.0); + const baseReward = Math.floor(loserCost * ratio * inverseMultiplier); - // 랜덤폭 (Min 80% ~ Max 120%) - const randomFactor = 0.8 + Math.random() * 0.4; + const randomFactor = randomMin + Math.random() * (randomMax - randomMin); const reward = Math.floor(baseReward * randomFactor) + (levelDiff * 100); // 내구도 감소 (양쪽 모두 -1) @@ -249,31 +259,28 @@ export class RefinementService { } } + const checkInGold = this.getSysConfigNum('CHECKIN_GOLD', 500); const updated = await prisma.refinementProfile.update({ where: { userId_guildId: { userId, guildId } }, data: { - gold: { increment: this.CHECKIN_GOLD }, + gold: { increment: checkInGold }, lastCheckIn: now, } }); - return { goldAdded: this.CHECKIN_GOLD, totalGold: updated.gold }; + 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.calculateCost(level); + const cost = this.getCost(level); - // 기대값 기반 판매가 개편 - // 0-4: 2배, 5-25: (2 + (L-4)*10)배 - let multiplier = 2; - if (level >= 5) { - multiplier = 2 + (level - 4) * 10; - } + const multiplier = this.levelConfigs.get(level)?.sellMultiplier ?? 1; const price = Math.floor(cost * multiplier); const updated = await prisma.refinementProfile.update({ @@ -299,8 +306,10 @@ export class RefinementService { }); if (!profile) { + await this.loadConfigs(); + const startGold = this.getSysConfigNum('START_GOLD', 1000); profile = await prisma.refinementProfile.create({ - data: { userId, guildId, gold: this.START_GOLD } + data: { userId, guildId, gold: startGold } }); }