feat: externalize refinement and battle balance configurations to database with runtime caching
This commit is contained in:
parent
2762801fbd
commit
49a2855f2b
|
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
);
|
||||||
|
|
@ -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())
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
|
@ -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 }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue