Files
asciinevrdie/lib/src/core/engine/potion_service.dart
JiWoong Sul 9e5472728f refactor(potion): 물약 자동 사용 조건 변경
- 임계치 기반 → 소모량 기반 조건 전환
- HP/MP 소모량 >= 물약 회복량일 때 사용
- emergencyHpThreshold, emergencyMpThreshold 상수 제거
- 우선순위 HP > MP 유지
2026-01-16 00:15:38 +09:00

515 lines
14 KiB
Dart

import 'dart:math' as math;
import 'package:asciineverdie/data/potion_data.dart';
import 'package:asciineverdie/src/core/model/monster_grade.dart';
import 'package:asciineverdie/src/core/model/potion.dart';
/// 물약 서비스
///
/// 물약 사용, 자동 사용 알고리즘, 인벤토리 관리
class PotionService {
const PotionService();
/// 글로벌 물약 쿨타임 (1배속 기준 3초)
static const int globalPotionCooldownMs = 3000;
// ============================================================================
// 물약 사용 가능 여부
// ============================================================================
/// 물약 사용 가능 여부 체크
///
/// [potionId] 물약 ID
/// [inventory] 물약 인벤토리
/// Returns: (사용 가능 여부, 실패 사유)
(bool, PotionUseFailReason?) canUsePotion(
String potionId,
PotionInventory inventory,
) {
// 물약 데이터 존재 체크
final potion = PotionData.getById(potionId);
if (potion == null) {
return (false, PotionUseFailReason.potionNotFound);
}
// 보유 수량 체크
if (!inventory.hasPotion(potionId)) {
return (false, PotionUseFailReason.outOfStock);
}
return (true, null);
}
// ============================================================================
// 물약 사용
// ============================================================================
/// 물약 사용
///
/// [potionId] 물약 ID
/// [inventory] 물약 인벤토리
/// [currentHp] 현재 HP
/// [maxHp] 최대 HP
/// [currentMp] 현재 MP
/// [maxMp] 최대 MP
PotionUseResult usePotion({
required String potionId,
required PotionInventory inventory,
required int currentHp,
required int maxHp,
required int currentMp,
required int maxMp,
}) {
final (canUse, failReason) = canUsePotion(potionId, inventory);
if (!canUse) {
return PotionUseResult.failed(failReason!);
}
final potion = PotionData.getById(potionId)!;
int healedAmount = 0;
int newHp = currentHp;
int newMp = currentMp;
if (potion.isHpPotion) {
healedAmount = potion.calculateHeal(maxHp);
newHp = (currentHp + healedAmount).clamp(0, maxHp);
healedAmount = newHp - currentHp; // 실제 회복량
} else if (potion.isMpPotion) {
healedAmount = potion.calculateHeal(maxMp);
newMp = (currentMp + healedAmount).clamp(0, maxMp);
healedAmount = newMp - currentMp; // 실제 회복량
}
final newInventory = inventory.usePotion(potionId);
return PotionUseResult(
success: true,
potion: potion,
healedAmount: healedAmount,
newHp: newHp,
newMp: newMp,
newInventory: newInventory,
);
}
// ============================================================================
// 긴급 물약 자동 사용
// ============================================================================
/// 긴급 HP 물약 선택
///
/// 소모된 HP >= 물약 회복량이면 사용할 최적의 물약 선택
/// [currentHp] 현재 HP
/// [maxHp] 최대 HP
/// [inventory] 물약 인벤토리
/// [playerLevel] 플레이어 레벨 (적정 티어 판단용)
Potion? selectEmergencyHpPotion({
required int currentHp,
required int maxHp,
required PotionInventory inventory,
required int playerLevel,
}) {
final hpLost = maxHp - currentHp;
if (hpLost <= 0) return null;
// 적정 티어 계산
final targetTier = PotionData.tierForLevel(playerLevel);
// 적정 티어부터 낮은 티어 순으로 검색
for (var tier = targetTier; tier >= 1; tier--) {
final potion = PotionData.getHpPotionByTier(tier);
if (potion != null && inventory.hasPotion(potion.id)) {
final healAmount = potion.calculateHeal(maxHp);
if (hpLost >= healAmount) {
return potion;
}
}
}
// 적정 티어 이상도 검색
for (var tier = targetTier + 1; tier <= 5; tier++) {
final potion = PotionData.getHpPotionByTier(tier);
if (potion != null && inventory.hasPotion(potion.id)) {
final healAmount = potion.calculateHeal(maxHp);
if (hpLost >= healAmount) {
return potion;
}
}
}
return null;
}
/// 긴급 MP 물약 선택
///
/// 소모된 MP >= 물약 회복량이면 사용할 최적의 물약 선택
Potion? selectEmergencyMpPotion({
required int currentMp,
required int maxMp,
required PotionInventory inventory,
required int playerLevel,
}) {
final mpLost = maxMp - currentMp;
if (mpLost <= 0) return null;
// 적정 티어 계산
final targetTier = PotionData.tierForLevel(playerLevel);
// 적정 티어부터 낮은 티어 순으로 검색
for (var tier = targetTier; tier >= 1; tier--) {
final potion = PotionData.getMpPotionByTier(tier);
if (potion != null && inventory.hasPotion(potion.id)) {
final healAmount = potion.calculateHeal(maxMp);
if (mpLost >= healAmount) {
return potion;
}
}
}
// 적정 티어 이상도 검색
for (var tier = targetTier + 1; tier <= 5; tier++) {
final potion = PotionData.getMpPotionByTier(tier);
if (potion != null && inventory.hasPotion(potion.id)) {
final healAmount = potion.calculateHeal(maxMp);
if (mpLost >= healAmount) {
return potion;
}
}
}
return null;
}
// ============================================================================
// 인벤토리 관리
// ============================================================================
/// 물약 드랍 추가
PotionInventory addPotionDrop(
PotionInventory inventory,
String potionId, [
int count = 1,
]) {
return inventory.addPotion(potionId, count);
}
// ============================================================================
// 물약 구매 시스템
// ============================================================================
/// 물약 구매 가능 여부 체크
///
/// [potionId] 물약 ID
/// [gold] 보유 골드
/// Returns: (구매 가능 여부, 실패 사유)
(bool, PotionPurchaseFailReason?) canPurchasePotion(
String potionId,
int gold,
) {
final potion = PotionData.getById(potionId);
if (potion == null) {
return (false, PotionPurchaseFailReason.potionNotFound);
}
if (gold < potion.price) {
return (false, PotionPurchaseFailReason.insufficientGold);
}
return (true, null);
}
/// 물약 구매
///
/// [potionId] 물약 ID
/// [inventory] 현재 물약 인벤토리
/// [gold] 보유 골드
/// [count] 구매 수량 (기본 1)
PotionPurchaseResult purchasePotion({
required String potionId,
required PotionInventory inventory,
required int gold,
int count = 1,
}) {
final potion = PotionData.getById(potionId);
if (potion == null) {
return PotionPurchaseResult.failed(
PotionPurchaseFailReason.potionNotFound,
);
}
final totalCost = potion.price * count;
if (gold < totalCost) {
return PotionPurchaseResult.failed(
PotionPurchaseFailReason.insufficientGold,
);
}
final newInventory = inventory.addPotion(potionId, count);
final newGold = gold - totalCost;
return PotionPurchaseResult(
success: true,
potion: potion,
quantity: count,
totalCost: totalCost,
newGold: newGold,
newInventory: newInventory,
);
}
/// 레벨에 맞는 물약 자동 구매
///
/// 골드의 일정 비율을 물약 구매에 사용
/// [playerLevel] 플레이어 레벨
/// [inventory] 현재 물약 인벤토리
/// [gold] 보유 골드
/// [spendRatio] 골드 사용 비율 (기본 20%)
PotionPurchaseResult autoPurchasePotions({
required int playerLevel,
required PotionInventory inventory,
required int gold,
double spendRatio = 0.20,
}) {
final tier = PotionData.tierForLevel(playerLevel);
final hpPotion = PotionData.getHpPotionByTier(tier);
final mpPotion = PotionData.getMpPotionByTier(tier);
if (hpPotion == null && mpPotion == null) {
return PotionPurchaseResult.failed(
PotionPurchaseFailReason.potionNotFound,
);
}
// 사용 가능 골드
final spendableGold = (gold * spendRatio).floor();
if (spendableGold <= 0) {
return PotionPurchaseResult.failed(
PotionPurchaseFailReason.insufficientGold,
);
}
var currentInventory = inventory;
var currentGold = gold;
var totalSpent = 0;
var hpPurchased = 0;
var mpPurchased = 0;
// HP 물약 우선 구매 (60%), MP 물약 (40%)
final hpBudget = (spendableGold * 0.6).floor();
final mpBudget = spendableGold - hpBudget;
// HP 물약 구매
if (hpPotion != null && hpBudget >= hpPotion.price) {
final count = hpBudget ~/ hpPotion.price;
final cost = count * hpPotion.price;
currentInventory = currentInventory.addPotion(hpPotion.id, count);
currentGold -= cost;
totalSpent += cost;
hpPurchased = count;
}
// MP 물약 구매
if (mpPotion != null && mpBudget >= mpPotion.price) {
final count = mpBudget ~/ mpPotion.price;
final cost = count * mpPotion.price;
currentInventory = currentInventory.addPotion(mpPotion.id, count);
currentGold -= cost;
totalSpent += cost;
mpPurchased = count;
}
if (totalSpent == 0) {
return PotionPurchaseResult.failed(
PotionPurchaseFailReason.insufficientGold,
);
}
return PotionPurchaseResult(
success: true,
potion: hpPotion ?? mpPotion,
quantity: hpPurchased + mpPurchased,
totalCost: totalSpent,
newGold: currentGold,
newInventory: currentInventory,
);
}
// ============================================================================
// 물약 드랍 시스템
// ============================================================================
/// 기본 물약 드랍 확률 (15%)
static const double baseDropChance = 0.15;
/// 레벨당 드랍 확률 증가 (0.5%씩)
static const double dropChancePerLevel = 0.005;
/// 최대 드랍 확률 (35%)
static const double maxDropChance = 0.35;
/// 물약 드랍 시도
///
/// 전투 승리 시 물약 드랍 여부 결정 및 물약 획득
/// [playerLevel] 플레이어 레벨 (드랍 확률 및 티어 결정)
/// [monsterLevel] 몬스터 레벨 (티어 결정에 영향)
/// [monsterGrade] 몬스터 등급 (드랍 확률 보너스)
/// [inventory] 현재 물약 인벤토리
/// [roll] 0~99 범위의 난수 (드랍 확률 판정)
/// [typeRoll] 0~99 범위의 난수 (HP/MP 결정)
/// Returns: (업데이트된 인벤토리, 드랍된 물약 또는 null)
(PotionInventory, Potion?) tryPotionDrop({
required int playerLevel,
required int monsterLevel,
required MonsterGrade monsterGrade,
required PotionInventory inventory,
required int roll,
required int typeRoll,
}) {
// 기본 드랍 확률 계산
var dropChance = (baseDropChance + playerLevel * dropChancePerLevel).clamp(
baseDropChance,
maxDropChance,
);
// 몬스터 등급 보너스 (Elite +5%, Boss +15%)
dropChance += monsterGrade.potionDropBonus;
// 몬스터 레벨 > 플레이어 레벨이면 추가 확률 (+1%/레벨 차이)
if (monsterLevel > playerLevel) {
dropChance += (monsterLevel - playerLevel) * 0.01;
}
// 최대 확률 제한 (50%)
dropChance = dropChance.clamp(0.0, 0.5);
final dropThreshold = (dropChance * 100).round();
// 드랍 실패
if (roll >= dropThreshold) {
return (inventory, null);
}
// 물약 타입 결정 (60% HP, 40% MP)
final isHpPotion = typeRoll < 60;
// 티어 결정: max(플레이어 레벨, 몬스터 레벨) 기반
final effectiveLevel = math.max(playerLevel, monsterLevel);
final tier = PotionData.tierForLevel(effectiveLevel);
// 물약 선택
final Potion? potion;
if (isHpPotion) {
potion = PotionData.getHpPotionByTier(tier);
} else {
potion = PotionData.getMpPotionByTier(tier);
}
if (potion == null) {
return (inventory, null);
}
// 인벤토리에 추가
final updatedInventory = inventory.addPotion(potion.id);
return (updatedInventory, potion);
}
}
/// 물약 사용 결과
class PotionUseResult {
const PotionUseResult({
required this.success,
this.potion,
this.healedAmount = 0,
this.newHp = 0,
this.newMp = 0,
this.newInventory,
this.failReason,
});
/// 성공 여부
final bool success;
/// 사용한 물약
final Potion? potion;
/// 실제 회복량
final int healedAmount;
/// 사용 후 HP
final int newHp;
/// 사용 후 MP
final int newMp;
/// 업데이트된 인벤토리
final PotionInventory? newInventory;
/// 실패 사유
final PotionUseFailReason? failReason;
/// 실패 결과 생성
factory PotionUseResult.failed(PotionUseFailReason reason) {
return PotionUseResult(success: false, failReason: reason);
}
}
/// 물약 사용 실패 사유
enum PotionUseFailReason {
/// 물약 없음 (데이터 없음)
potionNotFound,
/// 보유 물약 없음 (재고 부족)
outOfStock,
/// 글로벌 쿨타임 중
onCooldown,
}
/// 물약 구매 결과
class PotionPurchaseResult {
const PotionPurchaseResult({
required this.success,
this.potion,
this.quantity = 0,
this.totalCost = 0,
this.newGold = 0,
this.newInventory,
this.failReason,
});
/// 성공 여부
final bool success;
/// 구매한 물약
final Potion? potion;
/// 구매 수량
final int quantity;
/// 총 비용
final int totalCost;
/// 구매 후 골드
final int newGold;
/// 업데이트된 인벤토리
final PotionInventory? newInventory;
/// 실패 사유
final PotionPurchaseFailReason? failReason;
/// 실패 결과 생성
factory PotionPurchaseResult.failed(PotionPurchaseFailReason reason) {
return PotionPurchaseResult(success: false, failReason: reason);
}
}
/// 물약 구매 실패 사유
enum PotionPurchaseFailReason {
/// 물약 없음 (데이터 없음)
potionNotFound,
/// 골드 부족
insufficientGold,
}