feat: externalize refinement and battle balance configurations to database with runtime caching

This commit is contained in:
이정수 2026-03-30 18:00:46 +09:00
parent 2762801fbd
commit 49a2855f2b
6 changed files with 260 additions and 70 deletions

View File

@ -29,5 +29,8 @@
"start": "node dist/index.js", "start": "node dist/index.js",
"test": "jest", "test": "jest",
"check-i18n": "tsx scripts/check-i18n-tests.ts" "check-i18n": "tsx scripts/check-i18n-tests.ts"
},
"prisma": {
"seed": "tsx prisma/seed.ts"
} }
} }

View File

@ -7,4 +7,7 @@ export default defineConfig({
datasource: { datasource: {
url: process.env.DATABASE_URL!, url: process.env.DATABASE_URL!,
}, },
migrations: {
seed: 'tsx ./prisma/seed.ts',
},
}); });

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

@ -150,6 +150,33 @@ model RefinementProfile {
@@index([guildId, weaponLevel(sort: Desc)]) @@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 { model ActivityLog {
id String @id @default(uuid()) id String @id @default(uuid())

116
prisma/seed.ts Normal file
View File

@ -0,0 +1,116 @@
import { Pool } from 'pg';
import { PrismaPg } from '@prisma/adapter-pg';
import { PrismaClient } from '@prisma/client';
import 'dotenv/config';
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const adapter = new PrismaPg(pool);
const prisma = new PrismaClient({ adapter });
async function main() {
console.log('🌱 Start seeding refinement balance data...');
// 1. Refinement System Config (Global)
const systemConfigs = [
{ key: 'MAX_LEVEL', value: '25', description: 'Maximum weapon level' },
{ key: 'START_GOLD', value: '1000', description: 'Initial gold for new players' },
{ key: 'CHECKIN_GOLD', value: '500', description: 'Gold awarded for daily check-in' },
{ key: 'DAILY_BATTLE_LIMIT', value: '10', description: 'Maximum battles per day' },
{ key: 'INVERSE_REWARD_MULTIPLIER', value: '2.0', description: 'Base multiplier for battle rewards' },
{ key: 'REWARD_RANDOM_MIN', value: '0.8', description: 'Minimum random factor for rewards' },
{ key: 'REWARD_RANDOM_MAX', value: '1.2', description: 'Maximum random factor for rewards' },
];
for (const config of systemConfigs) {
await prisma.refinementSystemConfig.upsert({
where: { key: config.key },
update: config,
create: config,
});
}
// 2. Refinement Level Config (0-25)
const levelConfigs = [];
for (let l = 0; l <= 25; l++) {
let successRate = 0;
if (l === 0) successRate = 1.0;
else if (l === 1) successRate = 0.98;
else if (l === 2) successRate = 0.95;
else if (l === 3) successRate = 0.92;
else if (l === 4) successRate = 0.90;
else if (l === 5) successRate = 0.05;
else if (l === 6) successRate = 0.04;
else if (l === 7) successRate = 0.03;
else if (l === 8) successRate = 0.02;
else if (l < 10) successRate = 0.01;
else if (l < 20) successRate = 0.001;
else successRate = 0.0001;
const cost = Math.floor(10 * Math.pow(1.6, l));
const destroyRate = l * 0.015;
const sellMultiplier = l < 5 ? 2.0 : 2 + (l - 4) * 10;
levelConfigs.push({
level: l,
successRate,
destroyRate,
cost,
sellMultiplier,
});
}
for (const config of levelConfigs) {
await prisma.refinementLevelConfig.upsert({
where: { level: config.level },
update: config,
create: config,
});
}
// 3. Refinement Battle Config (Gap)
const battleConfigs = [];
for (let g = 0; g <= 25; g++) {
battleConfigs.push({ gap: g, winRate: Math.min(0.99, 0.5 + g * 0.05) });
}
const negativeGaps = [
{ gap: -1, rate: 0.4 },
{ gap: -2, rate: 0.3 },
{ gap: -3, rate: 0.2 },
{ gap: -4, rate: 0.1 },
{ gap: -5, rate: 0.05 },
{ gap: -6, rate: 0.04 },
{ gap: -7, rate: 0.03 },
{ gap: -8, rate: 0.02 },
{ gap: -9, rate: 0.01 },
];
for (const n of negativeGaps) {
battleConfigs.push({ gap: n.gap, winRate: n.rate });
}
for (let g = -10; g >= -25; g--) {
battleConfigs.push({ gap: g, winRate: 0 });
}
for (const config of battleConfigs) {
await prisma.refinementBattleConfig.upsert({
where: { levelGap: config.gap },
update: { winRate: config.winRate },
create: { levelGap: config.gap, winRate: config.winRate },
});
}
console.log('✅ Seeding completed!');
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
await pool.end();
});

View File

@ -1,6 +1,7 @@
import { prisma } from '../database'; import { prisma } from '../database';
import { logger } from '../utils/logger'; import { logger } from '../utils/logger';
import { FeverService } from './FeverService'; import { FeverService } from './FeverService';
import { RefinementLevelConfig, RefinementBattleConfig, RefinementSystemConfig } from '@prisma/client';
export interface RefineResult { export interface RefineResult {
success: boolean; success: boolean;
@ -25,10 +26,12 @@ export interface BattleResult {
} }
export class RefinementService { export class RefinementService {
private static MAX_LEVEL = 25; // 캐시 필드
private static START_GOLD = 1000; private static levelConfigs = new Map<number, RefinementLevelConfig>();
private static CHECKIN_GOLD = 500; private static battleConfigs = new Map<number, number>(); // Gap -> WinRate
private static systemConfigs = new Map<string, string>(); // Key -> Value
private static isInitialized = false;
/** /**
* (10 + level) * (10 + level)
*/ */
@ -37,48 +40,69 @@ export class RefinementService {
} }
/** /**
* * DB에서
*/ */
public static calculateCost(level: number): number { public static async loadConfigs(force: boolean = false) {
return Math.floor(10 * Math.pow(1.6, level)); 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> { public static async tryRefine(userId: string, guildId: string): Promise<RefineResult> {
await this.loadConfigs();
const profile = await this.getOrCreateProfile(userId, guildId); const profile = await this.getOrCreateProfile(userId, guildId);
if (profile.weaponLevel >= this.MAX_LEVEL) { const maxLevel = this.getSysConfigNum('MAX_LEVEL', 25);
throw new Error(`이미 최대 단계(${this.MAX_LEVEL}강)에 도달했습니다. 무기를 판매하고 다시 시작하세요!`); if (profile.weaponLevel >= maxLevel) {
throw new Error(`이미 최대 단계(${maxLevel}강)에 도달했습니다. 무기를 판매하고 다시 시작하세요!`);
} }
// 비용 계산: floor(10 * 1.6^level) const levelConfig = this.levelConfigs.get(profile.weaponLevel);
const cost = this.calculateCost(profile.weaponLevel); if (!levelConfig) throw new Error('강화 정보를 불러올 수 없습니다.');
const cost = levelConfig.cost;
if (profile.gold < cost) { if (profile.gold < cost) {
throw new Error('골드가 부족합니다.'); throw new Error('골드가 부족합니다.');
} }
const fever = await FeverService.getFeverBonus(guildId); const fever = await FeverService.getFeverBonus(guildId);
const successRate = levelConfig.successRate + (fever.active ? fever.bonusRate : 0);
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 random = Math.random(); const random = Math.random();
const success = random <= successRate; const success = random <= successRate;
@ -87,16 +111,14 @@ export class RefinementService {
let newDurability = profile.durability; let newDurability = profile.durability;
if (success) { if (success) {
newLevel = Math.min(this.MAX_LEVEL, profile.weaponLevel + 1); newLevel = Math.min(maxLevel, profile.weaponLevel + 1);
// 내구도 회복 (단계 상승 시 새로운 최대치로)
newDurability = this.getMaxDurability(newLevel); newDurability = this.getMaxDurability(newLevel);
} else { } else {
// 실패 시 파괴 확률: level * 1.5% const destroyRate = levelConfig.destroyRate;
const destroyRate = profile.weaponLevel * 0.015;
if (Math.random() <= destroyRate) { if (Math.random() <= destroyRate) {
destroyed = true; destroyed = true;
newLevel = 0; newLevel = 0;
newDurability = 10; // 파괴 시 초기화 newDurability = 10;
} }
} }
@ -129,6 +151,7 @@ export class RefinementService {
* () * ()
*/ */
public static async startBattle(attackerId: string, targetId: string, guildId: string): Promise<BattleResult> { public static async startBattle(attackerId: string, targetId: string, guildId: string): Promise<BattleResult> {
await this.loadConfigs();
const attacker = await this.getOrCreateProfile(attackerId, guildId); const attacker = await this.getOrCreateProfile(attackerId, guildId);
const target = await this.getOrCreateProfile(targetId, guildId); const target = await this.getOrCreateProfile(targetId, guildId);
@ -137,9 +160,10 @@ export class RefinementService {
const lastReset = attacker.lastBattleReset || new Date(0); const lastReset = attacker.lastBattleReset || new Date(0);
const isNewDay = now.toDateString() !== lastReset.toDateString(); const isNewDay = now.toDateString() !== lastReset.toDateString();
const dailyLimit = this.getSysConfigNum('DAILY_BATTLE_LIMIT', 10);
let currentDailyCount = isNewDay ? 0 : attacker.dailyBattleCount; let currentDailyCount = isNewDay ? 0 : attacker.dailyBattleCount;
if (currentDailyCount >= 10) { if (currentDailyCount >= dailyLimit) {
throw new Error('일일 공격 제한(10회)에 도달했습니다. 내일 다시 도전하세요!'); throw new Error(`일일 공격 제한(${dailyLimit}회)에 도달했습니다. 내일 다시 도전하세요!`);
} }
if (attacker.weaponLevel === 0 || target.weaponLevel === 0) { if (attacker.weaponLevel === 0 || target.weaponLevel === 0) {
@ -148,43 +172,29 @@ export class RefinementService {
if (attacker.isDisabled) throw new Error('현재 전투 불능 상태입니다. 무기 수리(재련 시도 등)가 필요합니다.'); if (attacker.isDisabled) throw new Error('현재 전투 불능 상태입니다. 무기 수리(재련 시도 등)가 필요합니다.');
// 1. 승패 확률 계산 (비선형 패널티 적용) // 1. 승패 확률 가져오기
const diff = attacker.weaponLevel - target.weaponLevel; const diff = attacker.weaponLevel - target.weaponLevel;
let winRate = 0.5; const winRate = this.battleConfigs.get(diff) ?? (diff > 0 ? 0.99 : 0);
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 isAttackerWin = Math.random() < winRate; const isAttackerWin = Math.random() < winRate;
const winnerId = isAttackerWin ? attackerId : targetId; const winnerId = isAttackerWin ? attackerId : targetId;
const loserId = isAttackerWin ? targetId : attackerId; const loserId = isAttackerWin ? targetId : attackerId;
// 보상 계산: 승리자 역배율 적용 (TargetCost × TargetLevel/WinnerLevel × 2.0) // 보상 계산: 승리자 역배율 적용 (TargetCost × TargetLevel/WinnerLevel × Multiplier)
const levelDiff = Math.abs(attacker.weaponLevel - target.weaponLevel); const levelDiff = Math.abs(attacker.weaponLevel - target.weaponLevel);
const winnerLevel = isAttackerWin ? attacker.weaponLevel : target.weaponLevel; const winnerLevel = isAttackerWin ? attacker.weaponLevel : target.weaponLevel;
const loserLevel = isAttackerWin ? target.weaponLevel : attacker.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 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 = randomMin + Math.random() * (randomMax - randomMin);
const randomFactor = 0.8 + Math.random() * 0.4;
const reward = Math.floor(baseReward * randomFactor) + (levelDiff * 100); const reward = Math.floor(baseReward * randomFactor) + (levelDiff * 100);
// 내구도 감소 (양쪽 모두 -1) // 내구도 감소 (양쪽 모두 -1)
@ -249,31 +259,28 @@ export class RefinementService {
} }
} }
const checkInGold = this.getSysConfigNum('CHECKIN_GOLD', 500);
const updated = await prisma.refinementProfile.update({ const updated = await prisma.refinementProfile.update({
where: { userId_guildId: { userId, guildId } }, where: { userId_guildId: { userId, guildId } },
data: { data: {
gold: { increment: this.CHECKIN_GOLD }, gold: { increment: checkInGold },
lastCheckIn: now, 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 }> { 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 profile = await this.getOrCreateProfile(userId, guildId);
const level = profile.weaponLevel; const level = profile.weaponLevel;
const cost = this.calculateCost(level); const cost = this.getCost(level);
// 기대값 기반 판매가 개편 const multiplier = this.levelConfigs.get(level)?.sellMultiplier ?? 1;
// 0-4: 2배, 5-25: (2 + (L-4)*10)배
let multiplier = 2;
if (level >= 5) {
multiplier = 2 + (level - 4) * 10;
}
const price = Math.floor(cost * multiplier); const price = Math.floor(cost * multiplier);
const updated = await prisma.refinementProfile.update({ const updated = await prisma.refinementProfile.update({
@ -299,8 +306,10 @@ export class RefinementService {
}); });
if (!profile) { if (!profile) {
await this.loadConfigs();
const startGold = this.getSysConfigNum('START_GOLD', 1000);
profile = await prisma.refinementProfile.create({ profile = await prisma.refinementProfile.create({
data: { userId, guildId, gold: this.START_GOLD } data: { userId, guildId, gold: startGold }
}); });
} }