Compare commits
4 Commits
0c7a562b00
...
49a2855f2b
| Author | SHA1 | Date |
|---|---|---|
|
|
49a2855f2b | |
|
|
2762801fbd | |
|
|
cc26613377 | |
|
|
793bba5a6a |
|
|
@ -9,10 +9,24 @@ The Refinement mini-game allows users to strengthen their virtual weapons, parti
|
||||||
|
|
||||||
### 1. Weapon Refinement
|
### 1. Weapon Refinement
|
||||||
- **Progression**: Level- **재련 비용**: `floor(10 × 1.6^level)G` (레벨별 기하급수적 증가)
|
- **Progression**: Level- **재련 비용**: `floor(10 × 1.6^level)G` (레벨별 기하급수적 증가)
|
||||||
- **판매 가격**: `floor(현재_단계_비용 × 2)G`
|
- **판매 가격**: `floor(현재_단계_비용 × 보정계수)G`
|
||||||
- **내구도**: 전투 참여 시(공격/방어 모두) 내구도 -1.
|
- 0~4강: 비용의 **2배**
|
||||||
- **파괴 조건**:
|
- 5~25강: 비용의 **(2 + (L-4) × 10)배** (기대값 보정으로 고강화 가치 극대화)
|
||||||
- 재련 실패 시 낮은 확률로 파괴 (0단계 회귀).
|
- **재련 성공 확률**:
|
||||||
|
- 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인 상태에서 전투 시도(공격) 시 전투 후 무기 파괴.
|
- 내구도가 0인 상태에서 전투 시도(공격) 시 전투 후 무기 파괴.
|
||||||
as the level increases.
|
as the level increases.
|
||||||
- **Risk**: Failure at higher levels can lead to weapon destruction (reset to level 0).
|
- **Risk**: Failure at higher levels can lead to weapon destruction (reset to level 0).
|
||||||
|
|
|
||||||
|
|
@ -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,3 @@
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "RefinementProfile" ADD COLUMN "dailyBattleCount" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
ADD COLUMN "lastBattleReset" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
|
||||||
|
|
@ -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")
|
||||||
|
);
|
||||||
|
|
@ -127,27 +127,56 @@ model MiniGameConfig {
|
||||||
|
|
||||||
// 재련 - 유저 상태
|
// 재련 - 유저 상태
|
||||||
model RefinementProfile {
|
model RefinementProfile {
|
||||||
userId String
|
userId String
|
||||||
guildId String
|
guildId String
|
||||||
gold Int @default(1000)
|
gold Int @default(1000)
|
||||||
weaponLevel Int @default(0)
|
weaponLevel Int @default(0)
|
||||||
maxWeaponLevel Int @default(0)
|
maxWeaponLevel Int @default(0)
|
||||||
durability Int @default(10)
|
durability Int @default(10)
|
||||||
tryCount Int @default(0)
|
tryCount Int @default(0)
|
||||||
successCount Int @default(0)
|
successCount Int @default(0)
|
||||||
failCount Int @default(0)
|
failCount Int @default(0)
|
||||||
destroyCount Int @default(0)
|
destroyCount Int @default(0)
|
||||||
battleWin Int @default(0)
|
battleWin Int @default(0)
|
||||||
battleLoss Int @default(0)
|
battleLoss Int @default(0)
|
||||||
isDisabled Boolean @default(false)
|
dailyBattleCount Int @default(0)
|
||||||
lastCheckIn DateTime?
|
lastBattleReset DateTime @default(now())
|
||||||
createdAt DateTime @default(now())
|
isDisabled Boolean @default(false)
|
||||||
updatedAt DateTime @updatedAt
|
lastCheckIn DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@id([userId, guildId])
|
@@id([userId, guildId])
|
||||||
@@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();
|
||||||
|
});
|
||||||
|
|
@ -191,6 +191,7 @@ export default {
|
||||||
const targetUser = interaction.options.getUser('user') || interaction.user;
|
const targetUser = interaction.options.getUser('user') || interaction.user;
|
||||||
const profile = await RefinementService.getProfile(targetUser.id, interaction.guildId);
|
const profile = await RefinementService.getProfile(targetUser.id, interaction.guildId);
|
||||||
|
|
||||||
|
const maxDurability = RefinementService.getMaxDurability(profile.weaponLevel);
|
||||||
const embed = new EmbedBuilder()
|
const embed = new EmbedBuilder()
|
||||||
.setTitle(`👤 ${targetUser.username} 님의 재련 프로필`)
|
.setTitle(`👤 ${targetUser.username} 님의 재련 프로필`)
|
||||||
.setColor(Colors.Blue)
|
.setColor(Colors.Blue)
|
||||||
|
|
@ -199,8 +200,9 @@ export default {
|
||||||
{ name: '현재 무기', value: `${profile.weaponLevel} 단계`, inline: true },
|
{ name: '현재 무기', value: `${profile.weaponLevel} 단계`, inline: true },
|
||||||
{ name: '최대 무기', value: `${profile.maxWeaponLevel} 단계`, inline: true },
|
{ name: '최대 무기', value: `${profile.maxWeaponLevel} 단계`, inline: true },
|
||||||
{ name: '소지 골드', value: `${profile.gold} G`, inline: true },
|
{ name: '소지 골드', value: `${profile.gold} G`, inline: true },
|
||||||
{ name: '내구도', value: `${profile.durability}`, inline: true },
|
{ name: '내구도', value: `${profile.durability} / ${maxDurability}`, inline: true },
|
||||||
{ name: '전투 상태', value: profile.isDisabled ? '❌ 전투 불능' : '✅ 양호', 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.lastCheckIn ? profile.lastCheckIn.toLocaleDateString() : '미기록', inline: true },
|
||||||
{ name: '통계', value: `시도: ${profile.tryCount} | 성공: ${profile.successCount} | 실패: ${profile.failCount} | 파괴: ${profile.destroyCount}`, inline: false },
|
{ name: '통계', value: `시도: ${profile.tryCount} | 성공: ${profile.successCount} | 실패: ${profile.failCount} | 파괴: ${profile.destroyCount}`, inline: false },
|
||||||
{ name: '전투', value: `승리: ${profile.battleWin} | 패배: ${profile.battleLoss}`, inline: false }
|
{ name: '전투', value: `승리: ${profile.battleWin} | 패배: ${profile.battleLoss}`, inline: false }
|
||||||
|
|
@ -261,11 +263,11 @@ export default {
|
||||||
.setColor(Colors.White)
|
.setColor(Colors.White)
|
||||||
.setDescription('무기를 강화하고 다른 유저와 전투하며 강력한 전사가 되어보세요!')
|
.setDescription('무기를 강화하고 다른 유저와 전투하며 강력한 전사가 되어보세요!')
|
||||||
.addFields(
|
.addFields(
|
||||||
{ name: '🛠️ 재련 (/refine try)', value: '골드를 소모하여 무기 수치를 올립니다. 단계가 높을수록 실패 시 파괴될 확률이 증가합니다. (최대 20단계)' },
|
{ name: '🛠️ 재련 (/refine try)', value: '골드를 소모하여 강화합니다. 5강부터 확률이 극악이며, 성공 시 **내구도가 최대치(10+레벨)**로 수리됩니다.' },
|
||||||
{ name: '⚔️ 전투 (/refine battle)', value: '다른 유저를 일방적으로 공격합니다. 승리 시 보상을 받습니다. 전투 참여자 모두 내구도가 1씩 감소합니다.' },
|
{ name: '⚔️ 전투 (/refine battle)', value: '다른 유저를 공격합니다. **일일 10회 제한**이 있으며, 5강 이상 차이나는 강자 공격 시 승률이 급락(최대 0%)합니다.' },
|
||||||
{ name: '🛡️ 내구도 및 파괴', value: '내구도가 0이 되면 전투 불능이 되며, 이 상태에서 또 공격하면 무기가 즉시 파기됩니다! 재련 시도 로 무기를 수리하세요.' },
|
{ name: '🛡️ 내구도 및 수명', value: '단계가 높을수록 최대 내구도가 증가하여 더 오래 사용할 수 있습니다. 내구도 0에서 공격 시 무기가 파괴됩니다.' },
|
||||||
{ name: '📅 출석 (/refine checkin)', value: '매일 한 번 골드를 수령할 수 있습니다.' },
|
{ name: '📅 출석 (/refine checkin)', value: '매일 한 번 골드를 수령할 수 있습니다.' },
|
||||||
{ name: '💰 판매 (/refine sell)', value: '강화된 무기를 팔고 0단계로 돌아갑니다. 단계가 높을수록 큰돈을 만질 수 있습니다.' },
|
{ name: '💰 판매 (/refine sell)', value: '무기를 팔고 0단계로 돌아갑니다. 고강화 무기는 난이도(기대값)에 비례해 **수억 골드**의 가치를 가집니다.' },
|
||||||
{ name: '🔥 피버 타임', value: '서버가 가장 활발한 시간대에 재련 성공 확률이 10% 증가합니다!' }
|
{ name: '🔥 피버 타임', value: '서버가 가장 활발한 시간대에 재련 성공 확률이 10% 증가합니다!' }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,33 +26,83 @@ export interface BattleResult {
|
||||||
}
|
}
|
||||||
|
|
||||||
export class RefinementService {
|
export class RefinementService {
|
||||||
private static MAX_LEVEL = 20;
|
// 캐시 필드
|
||||||
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)
|
||||||
|
*/
|
||||||
|
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> {
|
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 = Math.floor(10 * Math.pow(1.6, 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);
|
||||||
// 확률 계산 (20단계 기준 조정)
|
|
||||||
// 기본 성공률 = max(5%, 80% - level * 4%)
|
|
||||||
const baseSuccessRate = Math.max(0.05, 0.8 - profile.weaponLevel * 0.04);
|
|
||||||
const successRate = baseSuccessRate + (fever.active ? fever.bonusRate : 0);
|
|
||||||
|
|
||||||
const random = Math.random();
|
const random = Math.random();
|
||||||
const success = random <= successRate;
|
const success = random <= successRate;
|
||||||
|
|
@ -60,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 = 10;
|
|
||||||
} 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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -102,58 +151,80 @@ 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);
|
||||||
|
|
||||||
if (attacker.isDisabled) throw new Error('현재 전투 불능 상태입니다. 무기 수리(재련 시도 등)가 필요합니다.');
|
// 0. 일일 공격 횟수 체크 및 리셋 (KST 기준 자정 리셋)
|
||||||
|
const now = new Date();
|
||||||
let attackerDestroyed = false;
|
const lastReset = attacker.lastBattleReset || new Date(0);
|
||||||
if (attacker.durability <= 0) {
|
const isNewDay = now.toDateString() !== lastReset.toDateString();
|
||||||
attackerDestroyed = true;
|
|
||||||
|
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) {
|
||||||
// 우세 확률 = 0.5 + (내강화도 - 상대강화도) * 0.05 (최소 10%, 최대 90%)
|
throw new Error('0강 무기 소유자는 전투에 참여할 수 없습니다. 최소 1강 이상 강화하세요.');
|
||||||
const winRate = Math.max(0.1, Math.min(0.9, 0.5 + (attacker.weaponLevel - target.weaponLevel) * 0.05));
|
}
|
||||||
const isAttackerWin = Math.random() <= winRate;
|
|
||||||
|
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 winnerId = isAttackerWin ? attackerId : targetId;
|
||||||
const loserId = isAttackerWin ? targetId : attackerId;
|
const loserId = isAttackerWin ? targetId : attackerId;
|
||||||
|
|
||||||
// 보상 계산: 내 강화도 * 50G +- |차이| * 10G
|
// 보상 계산: 승리자 역배율 적용 (TargetCost × TargetLevel/WinnerLevel × Multiplier)
|
||||||
const levelDiff = Math.abs(attacker.weaponLevel - target.weaponLevel);
|
const levelDiff = Math.abs(attacker.weaponLevel - target.weaponLevel);
|
||||||
const baseReward = (isAttackerWin ? attacker.weaponLevel : target.weaponLevel) * 50;
|
const winnerLevel = isAttackerWin ? attacker.weaponLevel : target.weaponLevel;
|
||||||
const reward = Math.max(100, baseReward + (isAttackerWin ? -levelDiff : levelDiff) * 10);
|
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)
|
// 내구도 감소 (양쪽 모두 -1)
|
||||||
const newAttackerDurability = Math.max(0, attacker.durability - 1);
|
const newAttackerDurability = Math.max(0, attacker.durability - 1);
|
||||||
const newTargetDurability = Math.max(0, target.durability - 1);
|
const newTargetDurability = Math.max(0, target.durability - 1);
|
||||||
|
|
||||||
// 무기 파괴/불능 처리
|
// 무기 파괴/불능 처리
|
||||||
// 공격자가 내구도 0인 상태에서 공격했으면 전투 후 파괴
|
const attackerDestroyed = false;
|
||||||
const attackerWeaponLevel = attackerDestroyed ? 0 : attacker.weaponLevel;
|
|
||||||
|
|
||||||
await prisma.$transaction([
|
await prisma.$transaction([
|
||||||
prisma.refinementProfile.update({
|
prisma.refinementProfile.update({
|
||||||
where: { userId_guildId: { userId: attackerId, guildId } },
|
where: { userId_guildId: { userId: attackerId, guildId } },
|
||||||
data: {
|
data: {
|
||||||
gold: isAttackerWin ? { increment: reward } : undefined,
|
gold: attacker.gold + (isAttackerWin ? reward : 0),
|
||||||
durability: newAttackerDurability,
|
durability: newAttackerDurability,
|
||||||
weaponLevel: attackerDestroyed ? 0 : undefined,
|
|
||||||
isDisabled: newAttackerDurability <= 0,
|
isDisabled: newAttackerDurability <= 0,
|
||||||
battleWin: isAttackerWin ? { increment: 1 } : undefined,
|
battleWin: isAttackerWin ? attacker.battleWin + 1 : attacker.battleWin,
|
||||||
battleLoss: !isAttackerWin ? { increment: 1 } : undefined,
|
battleLoss: isAttackerWin ? attacker.battleLoss : attacker.battleLoss + 1,
|
||||||
destroyCount: attackerDestroyed ? { increment: 1 } : undefined,
|
dailyBattleCount: currentDailyCount + 1,
|
||||||
|
lastBattleReset: now,
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
prisma.refinementProfile.update({
|
prisma.refinementProfile.update({
|
||||||
where: { userId_guildId: { userId: targetId, guildId } },
|
where: { userId_guildId: { userId: targetId, guildId } },
|
||||||
data: {
|
data: {
|
||||||
gold: !isAttackerWin ? { increment: reward } : undefined,
|
gold: target.gold + (!isAttackerWin ? reward : 0),
|
||||||
durability: newTargetDurability,
|
durability: newTargetDurability,
|
||||||
isDisabled: newTargetDurability <= 0,
|
isDisabled: newTargetDurability <= 0,
|
||||||
battleWin: !isAttackerWin ? { increment: 1 } : undefined,
|
battleWin: !isAttackerWin ? target.battleWin + 1 : target.battleWin,
|
||||||
battleLoss: isAttackerWin ? { increment: 1 } : undefined,
|
battleLoss: !isAttackerWin ? target.battleLoss : target.battleLoss + 1,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
]);
|
]);
|
||||||
|
|
@ -188,26 +259,29 @@ 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);
|
||||||
if (profile.weaponLevel === 0) throw new Error('0단계 무기는 판매할 수 없습니다.');
|
const level = profile.weaponLevel;
|
||||||
|
const cost = this.getCost(level);
|
||||||
const currentCost = Math.floor(10 * Math.pow(1.6, profile.weaponLevel));
|
|
||||||
const price = Math.floor(currentCost * 2);
|
const multiplier = this.levelConfigs.get(level)?.sellMultiplier ?? 1;
|
||||||
|
const price = Math.floor(cost * multiplier);
|
||||||
|
|
||||||
const updated = await prisma.refinementProfile.update({
|
const updated = await prisma.refinementProfile.update({
|
||||||
where: { userId_guildId: { userId, guildId } },
|
where: { userId_guildId: { userId, guildId } },
|
||||||
|
|
@ -232,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