310 lines
10 KiB
TypeScript
310 lines
10 KiB
TypeScript
import { prisma } from '../database';
|
||
import { logger } from '../utils/logger';
|
||
import { FeverService } from './FeverService';
|
||
|
||
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 MAX_LEVEL = 25;
|
||
private static START_GOLD = 1000;
|
||
private static CHECKIN_GOLD = 500;
|
||
|
||
/**
|
||
* 레벨에 따른 최대 내구도 계산 (10 + level)
|
||
*/
|
||
public static getMaxDurability(level: number): number {
|
||
return 10 + level;
|
||
}
|
||
|
||
/**
|
||
* 레벨에 따른 강화 비용 계산
|
||
*/
|
||
public static calculateCost(level: number): number {
|
||
return Math.floor(10 * Math.pow(1.6, level));
|
||
}
|
||
|
||
/**
|
||
* 재련 시도
|
||
*/
|
||
public static async tryRefine(userId: string, guildId: string): Promise<RefineResult> {
|
||
const profile = await this.getOrCreateProfile(userId, guildId);
|
||
|
||
if (profile.weaponLevel >= this.MAX_LEVEL) {
|
||
throw new Error(`이미 최대 단계(${this.MAX_LEVEL}강)에 도달했습니다. 무기를 판매하고 다시 시작하세요!`);
|
||
}
|
||
|
||
// 비용 계산: floor(10 * 1.6^level)
|
||
const cost = this.calculateCost(profile.weaponLevel);
|
||
|
||
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 random = Math.random();
|
||
const success = random <= successRate;
|
||
let destroyed = false;
|
||
let newLevel = profile.weaponLevel;
|
||
let newDurability = profile.durability;
|
||
|
||
if (success) {
|
||
newLevel = Math.min(this.MAX_LEVEL, profile.weaponLevel + 1);
|
||
// 내구도 회복 (단계 상승 시 새로운 최대치로)
|
||
newDurability = this.getMaxDurability(newLevel);
|
||
} else {
|
||
// 실패 시 파괴 확률: level * 1.5%
|
||
const destroyRate = profile.weaponLevel * 0.015;
|
||
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> {
|
||
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();
|
||
|
||
let currentDailyCount = isNewDay ? 0 : attacker.dailyBattleCount;
|
||
if (currentDailyCount >= 10) {
|
||
throw new Error('일일 공격 제한(10회)에 도달했습니다. 내일 다시 도전하세요!');
|
||
}
|
||
|
||
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;
|
||
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 isAttackerWin = Math.random() < winRate;
|
||
|
||
const winnerId = isAttackerWin ? attackerId : targetId;
|
||
const loserId = isAttackerWin ? targetId : attackerId;
|
||
|
||
// 보상 계산: 승리자 역배율 적용 (TargetCost × TargetLevel/WinnerLevel × 2.0)
|
||
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);
|
||
|
||
// 승자 레벨이 높을수록 보상 삭감 계수 (1.0 ~ 0.04 사이)
|
||
const ratio = Math.min(1.0, loserLevel / winnerLevel);
|
||
const baseReward = Math.floor(loserCost * ratio * 2.0);
|
||
|
||
// 랜덤폭 (Min 80% ~ Max 120%)
|
||
const randomFactor = 0.8 + Math.random() * 0.4;
|
||
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 updated = await prisma.refinementProfile.update({
|
||
where: { userId_guildId: { userId, guildId } },
|
||
data: {
|
||
gold: { increment: this.CHECKIN_GOLD },
|
||
lastCheckIn: now,
|
||
}
|
||
});
|
||
|
||
return { goldAdded: this.CHECKIN_GOLD, totalGold: updated.gold };
|
||
}
|
||
|
||
/**
|
||
* 무기 판매
|
||
*/
|
||
public static async sellWeapon(userId: string, guildId: string): Promise<{ level: number; price: number; gold: number }> {
|
||
const profile = await this.getOrCreateProfile(userId, guildId);
|
||
const level = profile.weaponLevel;
|
||
const cost = this.calculateCost(level);
|
||
|
||
// 기대값 기반 판매가 개편
|
||
// 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 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) {
|
||
profile = await prisma.refinementProfile.create({
|
||
data: { userId, guildId, gold: this.START_GOLD }
|
||
});
|
||
}
|
||
|
||
return profile;
|
||
}
|
||
}
|