diff --git a/lib/src/core/engine/act_progression_service.dart b/lib/src/core/engine/act_progression_service.dart index 371bde3..16dbf84 100644 --- a/lib/src/core/engine/act_progression_service.dart +++ b/lib/src/core/engine/act_progression_service.dart @@ -1,7 +1,7 @@ import 'dart:math' as math; import 'package:asciineverdie/data/game_text_l10n.dart' as l10n; -import 'package:asciineverdie/src/core/animation/monster_size.dart'; +import 'package:asciineverdie/src/shared/animation/monster_size.dart'; import 'package:asciineverdie/src/core/engine/combat_calculator.dart'; import 'package:asciineverdie/src/core/model/combat_state.dart'; import 'package:asciineverdie/src/core/model/combat_stats.dart'; diff --git a/lib/src/core/engine/ad_service.dart b/lib/src/core/engine/ad_service.dart index 47e1685..8058323 100644 --- a/lib/src/core/engine/ad_service.dart +++ b/lib/src/core/engine/ad_service.dart @@ -71,15 +71,14 @@ class AdService { // ───────────────────────────────────────────────────────────────────────── // 프로덕션 광고 ID (AdMob 콘솔에서 생성 후 교체) // ───────────────────────────────────────────────────────────────────────── - // TODO: AdMob 콘솔에서 광고 단위 생성 후 아래 ID 교체 static const String _prodRewardedAndroid = - 'ca-app-pub-XXXXXXXXXXXXXXXX/XXXXXXXXXX'; // Android 리워드 광고 + 'ca-app-pub-6691216385521068/3457464395'; // Android 리워드 광고 static const String _prodRewardedIos = - 'ca-app-pub-XXXXXXXXXXXXXXXX/XXXXXXXXXX'; // iOS 리워드 광고 + 'ca-app-pub-XXXXXXXXXXXXXXXX/XXXXXXXXXX'; // TODO: iOS 리워드 광고 ID 교체 static const String _prodInterstitialAndroid = - 'ca-app-pub-XXXXXXXXXXXXXXXX/XXXXXXXXXX'; // Android 인터스티셜 광고 + 'ca-app-pub-6691216385521068/1625507977'; // Android 인터스티셜 광고 static const String _prodInterstitialIos = - 'ca-app-pub-XXXXXXXXXXXXXXXX/XXXXXXXXXX'; // iOS 인터스티셜 광고 + 'ca-app-pub-XXXXXXXXXXXXXXXX/XXXXXXXXXX'; // TODO: iOS 인터스티셜 광고 ID 교체 /// 리워드 광고 단위 ID (릴리즈 빌드: 프로덕션 ID, 디버그 빌드: 테스트 ID) String get _rewardAdUnitId { diff --git a/lib/src/core/engine/arena_combat_simulator.dart b/lib/src/core/engine/arena_combat_simulator.dart new file mode 100644 index 0000000..854c2b5 --- /dev/null +++ b/lib/src/core/engine/arena_combat_simulator.dart @@ -0,0 +1,497 @@ +import 'package:asciineverdie/data/skill_data.dart'; +import 'package:asciineverdie/src/core/engine/combat_calculator.dart'; +import 'package:asciineverdie/src/core/engine/skill_service.dart'; +import 'package:asciineverdie/src/core/model/arena_match.dart'; +import 'package:asciineverdie/src/core/model/combat_stats.dart'; +import 'package:asciineverdie/src/core/model/game_state.dart'; +import 'package:asciineverdie/src/core/model/hall_of_fame.dart'; +import 'package:asciineverdie/src/core/model/monster_combat_stats.dart'; +import 'package:asciineverdie/src/core/model/skill.dart'; +import 'package:asciineverdie/src/core/util/deterministic_random.dart'; + +/// 아레나 전투 시뮬레이터 +/// +/// ArenaService에서 분리된 전투 시뮬레이션 로직. +/// 스킬 시스템을 포함한 턴 기반 전투를 처리한다. +class ArenaCombatSimulator { + ArenaCombatSimulator({required DeterministicRandom rng}) + : _rng = rng, + _skillService = SkillService(rng: rng); + + final DeterministicRandom _rng; + final SkillService _skillService; + + /// 전투 시뮬레이션 (애니메이션용 스트림) + Stream simulateCombat(ArenaMatch match) async* { + final challengerStats = match.challenger.finalStats; + final opponentStats = match.opponent.finalStats; + + if (challengerStats == null || opponentStats == null) { + return; + } + + final calculator = CombatCalculator(rng: _rng); + + // 스킬 ID 목록 로드 + var challengerSkillIds = _getSkillIdsFromEntry(match.challenger); + var opponentSkillIds = _getSkillIdsFromEntry(match.opponent); + if (challengerSkillIds.isEmpty) { + challengerSkillIds = SkillData.defaultSkillIds; + } + if (opponentSkillIds.isEmpty) { + opponentSkillIds = SkillData.defaultSkillIds; + } + + // 스킬 시스템 상태 초기화 + var challengerSkillSystem = SkillSystemState.empty(); + var opponentSkillSystem = SkillSystemState.empty(); + + // DOT 및 디버프 추적 + var challengerDoTs = []; + var opponentDoTs = []; + var challengerDebuffs = []; + var opponentDebuffs = []; + + var playerCombatStats = challengerStats.copyWith( + hpCurrent: challengerStats.hpMax, + mpCurrent: challengerStats.mpMax, + ); + + var opponentCombatStats = opponentStats.copyWith( + hpCurrent: opponentStats.hpMax, + mpCurrent: opponentStats.mpMax, + ); + + int playerAccum = 0; + int opponentAccum = 0; + int elapsedMs = 0; + const tickMs = 200; + int turns = 0; + + // 초기 상태 전송 + yield ArenaCombatTurn( + challengerHp: playerCombatStats.hpCurrent, + opponentHp: opponentCombatStats.hpCurrent, + challengerHpMax: playerCombatStats.hpMax, + opponentHpMax: opponentCombatStats.hpMax, + challengerMp: playerCombatStats.mpCurrent, + opponentMp: opponentCombatStats.mpCurrent, + challengerMpMax: playerCombatStats.mpMax, + opponentMpMax: opponentCombatStats.mpMax, + ); + + while (playerCombatStats.isAlive && opponentCombatStats.hpCurrent > 0) { + playerAccum += tickMs; + opponentAccum += tickMs; + elapsedMs += tickMs; + + // 스킬 시스템 시간 업데이트 + challengerSkillSystem = challengerSkillSystem.copyWith( + elapsedMs: elapsedMs, + ); + opponentSkillSystem = opponentSkillSystem.copyWith(elapsedMs: elapsedMs); + + int? challengerDamage; + int? opponentDamage; + bool isChallengerCritical = false; + bool isOpponentCritical = false; + bool isChallengerEvaded = false; + bool isOpponentEvaded = false; + bool isChallengerBlocked = false; + bool isOpponentBlocked = false; + String? challengerSkillUsed; + String? opponentSkillUsed; + int? challengerHealAmount; + int? opponentHealAmount; + + // DOT 틱 처리 + final dotResult = _processDotTicks( + challengerDoTs: challengerDoTs, + opponentDoTs: opponentDoTs, + playerStats: playerCombatStats, + opponentStats: opponentCombatStats, + tickMs: tickMs, + ); + challengerDoTs = dotResult.challengerDoTs; + opponentDoTs = dotResult.opponentDoTs; + playerCombatStats = dotResult.playerStats; + opponentCombatStats = dotResult.opponentStats; + + // 만료된 디버프 정리 + challengerDebuffs = challengerDebuffs + .where((ActiveBuff d) => !d.isExpired(elapsedMs)) + .toList(); + opponentDebuffs = opponentDebuffs + .where((ActiveBuff d) => !d.isExpired(elapsedMs)) + .toList(); + + // 도전자 턴 + if (playerAccum >= playerCombatStats.attackDelayMs) { + playerAccum = 0; + + var opponentMonsterStats = MonsterCombatStats.fromCombatStats( + opponentCombatStats, + match.opponent.characterName, + ); + + final turnResult = _processCharacterTurn( + player: playerCombatStats, + target: opponentCombatStats, + targetMonster: opponentMonsterStats, + targetName: match.opponent.characterName, + entry: match.challenger, + skillIds: challengerSkillIds, + skillSystem: challengerSkillSystem, + activeDoTs: challengerDoTs, + activeDebuffs: opponentDebuffs, + calculator: calculator, + elapsedMs: elapsedMs, + ); + + playerCombatStats = turnResult.player; + opponentCombatStats = turnResult.target; + challengerSkillSystem = turnResult.skillSystem; + challengerDoTs = turnResult.activeDoTs; + opponentDebuffs = turnResult.targetDebuffs; + challengerDamage = turnResult.damage; + isChallengerCritical = turnResult.isCritical; + isOpponentEvaded = turnResult.isTargetEvaded; + challengerSkillUsed = turnResult.skillUsed; + challengerHealAmount = turnResult.healAmount; + } + + // 상대 턴 + if (opponentCombatStats.hpCurrent > 0 && + opponentAccum >= opponentCombatStats.attackDelayMs) { + opponentAccum = 0; + + var challengerMonsterStats = MonsterCombatStats.fromCombatStats( + playerCombatStats, + match.challenger.characterName, + ); + + final turnResult = _processCharacterTurn( + player: opponentCombatStats, + target: playerCombatStats, + targetMonster: challengerMonsterStats, + targetName: match.challenger.characterName, + entry: match.opponent, + skillIds: opponentSkillIds, + skillSystem: opponentSkillSystem, + activeDoTs: opponentDoTs, + activeDebuffs: challengerDebuffs, + calculator: calculator, + elapsedMs: elapsedMs, + ); + + opponentCombatStats = turnResult.player; + playerCombatStats = turnResult.target; + opponentSkillSystem = turnResult.skillSystem; + opponentDoTs = turnResult.activeDoTs; + challengerDebuffs = turnResult.targetDebuffs; + opponentDamage = turnResult.damage; + isOpponentCritical = turnResult.isCritical; + isChallengerEvaded = turnResult.isTargetEvaded; + isChallengerBlocked = turnResult.isTargetBlocked; + opponentSkillUsed = turnResult.skillUsed; + opponentHealAmount = turnResult.healAmount; + } + + // 액션이 발생했을 때만 턴 전송 + final hasAction = + challengerDamage != null || + opponentDamage != null || + challengerHealAmount != null || + opponentHealAmount != null || + challengerSkillUsed != null || + opponentSkillUsed != null; + + if (hasAction) { + turns++; + yield ArenaCombatTurn( + challengerDamage: challengerDamage, + opponentDamage: opponentDamage, + challengerHp: playerCombatStats.hpCurrent, + opponentHp: opponentCombatStats.hpCurrent, + challengerHpMax: playerCombatStats.hpMax, + opponentHpMax: opponentCombatStats.hpMax, + challengerMp: playerCombatStats.mpCurrent, + opponentMp: opponentCombatStats.mpCurrent, + challengerMpMax: playerCombatStats.mpMax, + opponentMpMax: opponentCombatStats.mpMax, + isChallengerCritical: isChallengerCritical, + isOpponentCritical: isOpponentCritical, + isChallengerEvaded: isChallengerEvaded, + isOpponentEvaded: isOpponentEvaded, + isChallengerBlocked: isChallengerBlocked, + isOpponentBlocked: isOpponentBlocked, + challengerSkillUsed: challengerSkillUsed, + opponentSkillUsed: opponentSkillUsed, + challengerHealAmount: challengerHealAmount, + opponentHealAmount: opponentHealAmount, + ); + + await Future.delayed(const Duration(milliseconds: 100)); + } + + if (turns > 1000) break; + } + } + + /// DOT 틱 처리 (양측) + ({ + List challengerDoTs, + List opponentDoTs, + CombatStats playerStats, + CombatStats opponentStats, + }) + _processDotTicks({ + required List challengerDoTs, + required List opponentDoTs, + required CombatStats playerStats, + required CombatStats opponentStats, + required int tickMs, + }) { + var updatedPlayerStats = playerStats; + var updatedOpponentStats = opponentStats; + + // 도전자 -> 상대에게 적용된 DOT + var dotDamageToOpponent = 0; + final updatedChallengerDoTs = []; + for (final dot in challengerDoTs) { + final (updatedDot, ticksTriggered) = dot.tick(tickMs); + if (ticksTriggered > 0) { + dotDamageToOpponent += dot.damagePerTick * ticksTriggered; + } + if (updatedDot.isActive) updatedChallengerDoTs.add(updatedDot); + } + if (dotDamageToOpponent > 0 && updatedOpponentStats.hpCurrent > 0) { + updatedOpponentStats = updatedOpponentStats.copyWith( + hpCurrent: (updatedOpponentStats.hpCurrent - dotDamageToOpponent).clamp( + 0, + updatedOpponentStats.hpMax, + ), + ); + } + + // 상대 -> 도전자에게 적용된 DOT + var dotDamageToChallenger = 0; + final updatedOpponentDoTs = []; + for (final dot in opponentDoTs) { + final (updatedDot, ticksTriggered) = dot.tick(tickMs); + if (ticksTriggered > 0) { + dotDamageToChallenger += dot.damagePerTick * ticksTriggered; + } + if (updatedDot.isActive) updatedOpponentDoTs.add(updatedDot); + } + if (dotDamageToChallenger > 0 && updatedPlayerStats.isAlive) { + updatedPlayerStats = updatedPlayerStats.copyWith( + hpCurrent: (updatedPlayerStats.hpCurrent - dotDamageToChallenger).clamp( + 0, + updatedPlayerStats.hpMax, + ), + ); + } + + return ( + challengerDoTs: updatedChallengerDoTs, + opponentDoTs: updatedOpponentDoTs, + playerStats: updatedPlayerStats, + opponentStats: updatedOpponentStats, + ); + } + + /// 캐릭터 턴 처리 (도전자/상대 공통) + ({ + CombatStats player, + CombatStats target, + SkillSystemState skillSystem, + List activeDoTs, + List targetDebuffs, + int? damage, + bool isCritical, + bool isTargetEvaded, + bool isTargetBlocked, + String? skillUsed, + int? healAmount, + }) + _processCharacterTurn({ + required CombatStats player, + required CombatStats target, + required MonsterCombatStats targetMonster, + required String targetName, + required HallOfFameEntry entry, + required List skillIds, + required SkillSystemState skillSystem, + required List activeDoTs, + required List activeDebuffs, + required CombatCalculator calculator, + required int elapsedMs, + }) { + int? damage; + bool isCritical = false; + bool isTargetEvaded = false; + bool isTargetBlocked = false; + String? skillUsed; + int? healAmount; + var updatedPlayer = player; + var updatedTarget = target; + var updatedSkillSystem = skillSystem; + var updatedDoTs = [...activeDoTs]; + var updatedDebuffs = [...activeDebuffs]; + + final selectedSkill = _skillService.selectAutoSkill( + player: updatedPlayer, + monster: targetMonster, + skillSystem: updatedSkillSystem, + availableSkillIds: skillIds, + activeDoTs: updatedDoTs, + activeDebuffs: updatedDebuffs, + ); + + if (selectedSkill != null && selectedSkill.isAttack) { + final skillRank = _getSkillRankFromEntry(entry, selectedSkill.id); + final skillResult = _skillService.useAttackSkillWithRank( + skill: selectedSkill, + player: updatedPlayer, + monster: targetMonster, + skillSystem: updatedSkillSystem, + rank: skillRank, + ); + updatedPlayer = skillResult.updatedPlayer; + updatedTarget = updatedTarget.copyWith( + hpCurrent: skillResult.updatedMonster.hpCurrent, + ); + updatedSkillSystem = skillResult.updatedSkillSystem; + skillUsed = selectedSkill.name; + damage = skillResult.result.damage; + } else if (selectedSkill != null && selectedSkill.isDot) { + final skillResult = _skillService.useDotSkill( + skill: selectedSkill, + player: updatedPlayer, + skillSystem: updatedSkillSystem, + playerInt: updatedPlayer.atk ~/ 10, + playerWis: updatedPlayer.def ~/ 10, + ); + updatedPlayer = skillResult.updatedPlayer; + updatedSkillSystem = skillResult.updatedSkillSystem; + if (skillResult.dotEffect != null) { + updatedDoTs.add(skillResult.dotEffect!); + } + skillUsed = selectedSkill.name; + } else if (selectedSkill != null && selectedSkill.isHeal) { + final skillResult = _skillService.useHealSkill( + skill: selectedSkill, + player: updatedPlayer, + skillSystem: updatedSkillSystem, + ); + updatedPlayer = skillResult.updatedPlayer; + updatedSkillSystem = skillResult.updatedSkillSystem; + skillUsed = selectedSkill.name; + healAmount = skillResult.result.healedAmount; + } else if (selectedSkill != null && selectedSkill.isBuff) { + final skillResult = _skillService.useBuffSkill( + skill: selectedSkill, + player: updatedPlayer, + skillSystem: updatedSkillSystem, + ); + updatedPlayer = skillResult.updatedPlayer; + updatedSkillSystem = skillResult.updatedSkillSystem; + skillUsed = selectedSkill.name; + } else if (selectedSkill != null && selectedSkill.isDebuff) { + final skillResult = _skillService.useDebuffSkill( + skill: selectedSkill, + player: updatedPlayer, + skillSystem: updatedSkillSystem, + currentDebuffs: updatedDebuffs, + ); + updatedPlayer = skillResult.updatedPlayer; + updatedSkillSystem = skillResult.updatedSkillSystem; + final debuffEffect = skillResult.debuffEffect; + if (debuffEffect != null) { + updatedDebuffs = + updatedDebuffs + .where((ActiveBuff d) => d.effect.id != debuffEffect.effect.id) + .toList() + ..add(debuffEffect); + } + skillUsed = selectedSkill.name; + } else { + // 일반 공격 + final opponentMonsterStats = MonsterCombatStats.fromCombatStats( + updatedTarget, + targetName, + ); + final result = calculator.playerAttackMonster( + attacker: updatedPlayer, + defender: opponentMonsterStats, + ); + updatedTarget = updatedTarget.copyWith( + hpCurrent: result.updatedDefender.hpCurrent, + ); + + if (result.result.isHit) { + damage = result.result.damage; + isCritical = result.result.isCritical; + } else { + isTargetEvaded = true; + } + } + + return ( + player: updatedPlayer, + target: updatedTarget, + skillSystem: updatedSkillSystem, + activeDoTs: updatedDoTs, + targetDebuffs: updatedDebuffs, + damage: damage, + isCritical: isCritical, + isTargetEvaded: isTargetEvaded, + isTargetBlocked: isTargetBlocked, + skillUsed: skillUsed, + healAmount: healAmount, + ); + } + + /// 스킬 ID 목록 추출 (HallOfFameEntry에서) + List _getSkillIdsFromEntry(HallOfFameEntry entry) { + final skillData = entry.finalSkills; + if (skillData == null || skillData.isEmpty) return []; + + final skillIds = []; + for (final data in skillData) { + final skillName = data['name']; + if (skillName != null) { + final skill = SkillData.getSkillBySpellName(skillName); + if (skill != null) { + skillIds.add(skill.id); + } + } + } + return skillIds; + } + + /// 스킬 랭크 조회 (HallOfFameEntry의 finalSkills에서) + int _getSkillRankFromEntry(HallOfFameEntry entry, String skillId) { + final skill = SkillData.getSkillById(skillId); + if (skill == null) return 1; + + final skillData = entry.finalSkills; + if (skillData == null || skillData.isEmpty) return 1; + + for (final data in skillData) { + if (data['name'] == skill.name) { + final rankStr = data['rank'] ?? 'I'; + return switch (rankStr) { + 'I' => 1, + 'II' => 2, + 'III' => 3, + 'IV' => 4, + 'V' => 5, + _ => 1, + }; + } + } + return 1; + } +} diff --git a/lib/src/core/engine/arena_service.dart b/lib/src/core/engine/arena_service.dart index aff18cc..c759dcf 100644 --- a/lib/src/core/engine/arena_service.dart +++ b/lib/src/core/engine/arena_service.dart @@ -1,14 +1,11 @@ -import 'package:asciineverdie/data/skill_data.dart'; +import 'package:asciineverdie/src/core/engine/arena_combat_simulator.dart'; import 'package:asciineverdie/src/core/engine/combat_calculator.dart'; import 'package:asciineverdie/src/core/engine/item_service.dart'; -import 'package:asciineverdie/src/core/engine/skill_service.dart'; import 'package:asciineverdie/src/core/model/arena_match.dart'; import 'package:asciineverdie/src/core/model/equipment_item.dart'; import 'package:asciineverdie/src/core/model/equipment_slot.dart'; -import 'package:asciineverdie/src/core/model/game_state.dart'; import 'package:asciineverdie/src/core/model/hall_of_fame.dart'; import 'package:asciineverdie/src/core/model/monster_combat_stats.dart'; -import 'package:asciineverdie/src/core/model/skill.dart'; import 'package:asciineverdie/src/core/util/deterministic_random.dart'; /// 아레나 서비스 @@ -23,64 +20,6 @@ class ArenaService { final DeterministicRandom _rng; - late final SkillService _skillService = SkillService(rng: _rng); - - // ============================================================================ - // 스킬 시스템 헬퍼 - // ============================================================================ - - /// HallOfFameEntry의 finalSkills에서 Skill 목록 추출 - List _getSkillsFromEntry(HallOfFameEntry entry) { - final skillData = entry.finalSkills; - if (skillData == null || skillData.isEmpty) return []; - - final skills = []; - for (final data in skillData) { - final skillName = data['name']; - if (skillName != null) { - final skill = SkillData.getSkillBySpellName(skillName); - if (skill != null) { - skills.add(skill); - } - } - } - return skills; - } - - /// 스킬 ID 목록 추출 (HallOfFameEntry에서) - List _getSkillIdsFromEntry(HallOfFameEntry entry) { - return _getSkillsFromEntry(entry).map((s) => s.id).toList(); - } - - /// 스킬 랭크 조회 (HallOfFameEntry의 finalSkills에서) - int _getSkillRankFromEntry(HallOfFameEntry entry, String skillId) { - final skill = SkillData.getSkillById(skillId); - if (skill == null) return 1; - - final skillData = entry.finalSkills; - if (skillData == null || skillData.isEmpty) return 1; - - for (final data in skillData) { - if (data['name'] == skill.name) { - final rankStr = data['rank'] ?? 'I'; - return _romanToInt(rankStr); - } - } - return 1; - } - - /// 로마 숫자 → 정수 변환 - int _romanToInt(String roman) { - return switch (roman) { - 'I' => 1, - 'II' => 2, - 'III' => 3, - 'IV' => 4, - 'V' => 5, - _ => 1, - }; - } - // ============================================================================ // 상대 결정 // ============================================================================ @@ -230,452 +169,10 @@ class ArenaService { /// 전투 시뮬레이션 (애니메이션용 스트림) /// - /// progress_service._processCombatTickWithSkills()와 동일한 로직 사용 - /// [match] 대전 정보 - /// Returns: 턴별 전투 상황 스트림 - Stream simulateCombat(ArenaMatch match) async* { - final calculator = CombatCalculator(rng: _rng); - - final challengerStats = match.challenger.finalStats; - final opponentStats = match.opponent.finalStats; - - if (challengerStats == null || opponentStats == null) { - return; - } - - // 스킬 ID 목록 로드 (SkillBook과 동일한 방식) - var challengerSkillIds = _getSkillIdsFromEntry(match.challenger); - var opponentSkillIds = _getSkillIdsFromEntry(match.opponent); - - // 스킬이 없으면 기본 스킬 사용 - if (challengerSkillIds.isEmpty) { - challengerSkillIds = SkillData.defaultSkillIds; - } - if (opponentSkillIds.isEmpty) { - opponentSkillIds = SkillData.defaultSkillIds; - } - - // 스킬 시스템 상태 초기화 - var challengerSkillSystem = SkillSystemState.empty(); - var opponentSkillSystem = SkillSystemState.empty(); - - // DOT 및 디버프 추적 (일반 전투와 동일) - var challengerDoTs = []; - var opponentDoTs = []; - var challengerDebuffs = []; - var opponentDebuffs = []; - - var playerCombatStats = challengerStats.copyWith( - hpCurrent: challengerStats.hpMax, - mpCurrent: challengerStats.mpMax, - ); - - var opponentCombatStats = opponentStats.copyWith( - hpCurrent: opponentStats.hpMax, - mpCurrent: opponentStats.mpMax, - ); - - var opponentMonsterStats = MonsterCombatStats.fromCombatStats( - opponentCombatStats, - match.opponent.characterName, - ); - - var challengerMonsterStats = MonsterCombatStats.fromCombatStats( - playerCombatStats, - match.challenger.characterName, - ); - - int playerAccum = 0; - int opponentAccum = 0; - int elapsedMs = 0; - const tickMs = 200; - int turns = 0; - - // 초기 상태 전송 - yield ArenaCombatTurn( - challengerHp: playerCombatStats.hpCurrent, - opponentHp: opponentCombatStats.hpCurrent, - challengerHpMax: playerCombatStats.hpMax, - opponentHpMax: opponentCombatStats.hpMax, - challengerMp: playerCombatStats.mpCurrent, - opponentMp: opponentCombatStats.mpCurrent, - challengerMpMax: playerCombatStats.mpMax, - opponentMpMax: opponentCombatStats.mpMax, - ); - - while (playerCombatStats.isAlive && opponentCombatStats.hpCurrent > 0) { - playerAccum += tickMs; - opponentAccum += tickMs; - elapsedMs += tickMs; - - // 스킬 시스템 시간 업데이트 - challengerSkillSystem = challengerSkillSystem.copyWith( - elapsedMs: elapsedMs, - ); - opponentSkillSystem = opponentSkillSystem.copyWith(elapsedMs: elapsedMs); - - int? challengerDamage; - int? opponentDamage; - bool isChallengerCritical = false; - bool isOpponentCritical = false; - bool isChallengerEvaded = false; - bool isOpponentEvaded = false; - bool isChallengerBlocked = false; - bool isOpponentBlocked = false; - String? challengerSkillUsed; - String? opponentSkillUsed; - int? challengerHealAmount; - int? opponentHealAmount; - - // ========================================================================= - // DOT 틱 처리 (도전자 → 상대에게 적용된 DOT) - // ========================================================================= - var dotDamageToOpponent = 0; - final updatedChallengerDoTs = []; - for (final dot in challengerDoTs) { - final (updatedDot, ticksTriggered) = dot.tick(tickMs); - if (ticksTriggered > 0) { - dotDamageToOpponent += dot.damagePerTick * ticksTriggered; - } - if (updatedDot.isActive) { - updatedChallengerDoTs.add(updatedDot); - } - } - challengerDoTs = updatedChallengerDoTs; - - if (dotDamageToOpponent > 0 && opponentCombatStats.hpCurrent > 0) { - opponentCombatStats = opponentCombatStats.copyWith( - hpCurrent: (opponentCombatStats.hpCurrent - dotDamageToOpponent) - .clamp(0, opponentCombatStats.hpMax), - ); - } - - // DOT 틱 처리 (상대 → 도전자에게 적용된 DOT) - var dotDamageToChallenger = 0; - final updatedOpponentDoTs = []; - for (final dot in opponentDoTs) { - final (updatedDot, ticksTriggered) = dot.tick(tickMs); - if (ticksTriggered > 0) { - dotDamageToChallenger += dot.damagePerTick * ticksTriggered; - } - if (updatedDot.isActive) { - updatedOpponentDoTs.add(updatedDot); - } - } - opponentDoTs = updatedOpponentDoTs; - - if (dotDamageToChallenger > 0 && playerCombatStats.isAlive) { - playerCombatStats = playerCombatStats.copyWith( - hpCurrent: (playerCombatStats.hpCurrent - dotDamageToChallenger) - .clamp(0, playerCombatStats.hpMax), - ); - } - - // ========================================================================= - // 만료된 디버프 정리 - // ========================================================================= - challengerDebuffs = challengerDebuffs - .where((ActiveBuff d) => !d.isExpired(elapsedMs)) - .toList(); - opponentDebuffs = opponentDebuffs - .where((ActiveBuff d) => !d.isExpired(elapsedMs)) - .toList(); - - // ========================================================================= - // 도전자 턴 (selectAutoSkill 사용 - 일반 전투와 동일) - // ========================================================================= - if (playerAccum >= playerCombatStats.attackDelayMs) { - playerAccum = 0; - - // 상대 몬스터 스탯 동기화 - opponentMonsterStats = MonsterCombatStats.fromCombatStats( - opponentCombatStats, - match.opponent.characterName, - ); - - // 스킬 자동 선택 (progress_service와 동일한 로직) - final selectedSkill = _skillService.selectAutoSkill( - player: playerCombatStats, - monster: opponentMonsterStats, - skillSystem: challengerSkillSystem, - availableSkillIds: challengerSkillIds, - activeDoTs: challengerDoTs, - activeDebuffs: opponentDebuffs, - ); - - if (selectedSkill != null && selectedSkill.isAttack) { - // 스킬 랭크 조회 및 적용 - final skillRank = _getSkillRankFromEntry( - match.challenger, - selectedSkill.id, - ); - final skillResult = _skillService.useAttackSkillWithRank( - skill: selectedSkill, - player: playerCombatStats, - monster: opponentMonsterStats, - skillSystem: challengerSkillSystem, - rank: skillRank, - ); - playerCombatStats = skillResult.updatedPlayer; - opponentCombatStats = opponentCombatStats.copyWith( - hpCurrent: skillResult.updatedMonster.hpCurrent, - ); - challengerSkillSystem = skillResult.updatedSkillSystem; - challengerSkillUsed = selectedSkill.name; - challengerDamage = skillResult.result.damage; - } else if (selectedSkill != null && selectedSkill.isDot) { - // DOT 스킬 사용 - final skillResult = _skillService.useDotSkill( - skill: selectedSkill, - player: playerCombatStats, - skillSystem: challengerSkillSystem, - playerInt: playerCombatStats.atk ~/ 10, - playerWis: playerCombatStats.def ~/ 10, - ); - playerCombatStats = skillResult.updatedPlayer; - challengerSkillSystem = skillResult.updatedSkillSystem; - if (skillResult.dotEffect != null) { - challengerDoTs.add(skillResult.dotEffect!); - } - challengerSkillUsed = selectedSkill.name; - } else if (selectedSkill != null && selectedSkill.isHeal) { - // 회복 스킬 사용 - final skillResult = _skillService.useHealSkill( - skill: selectedSkill, - player: playerCombatStats, - skillSystem: challengerSkillSystem, - ); - playerCombatStats = skillResult.updatedPlayer; - challengerSkillSystem = skillResult.updatedSkillSystem; - challengerSkillUsed = selectedSkill.name; - challengerHealAmount = skillResult.result.healedAmount; - } else if (selectedSkill != null && selectedSkill.isBuff) { - // 버프 스킬 사용 - final skillResult = _skillService.useBuffSkill( - skill: selectedSkill, - player: playerCombatStats, - skillSystem: challengerSkillSystem, - ); - playerCombatStats = skillResult.updatedPlayer; - challengerSkillSystem = skillResult.updatedSkillSystem; - challengerSkillUsed = selectedSkill.name; - } else if (selectedSkill != null && selectedSkill.isDebuff) { - // 디버프 스킬 사용 - final skillResult = _skillService.useDebuffSkill( - skill: selectedSkill, - player: playerCombatStats, - skillSystem: challengerSkillSystem, - currentDebuffs: opponentDebuffs, - ); - playerCombatStats = skillResult.updatedPlayer; - challengerSkillSystem = skillResult.updatedSkillSystem; - final debuffEffect = skillResult.debuffEffect; - if (debuffEffect != null) { - opponentDebuffs = - opponentDebuffs - .where( - (ActiveBuff d) => d.effect.id != debuffEffect.effect.id, - ) - .toList() - ..add(debuffEffect); - } - challengerSkillUsed = selectedSkill.name; - } else { - // 일반 공격 - final result = calculator.playerAttackMonster( - attacker: playerCombatStats, - defender: opponentMonsterStats, - ); - opponentCombatStats = opponentCombatStats.copyWith( - hpCurrent: result.updatedDefender.hpCurrent, - ); - - if (result.result.isHit) { - challengerDamage = result.result.damage; - isChallengerCritical = result.result.isCritical; - } else { - isOpponentEvaded = true; - } - } - } - - // ========================================================================= - // 상대 턴 (selectAutoSkill 사용 - 일반 전투와 동일) - // ========================================================================= - if (opponentCombatStats.hpCurrent > 0 && - opponentAccum >= opponentCombatStats.attackDelayMs) { - opponentAccum = 0; - - // 도전자 몬스터 스탯 동기화 - challengerMonsterStats = MonsterCombatStats.fromCombatStats( - playerCombatStats, - match.challenger.characterName, - ); - - // 스킬 자동 선택 (progress_service와 동일한 로직) - final selectedSkill = _skillService.selectAutoSkill( - player: opponentCombatStats, - monster: challengerMonsterStats, - skillSystem: opponentSkillSystem, - availableSkillIds: opponentSkillIds, - activeDoTs: opponentDoTs, - activeDebuffs: challengerDebuffs, - ); - - if (selectedSkill != null && selectedSkill.isAttack) { - // 스킬 랭크 조회 및 적용 - final skillRank = _getSkillRankFromEntry( - match.opponent, - selectedSkill.id, - ); - final skillResult = _skillService.useAttackSkillWithRank( - skill: selectedSkill, - player: opponentCombatStats, - monster: challengerMonsterStats, - skillSystem: opponentSkillSystem, - rank: skillRank, - ); - opponentCombatStats = skillResult.updatedPlayer; - playerCombatStats = playerCombatStats.copyWith( - hpCurrent: skillResult.updatedMonster.hpCurrent, - ); - opponentSkillSystem = skillResult.updatedSkillSystem; - opponentSkillUsed = selectedSkill.name; - opponentDamage = skillResult.result.damage; - } else if (selectedSkill != null && selectedSkill.isDot) { - // DOT 스킬 사용 - final skillResult = _skillService.useDotSkill( - skill: selectedSkill, - player: opponentCombatStats, - skillSystem: opponentSkillSystem, - playerInt: opponentCombatStats.atk ~/ 10, - playerWis: opponentCombatStats.def ~/ 10, - ); - opponentCombatStats = skillResult.updatedPlayer; - opponentSkillSystem = skillResult.updatedSkillSystem; - if (skillResult.dotEffect != null) { - opponentDoTs.add(skillResult.dotEffect!); - } - opponentSkillUsed = selectedSkill.name; - } else if (selectedSkill != null && selectedSkill.isHeal) { - // 회복 스킬 사용 - final skillResult = _skillService.useHealSkill( - skill: selectedSkill, - player: opponentCombatStats, - skillSystem: opponentSkillSystem, - ); - opponentCombatStats = skillResult.updatedPlayer; - opponentSkillSystem = skillResult.updatedSkillSystem; - opponentSkillUsed = selectedSkill.name; - opponentHealAmount = skillResult.result.healedAmount; - } else if (selectedSkill != null && selectedSkill.isBuff) { - // 버프 스킬 사용 - final skillResult = _skillService.useBuffSkill( - skill: selectedSkill, - player: opponentCombatStats, - skillSystem: opponentSkillSystem, - ); - opponentCombatStats = skillResult.updatedPlayer; - opponentSkillSystem = skillResult.updatedSkillSystem; - opponentSkillUsed = selectedSkill.name; - } else if (selectedSkill != null && selectedSkill.isDebuff) { - // 디버프 스킬 사용 - final skillResult = _skillService.useDebuffSkill( - skill: selectedSkill, - player: opponentCombatStats, - skillSystem: opponentSkillSystem, - currentDebuffs: challengerDebuffs, - ); - opponentCombatStats = skillResult.updatedPlayer; - opponentSkillSystem = skillResult.updatedSkillSystem; - final debuffEffect = skillResult.debuffEffect; - if (debuffEffect != null) { - challengerDebuffs = - challengerDebuffs - .where( - (ActiveBuff d) => d.effect.id != debuffEffect.effect.id, - ) - .toList() - ..add(debuffEffect); - } - opponentSkillUsed = selectedSkill.name; - } else { - // 일반 공격 (디버프 효과 적용) - var debuffedOpponent = opponentCombatStats; - if (challengerDebuffs.isNotEmpty) { - double atkMod = 0; - for (final debuff in challengerDebuffs) { - if (!debuff.isExpired(elapsedMs)) { - atkMod += debuff.effect.atkModifier; - } - } - final newAtk = (opponentCombatStats.atk * (1 + atkMod)) - .round() - .clamp(opponentCombatStats.atk ~/ 10, opponentCombatStats.atk); - debuffedOpponent = opponentCombatStats.copyWith(atk: newAtk); - } - - opponentMonsterStats = MonsterCombatStats.fromCombatStats( - debuffedOpponent, - match.opponent.characterName, - ); - final result = calculator.monsterAttackPlayer( - attacker: opponentMonsterStats, - defender: playerCombatStats, - ); - playerCombatStats = result.updatedDefender; - - if (result.result.isHit) { - opponentDamage = result.result.damage; - isOpponentCritical = result.result.isCritical; - isChallengerBlocked = result.result.isBlocked; - } else { - isChallengerEvaded = true; - } - } - } - - // 액션이 발생했을 때만 턴 전송 - final hasAction = - challengerDamage != null || - opponentDamage != null || - challengerHealAmount != null || - opponentHealAmount != null || - challengerSkillUsed != null || - opponentSkillUsed != null; - - if (hasAction) { - turns++; - yield ArenaCombatTurn( - challengerDamage: challengerDamage, - opponentDamage: opponentDamage, - challengerHp: playerCombatStats.hpCurrent, - opponentHp: opponentCombatStats.hpCurrent, - challengerHpMax: playerCombatStats.hpMax, - opponentHpMax: opponentCombatStats.hpMax, - challengerMp: playerCombatStats.mpCurrent, - opponentMp: opponentCombatStats.mpCurrent, - challengerMpMax: playerCombatStats.mpMax, - opponentMpMax: opponentCombatStats.mpMax, - isChallengerCritical: isChallengerCritical, - isOpponentCritical: isOpponentCritical, - isChallengerEvaded: isChallengerEvaded, - isOpponentEvaded: isOpponentEvaded, - isChallengerBlocked: isChallengerBlocked, - isOpponentBlocked: isOpponentBlocked, - challengerSkillUsed: challengerSkillUsed, - opponentSkillUsed: opponentSkillUsed, - challengerHealAmount: challengerHealAmount, - opponentHealAmount: opponentHealAmount, - ); - - // 애니메이션을 위한 딜레이 - await Future.delayed(const Duration(milliseconds: 100)); - } - - // 무한 루프 방지 - if (turns > 1000) break; - } + /// ArenaCombatSimulator에 위임하여 턴별 전투 상황을 스트림으로 반환. + Stream simulateCombat(ArenaMatch match) { + final simulator = ArenaCombatSimulator(rng: _rng); + return simulator.simulateCombat(match); } // ============================================================================ // AI 베팅 슬롯 선택 diff --git a/lib/src/core/engine/combat_tick_service.dart b/lib/src/core/engine/combat_tick_service.dart index 83bdf9b..66b0357 100644 --- a/lib/src/core/engine/combat_tick_service.dart +++ b/lib/src/core/engine/combat_tick_service.dart @@ -2,6 +2,7 @@ import 'package:asciineverdie/data/class_data.dart'; import 'package:asciineverdie/data/skill_data.dart'; import 'package:asciineverdie/src/core/engine/combat_calculator.dart'; import 'package:asciineverdie/src/core/model/class_traits.dart'; +import 'package:asciineverdie/src/core/engine/player_attack_processor.dart'; import 'package:asciineverdie/src/core/engine/potion_service.dart'; import 'package:asciineverdie/src/core/engine/skill_service.dart'; import 'package:asciineverdie/src/core/model/combat_event.dart'; @@ -126,7 +127,8 @@ class CombatTickService { // 플레이어 공격 체크 if (playerAccumulator >= playerStats.attackDelayMs) { - final attackResult = _processPlayerAttack( + final attackProcessor = PlayerAttackProcessor(rng: rng); + final attackResult = attackProcessor.processAttack( state: state, playerStats: playerStats, monsterStats: monsterStats, @@ -363,249 +365,6 @@ class CombatTickService { return null; } - /// 플레이어 공격 처리 - ({ - CombatStats playerStats, - MonsterCombatStats monsterStats, - SkillSystemState skillSystem, - List activeDoTs, - List activeDebuffs, - int totalDamageDealt, - List events, - bool isFirstPlayerAttack, - }) - _processPlayerAttack({ - required GameState state, - required CombatStats playerStats, - required MonsterCombatStats monsterStats, - required SkillSystemState updatedSkillSystem, - required List activeDoTs, - required List activeDebuffs, - required int totalDamageDealt, - required int timestamp, - required CombatCalculator calculator, - required SkillService skillService, - required bool isFirstPlayerAttack, - required double firstStrikeBonus, - required bool hasMultiAttack, - double healingMultiplier = 1.0, - }) { - final events = []; - var newPlayerStats = playerStats; - var newMonsterStats = monsterStats; - var newSkillSystem = updatedSkillSystem; - var newActiveDoTs = [...activeDoTs]; - var newActiveBuffs = [...activeDebuffs]; - var newTotalDamageDealt = totalDamageDealt; - - // 장착된 스킬 슬롯에서 사용 가능한 스킬 ID 목록 조회 - var availableSkillIds = state.skillSystem.equippedSkills.allSkills - .map((s) => s.id) - .toList(); - // 장착된 스킬이 없으면 기본 스킬 사용 - if (availableSkillIds.isEmpty) { - availableSkillIds = SkillData.defaultSkillIds; - } - - final selectedSkill = skillService.selectAutoSkill( - player: newPlayerStats, - monster: newMonsterStats, - skillSystem: newSkillSystem, - availableSkillIds: availableSkillIds, - activeDoTs: newActiveDoTs, - activeDebuffs: newActiveBuffs, - ); - - if (selectedSkill != null && selectedSkill.isAttack) { - // 스킬 랭크 조회 - final skillRank = skillService.getSkillRankFromSkillBook( - state.skillBook, - selectedSkill.id, - ); - // 랭크 스케일링 적용된 공격 스킬 사용 - final skillResult = skillService.useAttackSkillWithRank( - skill: selectedSkill, - player: newPlayerStats, - monster: newMonsterStats, - skillSystem: newSkillSystem, - rank: skillRank, - ); - newPlayerStats = skillResult.updatedPlayer; - newMonsterStats = skillResult.updatedMonster; - newTotalDamageDealt += skillResult.result.damage; - newSkillSystem = skillResult.updatedSkillSystem.startGlobalCooldown(); - - events.add( - CombatEvent.playerSkill( - timestamp: timestamp, - skillName: selectedSkill.name, - damage: skillResult.result.damage, - targetName: newMonsterStats.name, - attackDelayMs: newPlayerStats.attackDelayMs, - ), - ); - } else if (selectedSkill != null && selectedSkill.isDot) { - final skillResult = skillService.useDotSkill( - skill: selectedSkill, - player: newPlayerStats, - skillSystem: newSkillSystem, - playerInt: state.stats.intelligence, - playerWis: state.stats.wis, - ); - newPlayerStats = skillResult.updatedPlayer; - newSkillSystem = skillResult.updatedSkillSystem.startGlobalCooldown(); - - if (skillResult.dotEffect != null) { - newActiveDoTs.add(skillResult.dotEffect!); - } - - events.add( - CombatEvent.playerSkill( - timestamp: timestamp, - skillName: selectedSkill.name, - damage: skillResult.result.damage, - targetName: newMonsterStats.name, - attackDelayMs: newPlayerStats.attackDelayMs, - ), - ); - } else if (selectedSkill != null && selectedSkill.isHeal) { - final skillResult = skillService.useHealSkill( - skill: selectedSkill, - player: newPlayerStats, - skillSystem: newSkillSystem, - healingMultiplier: healingMultiplier, - ); - newPlayerStats = skillResult.updatedPlayer; - newSkillSystem = skillResult.updatedSkillSystem.startGlobalCooldown(); - - events.add( - CombatEvent.playerHeal( - timestamp: timestamp, - healAmount: skillResult.result.healedAmount, - skillName: selectedSkill.name, - ), - ); - } else if (selectedSkill != null && selectedSkill.isBuff) { - final skillResult = skillService.useBuffSkill( - skill: selectedSkill, - player: newPlayerStats, - skillSystem: newSkillSystem, - ); - newPlayerStats = skillResult.updatedPlayer; - newSkillSystem = skillResult.updatedSkillSystem.startGlobalCooldown(); - - events.add( - CombatEvent.playerBuff( - timestamp: timestamp, - skillName: selectedSkill.name, - ), - ); - } else if (selectedSkill != null && selectedSkill.isDebuff) { - final skillResult = skillService.useDebuffSkill( - skill: selectedSkill, - player: newPlayerStats, - skillSystem: newSkillSystem, - currentDebuffs: newActiveBuffs, - ); - newPlayerStats = skillResult.updatedPlayer; - newSkillSystem = skillResult.updatedSkillSystem.startGlobalCooldown(); - - if (skillResult.debuffEffect != null) { - newActiveBuffs = - newActiveBuffs - .where( - (d) => d.effect.id != skillResult.debuffEffect!.effect.id, - ) - .toList() - ..add(skillResult.debuffEffect!); - } - - events.add( - CombatEvent.playerDebuff( - timestamp: timestamp, - skillName: selectedSkill.name, - targetName: newMonsterStats.name, - ), - ); - } else { - // 일반 공격 - final attackResult = calculator.playerAttackMonster( - attacker: newPlayerStats, - defender: newMonsterStats, - ); - newMonsterStats = attackResult.updatedDefender; - - // 첫 공격 배율 적용 (예: Pointer Assassin 1.5배) - var damage = attackResult.result.damage; - if (isFirstPlayerAttack && firstStrikeBonus > 1.0) { - damage = (damage * firstStrikeBonus).round(); - // 첫 공격 배율이 적용된 데미지로 몬스터 HP 재계산 - final extraDamage = damage - attackResult.result.damage; - if (extraDamage > 0) { - final newHp = (newMonsterStats.hpCurrent - extraDamage).clamp( - 0, - newMonsterStats.hpMax, - ); - newMonsterStats = newMonsterStats.copyWith(hpCurrent: newHp); - } - } - newTotalDamageDealt += damage; - - final result = attackResult.result; - if (result.isEvaded) { - events.add( - CombatEvent.monsterEvade( - timestamp: timestamp, - targetName: newMonsterStats.name, - ), - ); - } else { - events.add( - CombatEvent.playerAttack( - timestamp: timestamp, - damage: damage, - targetName: newMonsterStats.name, - isCritical: result.isCritical, - attackDelayMs: newPlayerStats.attackDelayMs, - ), - ); - } - - // 연속 공격 (Refactor Monk 패시브) - 30% 확률로 추가 공격 - if (hasMultiAttack && newMonsterStats.isAlive && rng.nextDouble() < 0.3) { - final extraAttack = calculator.playerAttackMonster( - attacker: newPlayerStats, - defender: newMonsterStats, - ); - newMonsterStats = extraAttack.updatedDefender; - newTotalDamageDealt += extraAttack.result.damage; - - if (!extraAttack.result.isEvaded) { - events.add( - CombatEvent.playerAttack( - timestamp: timestamp, - damage: extraAttack.result.damage, - targetName: newMonsterStats.name, - isCritical: extraAttack.result.isCritical, - attackDelayMs: newPlayerStats.attackDelayMs, - ), - ); - } - } - } - - return ( - playerStats: newPlayerStats, - monsterStats: newMonsterStats, - skillSystem: newSkillSystem, - activeDoTs: newActiveDoTs, - activeDebuffs: newActiveBuffs, - totalDamageDealt: newTotalDamageDealt, - events: events, - isFirstPlayerAttack: false, // 첫 공격 이후에는 false - ); - } - /// 몬스터 공격 처리 ({CombatStats playerStats, int totalDamageTaken, List events}) _processMonsterAttack({ diff --git a/lib/src/core/engine/death_handler.dart b/lib/src/core/engine/death_handler.dart new file mode 100644 index 0000000..0af2b3a --- /dev/null +++ b/lib/src/core/engine/death_handler.dart @@ -0,0 +1,172 @@ +import 'package:asciineverdie/src/core/model/equipment_item.dart'; +import 'package:asciineverdie/src/core/model/equipment_slot.dart'; +import 'package:asciineverdie/src/core/model/game_state.dart'; +import 'package:asciineverdie/src/core/model/item_stats.dart'; + +/// 플레이어 사망 처리 서비스 +/// +/// ProgressService에서 분리된 사망 관련 로직 담당: +/// - 장비 손실 계산 +/// - 사망 정보 기록 +/// - 보스전 레벨링 모드 진입 +class DeathHandler { + const DeathHandler(); + + /// 플레이어 사망 처리 (Phase 4) + /// + /// 모든 장비 상실 및 사망 정보 기록. + /// 보스전 사망 시: 장비 보호 + 5분 레벨링 모드 진입. + GameState processPlayerDeath( + GameState state, { + required String killerName, + required DeathCause cause, + }) { + // 사망 직전 전투 이벤트 저장 (최대 10개) + final lastCombatEvents = + state.progress.currentCombat?.recentEvents ?? const []; + + // 보스전 사망 여부 확인 (최종 보스 fighting 상태) + final isBossDeath = + state.progress.finalBossState == FinalBossState.fighting; + + // 보스전 사망이 아닐 경우에만 장비 손실 + var newEquipment = state.equipment; + var lostCount = 0; + String? lostItemName; + EquipmentSlot? lostItemSlot; + ItemRarity? lostItemRarity; + EquipmentItem? lostEquipmentItem; // 광고 부활 시 복구용 + + if (!isBossDeath) { + final lossResult = _calculateEquipmentLoss(state); + newEquipment = lossResult.equipment; + lostCount = lossResult.lostCount; + lostItemName = lossResult.lostItemName; + lostItemSlot = lossResult.lostItemSlot; + lostItemRarity = lossResult.lostItemRarity; + lostEquipmentItem = lossResult.lostItem; + } + + // 사망 정보 생성 (전투 로그 포함) + final deathInfo = DeathInfo( + cause: cause, + killerName: killerName, + lostEquipmentCount: lostCount, + lostItemName: lostItemName, + lostItemSlot: lostItemSlot, + lostItemRarity: lostItemRarity, + lostItem: lostEquipmentItem, + goldAtDeath: state.inventory.gold, + levelAtDeath: state.traits.level, + timestamp: state.skillSystem.elapsedMs, + lastCombatEvents: lastCombatEvents, + ); + + // 보스전 사망 시 5분 레벨링 모드 진입 + final bossLevelingEndTime = isBossDeath + ? DateTime.now().millisecondsSinceEpoch + + (5 * 60 * 1000) // 5분 + : null; + + // 전투 상태 초기화 및 사망 횟수 증가 + final progress = state.progress.copyWith( + currentCombat: null, + deathCount: state.progress.deathCount + 1, + bossLevelingEndTime: bossLevelingEndTime, + ); + + return state.copyWith( + equipment: newEquipment, + progress: progress, + deathInfo: deathInfo, + ); + } + + /// 장비 손실 계산 + ({ + Equipment equipment, + int lostCount, + String? lostItemName, + EquipmentSlot? lostItemSlot, + ItemRarity? lostItemRarity, + EquipmentItem? lostItem, + }) + _calculateEquipmentLoss(GameState state) { + var newEquipment = state.equipment; + + // 레벨 기반 장비 손실 확률 계산 + // Lv 1: 20%, Lv 5: ~56%, Lv 10+: 100% + // 공식: 20 + (level - 1) * 80 / 9 + final level = state.traits.level; + final lossChancePercent = level >= 10 + ? 100 + : (20 + ((level - 1) * 80 ~/ 9)).clamp(0, 100); + final roll = state.rng.nextInt(100); // 0~99 + final shouldLoseEquipment = roll < lossChancePercent; + + // ignore: avoid_print + print( + '[Death] Lv$level lossChance=$lossChancePercent% roll=$roll ' + 'shouldLose=$shouldLoseEquipment', + ); + + if (!shouldLoseEquipment) { + return ( + equipment: newEquipment, + lostCount: 0, + lostItemName: null, + lostItemSlot: null, + lostItemRarity: null, + lostItem: null, + ); + } + + // 무기(슬롯 0)를 제외한 장착된 장비 중 1개를 제물로 삭제 + final equippedNonWeaponSlots = []; + for (var i = 1; i < Equipment.slotCount; i++) { + final item = state.equipment.getItemByIndex(i); + if (item.isNotEmpty) { + equippedNonWeaponSlots.add(i); + } + } + + if (equippedNonWeaponSlots.isEmpty) { + return ( + equipment: newEquipment, + lostCount: 0, + lostItemName: null, + lostItemSlot: null, + lostItemRarity: null, + lostItem: null, + ); + } + + // 랜덤하게 1개 슬롯 선택 + final sacrificeIndex = + equippedNonWeaponSlots[state.rng.nextInt( + equippedNonWeaponSlots.length, + )]; + + // 제물로 바칠 아이템 정보 저장 + final lostItem = state.equipment.getItemByIndex(sacrificeIndex); + final lostItemSlot = EquipmentSlot.values[sacrificeIndex]; + + // 해당 슬롯을 빈 장비로 교체 + newEquipment = newEquipment.setItemByIndex( + sacrificeIndex, + EquipmentItem.empty(lostItemSlot), + ); + + // ignore: avoid_print + print('[Death] Lost item: ${lostItem.name} (slot: $lostItemSlot)'); + + return ( + equipment: newEquipment, + lostCount: 1, + lostItemName: lostItem.name, + lostItemSlot: lostItemSlot, + lostItemRarity: lostItem.rarity, + lostItem: lostItem, + ); + } +} diff --git a/lib/src/core/engine/loot_handler.dart b/lib/src/core/engine/loot_handler.dart new file mode 100644 index 0000000..553c117 --- /dev/null +++ b/lib/src/core/engine/loot_handler.dart @@ -0,0 +1,80 @@ +import 'package:asciineverdie/src/core/engine/game_mutations.dart'; +import 'package:asciineverdie/src/core/engine/potion_service.dart'; +import 'package:asciineverdie/src/core/model/game_state.dart'; +import 'package:asciineverdie/src/core/model/monster_grade.dart'; +import 'package:asciineverdie/src/core/model/potion.dart'; + +/// 전리품 처리 서비스 +/// +/// ProgressService에서 분리된 전리품 획득 로직 담당: +/// - 몬스터 부위 아이템 인벤토리 추가 +/// - 특수 아이템 획득 (WinItem) +/// - 물약 드랍 +class LootHandler { + const LootHandler({required this.mutations}); + + final GameMutations mutations; + + /// 킬 태스크 완료 시 전리품 획득 (원본 Main.pas:625-630) + ({GameState state, Potion? droppedPotion}) winLoot(GameState state) { + final taskInfo = state.progress.currentTask; + final monsterPart = taskInfo.monsterPart ?? ''; + final monsterBaseName = taskInfo.monsterBaseName ?? ''; + + var resultState = state; + + // 부위가 '*'이면 WinItem 호출 (특수 아이템) + if (monsterPart == '*') { + resultState = mutations.winItem(resultState); + } else if (monsterPart.isNotEmpty && monsterBaseName.isNotEmpty) { + // 원본: Add(Inventory, LowerCase(Split(fTask.Caption,1) + ' ' + + // ProperCase(Split(fTask.Caption,3))), 1); + // 예: "goblin Claw" 형태로 인벤토리 추가 + final itemName = + '${monsterBaseName.toLowerCase()} ${_properCase(monsterPart)}'; + + // 인벤토리에 추가 + final items = [...resultState.inventory.items]; + final existing = items.indexWhere((e) => e.name == itemName); + if (existing >= 0) { + items[existing] = items[existing].copyWith( + count: items[existing].count + 1, + ); + } else { + items.add(InventoryEntry(name: itemName, count: 1)); + } + + resultState = resultState.copyWith( + inventory: resultState.inventory.copyWith(items: items), + ); + } + + // 물약 드랍 시도 + final potionService = const PotionService(); + final rng = resultState.rng; + final monsterLevel = taskInfo.monsterLevel ?? resultState.traits.level; + final monsterGrade = taskInfo.monsterGrade ?? MonsterGrade.normal; + final (updatedPotionInventory, droppedPotion) = potionService.tryPotionDrop( + playerLevel: resultState.traits.level, + monsterLevel: monsterLevel, + monsterGrade: monsterGrade, + inventory: resultState.potionInventory, + roll: rng.nextInt(100), + typeRoll: rng.nextInt(100), + ); + + return ( + state: resultState.copyWith( + rng: rng, + potionInventory: updatedPotionInventory, + ), + droppedPotion: droppedPotion, + ); + } + + /// 첫 글자만 대문자로 변환 (원본 ProperCase) + String _properCase(String s) { + if (s.isEmpty) return s; + return s[0].toUpperCase() + s.substring(1); + } +} diff --git a/lib/src/core/engine/player_attack_processor.dart b/lib/src/core/engine/player_attack_processor.dart new file mode 100644 index 0000000..719ce57 --- /dev/null +++ b/lib/src/core/engine/player_attack_processor.dart @@ -0,0 +1,411 @@ +import 'package:asciineverdie/data/skill_data.dart'; +import 'package:asciineverdie/src/core/engine/combat_calculator.dart'; +import 'package:asciineverdie/src/core/engine/skill_service.dart'; +import 'package:asciineverdie/src/core/model/combat_event.dart'; +import 'package:asciineverdie/src/core/model/combat_stats.dart'; +import 'package:asciineverdie/src/core/model/game_state.dart'; +import 'package:asciineverdie/src/core/model/monster_combat_stats.dart'; +import 'package:asciineverdie/src/core/model/skill.dart'; +import 'package:asciineverdie/src/core/util/deterministic_random.dart'; + +/// 플레이어 공격 처리 결과 +typedef PlayerAttackResult = ({ + CombatStats playerStats, + MonsterCombatStats monsterStats, + SkillSystemState skillSystem, + List activeDoTs, + List activeDebuffs, + int totalDamageDealt, + List events, + bool isFirstPlayerAttack, +}); + +/// 플레이어 공격 처리 서비스 +/// +/// CombatTickService에서 분리된 플레이어 공격 로직 담당: +/// - 스킬 자동 선택 및 사용 +/// - 일반 공격 처리 +/// - 첫 공격 보너스 +/// - 연속 공격 (Multi-attack) +class PlayerAttackProcessor { + PlayerAttackProcessor({required this.rng}); + + final DeterministicRandom rng; + + /// 플레이어 공격 처리 + PlayerAttackResult processAttack({ + required GameState state, + required CombatStats playerStats, + required MonsterCombatStats monsterStats, + required SkillSystemState updatedSkillSystem, + required List activeDoTs, + required List activeDebuffs, + required int totalDamageDealt, + required int timestamp, + required CombatCalculator calculator, + required SkillService skillService, + required bool isFirstPlayerAttack, + required double firstStrikeBonus, + required bool hasMultiAttack, + double healingMultiplier = 1.0, + }) { + final events = []; + var newPlayerStats = playerStats; + var newMonsterStats = monsterStats; + var newSkillSystem = updatedSkillSystem; + var newActiveDoTs = [...activeDoTs]; + var newActiveBuffs = [...activeDebuffs]; + var newTotalDamageDealt = totalDamageDealt; + + // 장착된 스킬 슬롯에서 사용 가능한 스킬 ID 목록 조회 + var availableSkillIds = state.skillSystem.equippedSkills.allSkills + .map((s) => s.id) + .toList(); + // 장착된 스킬이 없으면 기본 스킬 사용 + if (availableSkillIds.isEmpty) { + availableSkillIds = SkillData.defaultSkillIds; + } + + final selectedSkill = skillService.selectAutoSkill( + player: newPlayerStats, + monster: newMonsterStats, + skillSystem: newSkillSystem, + availableSkillIds: availableSkillIds, + activeDoTs: newActiveDoTs, + activeDebuffs: newActiveBuffs, + ); + + if (selectedSkill != null && selectedSkill.isAttack) { + final result = _useAttackSkill( + state: state, + skill: selectedSkill, + playerStats: newPlayerStats, + monsterStats: newMonsterStats, + skillSystem: newSkillSystem, + skillService: skillService, + timestamp: timestamp, + ); + newPlayerStats = result.playerStats; + newMonsterStats = result.monsterStats; + newTotalDamageDealt += result.damage; + newSkillSystem = result.skillSystem; + events.add(result.event); + } else if (selectedSkill != null && selectedSkill.isDot) { + final result = _useDotSkill( + state: state, + skill: selectedSkill, + playerStats: newPlayerStats, + skillSystem: newSkillSystem, + skillService: skillService, + monsterName: newMonsterStats.name, + timestamp: timestamp, + ); + newPlayerStats = result.playerStats; + newSkillSystem = result.skillSystem; + if (result.dotEffect != null) newActiveDoTs.add(result.dotEffect!); + events.add(result.event); + } else if (selectedSkill != null && selectedSkill.isHeal) { + final result = _useHealSkill( + skill: selectedSkill, + playerStats: newPlayerStats, + skillSystem: newSkillSystem, + skillService: skillService, + healingMultiplier: healingMultiplier, + timestamp: timestamp, + ); + newPlayerStats = result.playerStats; + newSkillSystem = result.skillSystem; + events.add(result.event); + } else if (selectedSkill != null && selectedSkill.isBuff) { + final result = skillService.useBuffSkill( + skill: selectedSkill, + player: newPlayerStats, + skillSystem: newSkillSystem, + ); + newPlayerStats = result.updatedPlayer; + newSkillSystem = result.updatedSkillSystem.startGlobalCooldown(); + events.add( + CombatEvent.playerBuff( + timestamp: timestamp, + skillName: selectedSkill.name, + ), + ); + } else if (selectedSkill != null && selectedSkill.isDebuff) { + final result = _useDebuffSkill( + skill: selectedSkill, + playerStats: newPlayerStats, + skillSystem: newSkillSystem, + skillService: skillService, + activeDebuffs: newActiveBuffs, + monsterName: newMonsterStats.name, + timestamp: timestamp, + ); + newPlayerStats = result.playerStats; + newSkillSystem = result.skillSystem; + newActiveBuffs = result.activeDebuffs; + events.add(result.event); + } else { + // 일반 공격 + final result = _processNormalAttack( + playerStats: newPlayerStats, + monsterStats: newMonsterStats, + calculator: calculator, + isFirstPlayerAttack: isFirstPlayerAttack, + firstStrikeBonus: firstStrikeBonus, + hasMultiAttack: hasMultiAttack, + timestamp: timestamp, + ); + newMonsterStats = result.monsterStats; + newTotalDamageDealt += result.totalDamage; + events.addAll(result.events); + } + + return ( + playerStats: newPlayerStats, + monsterStats: newMonsterStats, + skillSystem: newSkillSystem, + activeDoTs: newActiveDoTs, + activeDebuffs: newActiveBuffs, + totalDamageDealt: newTotalDamageDealt, + events: events, + isFirstPlayerAttack: false, + ); + } + + // ============================================================================ + // 스킬 사용 헬퍼 + // ============================================================================ + + ({ + CombatStats playerStats, + MonsterCombatStats monsterStats, + int damage, + SkillSystemState skillSystem, + CombatEvent event, + }) + _useAttackSkill({ + required GameState state, + required Skill skill, + required CombatStats playerStats, + required MonsterCombatStats monsterStats, + required SkillSystemState skillSystem, + required SkillService skillService, + required int timestamp, + }) { + final skillRank = skillService.getSkillRankFromSkillBook( + state.skillBook, + skill.id, + ); + final skillResult = skillService.useAttackSkillWithRank( + skill: skill, + player: playerStats, + monster: monsterStats, + skillSystem: skillSystem, + rank: skillRank, + ); + return ( + playerStats: skillResult.updatedPlayer, + monsterStats: skillResult.updatedMonster, + damage: skillResult.result.damage, + skillSystem: skillResult.updatedSkillSystem.startGlobalCooldown(), + event: CombatEvent.playerSkill( + timestamp: timestamp, + skillName: skill.name, + damage: skillResult.result.damage, + targetName: monsterStats.name, + attackDelayMs: playerStats.attackDelayMs, + ), + ); + } + + ({ + CombatStats playerStats, + SkillSystemState skillSystem, + DotEffect? dotEffect, + CombatEvent event, + }) + _useDotSkill({ + required GameState state, + required Skill skill, + required CombatStats playerStats, + required SkillSystemState skillSystem, + required SkillService skillService, + required String monsterName, + required int timestamp, + }) { + final skillResult = skillService.useDotSkill( + skill: skill, + player: playerStats, + skillSystem: skillSystem, + playerInt: state.stats.intelligence, + playerWis: state.stats.wis, + ); + return ( + playerStats: skillResult.updatedPlayer, + skillSystem: skillResult.updatedSkillSystem.startGlobalCooldown(), + dotEffect: skillResult.dotEffect, + event: CombatEvent.playerSkill( + timestamp: timestamp, + skillName: skill.name, + damage: skillResult.result.damage, + targetName: monsterName, + attackDelayMs: playerStats.attackDelayMs, + ), + ); + } + + ({CombatStats playerStats, SkillSystemState skillSystem, CombatEvent event}) + _useHealSkill({ + required Skill skill, + required CombatStats playerStats, + required SkillSystemState skillSystem, + required SkillService skillService, + required double healingMultiplier, + required int timestamp, + }) { + final skillResult = skillService.useHealSkill( + skill: skill, + player: playerStats, + skillSystem: skillSystem, + healingMultiplier: healingMultiplier, + ); + return ( + playerStats: skillResult.updatedPlayer, + skillSystem: skillResult.updatedSkillSystem.startGlobalCooldown(), + event: CombatEvent.playerHeal( + timestamp: timestamp, + healAmount: skillResult.result.healedAmount, + skillName: skill.name, + ), + ); + } + + ({ + CombatStats playerStats, + SkillSystemState skillSystem, + List activeDebuffs, + CombatEvent event, + }) + _useDebuffSkill({ + required Skill skill, + required CombatStats playerStats, + required SkillSystemState skillSystem, + required SkillService skillService, + required List activeDebuffs, + required String monsterName, + required int timestamp, + }) { + final skillResult = skillService.useDebuffSkill( + skill: skill, + player: playerStats, + skillSystem: skillSystem, + currentDebuffs: activeDebuffs, + ); + var newDebuffs = activeDebuffs; + if (skillResult.debuffEffect != null) { + newDebuffs = + activeDebuffs + .where((d) => d.effect.id != skillResult.debuffEffect!.effect.id) + .toList() + ..add(skillResult.debuffEffect!); + } + return ( + playerStats: skillResult.updatedPlayer, + skillSystem: skillResult.updatedSkillSystem.startGlobalCooldown(), + activeDebuffs: newDebuffs, + event: CombatEvent.playerDebuff( + timestamp: timestamp, + skillName: skill.name, + targetName: monsterName, + ), + ); + } + + // ============================================================================ + // 일반 공격 + // ============================================================================ + + ({MonsterCombatStats monsterStats, int totalDamage, List events}) + _processNormalAttack({ + required CombatStats playerStats, + required MonsterCombatStats monsterStats, + required CombatCalculator calculator, + required bool isFirstPlayerAttack, + required double firstStrikeBonus, + required bool hasMultiAttack, + required int timestamp, + }) { + final events = []; + var newMonsterStats = monsterStats; + var totalDamage = 0; + + final attackResult = calculator.playerAttackMonster( + attacker: playerStats, + defender: newMonsterStats, + ); + newMonsterStats = attackResult.updatedDefender; + + // 첫 공격 배율 적용 + var damage = attackResult.result.damage; + if (isFirstPlayerAttack && firstStrikeBonus > 1.0) { + damage = (damage * firstStrikeBonus).round(); + final extraDamage = damage - attackResult.result.damage; + if (extraDamage > 0) { + final newHp = (newMonsterStats.hpCurrent - extraDamage).clamp( + 0, + newMonsterStats.hpMax, + ); + newMonsterStats = newMonsterStats.copyWith(hpCurrent: newHp); + } + } + totalDamage += damage; + + final result = attackResult.result; + if (result.isEvaded) { + events.add( + CombatEvent.monsterEvade( + timestamp: timestamp, + targetName: newMonsterStats.name, + ), + ); + } else { + events.add( + CombatEvent.playerAttack( + timestamp: timestamp, + damage: damage, + targetName: newMonsterStats.name, + isCritical: result.isCritical, + attackDelayMs: playerStats.attackDelayMs, + ), + ); + } + + // 연속 공격 (Refactor Monk 패시브) - 30% 확률로 추가 공격 + if (hasMultiAttack && newMonsterStats.isAlive && rng.nextDouble() < 0.3) { + final extraAttack = calculator.playerAttackMonster( + attacker: playerStats, + defender: newMonsterStats, + ); + newMonsterStats = extraAttack.updatedDefender; + totalDamage += extraAttack.result.damage; + + if (!extraAttack.result.isEvaded) { + events.add( + CombatEvent.playerAttack( + timestamp: timestamp, + damage: extraAttack.result.damage, + targetName: newMonsterStats.name, + isCritical: extraAttack.result.isCritical, + attackDelayMs: playerStats.attackDelayMs, + ), + ); + } + } + + return ( + monsterStats: newMonsterStats, + totalDamage: totalDamage, + events: events, + ); + } +} diff --git a/lib/src/core/engine/progress_service.dart b/lib/src/core/engine/progress_service.dart index c1e29e3..0129fcc 100644 --- a/lib/src/core/engine/progress_service.dart +++ b/lib/src/core/engine/progress_service.dart @@ -1,28 +1,18 @@ -import 'dart:math' as math; - import 'package:asciineverdie/data/class_data.dart'; import 'package:asciineverdie/data/game_text_l10n.dart' as l10n; import 'package:asciineverdie/data/race_data.dart'; import 'package:asciineverdie/src/core/model/class_traits.dart'; -import 'package:asciineverdie/src/core/animation/monster_size.dart'; import 'package:asciineverdie/src/core/engine/act_progression_service.dart'; -import 'package:asciineverdie/src/core/engine/combat_calculator.dart'; import 'package:asciineverdie/src/core/engine/combat_tick_service.dart'; +import 'package:asciineverdie/src/core/engine/death_handler.dart'; import 'package:asciineverdie/src/core/engine/game_mutations.dart'; +import 'package:asciineverdie/src/core/engine/loot_handler.dart'; import 'package:asciineverdie/src/core/engine/market_service.dart'; -import 'package:asciineverdie/src/core/engine/potion_service.dart'; import 'package:asciineverdie/src/core/engine/reward_service.dart'; import 'package:asciineverdie/src/core/engine/skill_service.dart'; +import 'package:asciineverdie/src/core/engine/task_generator.dart'; import 'package:asciineverdie/src/core/model/combat_event.dart'; -import 'package:asciineverdie/src/core/model/combat_state.dart'; -import 'package:asciineverdie/src/core/model/combat_stats.dart'; -import 'package:asciineverdie/src/core/model/equipment_item.dart'; -import 'package:asciineverdie/src/core/model/equipment_slot.dart'; import 'package:asciineverdie/src/core/model/game_state.dart'; -import 'package:asciineverdie/src/core/model/item_stats.dart'; -import 'package:asciineverdie/src/core/model/monster_combat_stats.dart'; -import 'package:asciineverdie/src/core/model/monster_grade.dart'; -import 'package:asciineverdie/src/core/model/potion.dart'; import 'package:asciineverdie/src/core/model/pq_config.dart'; import 'package:asciineverdie/src/core/util/balance_constants.dart'; import 'package:asciineverdie/src/core/util/pq_logic.dart' as pq_logic; @@ -58,12 +48,17 @@ class ProgressService { required this.config, required this.mutations, required this.rewards, - }); + }) : _taskGenerator = TaskGenerator(config: config), + _lootHandler = LootHandler(mutations: mutations); final PqConfig config; final GameMutations mutations; final RewardService rewards; + final TaskGenerator _taskGenerator; + final LootHandler _lootHandler; + static const _deathHandler = DeathHandler(); + /// 새 게임 초기화 (원본 GoButtonClick, Main.pas:741-767) /// Prologue 태스크들을 큐에 추가하고 첫 태스크 시작 GameState initializeNewGame(GameState state) { @@ -318,7 +313,7 @@ class ProgressService { // 플레이어 사망 체크 if (!updatedCombat.playerStats.isAlive) { final monsterName = updatedCombat.monsterStats.name; - nextState = _processPlayerDeath( + nextState = _deathHandler.processPlayerDeath( state, killerName: monsterName, cause: DeathCause.monster, @@ -380,7 +375,7 @@ class ProgressService { } // 전리품 획득 - final lootResult = _winLoot(nextState); + final lootResult = _lootHandler.winLoot(nextState); nextState = lootResult.state; // 물약 드랍 로그 추가 @@ -636,7 +631,7 @@ class ProgressService { } } else { nextState = nextState.copyWith(progress: progress, queue: queue); - final newTaskResult = _generateNextTask(nextState); + final newTaskResult = _taskGenerator.generateNextTask(nextState); progress = newTaskResult.progress; queue = newTaskResult.queue; } @@ -650,241 +645,6 @@ class ProgressService { ); } - /// 큐가 비어있을 때 다음 태스크 생성 (원본 Dequeue 667-684줄) - ({ProgressState progress, QueueState queue}) _generateNextTask( - GameState state, - ) { - var progress = state.progress; - final queue = state.queue; - final oldTaskType = progress.currentTask.type; - - // 1. Encumbrance 초과 시 시장 이동 - if (_shouldGoToMarket(progress)) { - return _createMarketTask(progress, queue); - } - - // 2. 전환 태스크 (buying/heading) - if (_needsTransitionTask(oldTaskType)) { - return _createTransitionTask(state, progress, queue); - } - - // 3. Act Boss 리트라이 - if (state.progress.pendingActCompletion) { - return _createActBossRetryTask(state, progress, queue); - } - - // 4. 최종 보스 전투 - if (state.progress.finalBossState == FinalBossState.fighting && - !state.progress.isInBossLevelingMode) { - if (state.progress.bossLevelingEndTime != null) { - progress = progress.copyWith(clearBossLevelingEndTime: true); - } - final actProgressionService = ActProgressionService(config: config); - return actProgressionService.startFinalBossFight(state, progress, queue); - } - - // 5. 일반 몬스터 전투 - return _createMonsterTask(state, progress, queue); - } - - /// 시장 이동 조건 확인 - bool _shouldGoToMarket(ProgressState progress) { - return progress.encumbrance.position >= progress.encumbrance.max && - progress.encumbrance.max > 0; - } - - /// 전환 태스크 필요 여부 확인 - bool _needsTransitionTask(TaskType oldTaskType) { - return oldTaskType != TaskType.kill && - oldTaskType != TaskType.neutral && - oldTaskType != TaskType.buying; - } - - /// 시장 이동 태스크 생성 - ({ProgressState progress, QueueState queue}) _createMarketTask( - ProgressState progress, - QueueState queue, - ) { - final taskResult = pq_logic.startTask( - progress, - l10n.taskHeadingToMarket(), - 4 * 1000, - ); - final updatedProgress = taskResult.progress.copyWith( - currentTask: TaskInfo(caption: taskResult.caption, type: TaskType.market), - currentCombat: null, - ); - return (progress: updatedProgress, queue: queue); - } - - /// 전환 태스크 생성 (buying 또는 heading) - ({ProgressState progress, QueueState queue}) _createTransitionTask( - GameState state, - ProgressState progress, - QueueState queue, - ) { - final gold = state.inventory.gold; - final equipPrice = state.traits.level * 50; - - // Gold 충분 시 장비 구매 - if (gold > equipPrice) { - final taskResult = pq_logic.startTask( - progress, - l10n.taskUpgradingHardware(), - 5 * 1000, - ); - final updatedProgress = taskResult.progress.copyWith( - currentTask: TaskInfo( - caption: taskResult.caption, - type: TaskType.buying, - ), - currentCombat: null, - ); - return (progress: updatedProgress, queue: queue); - } - - // Gold 부족 시 전장 이동 - final taskResult = pq_logic.startTask( - progress, - l10n.taskEnteringDebugZone(), - 4 * 1000, - ); - final updatedProgress = taskResult.progress.copyWith( - currentTask: TaskInfo( - caption: taskResult.caption, - type: TaskType.neutral, - ), - currentCombat: null, - ); - return (progress: updatedProgress, queue: queue); - } - - /// Act Boss 재도전 태스크 생성 - ({ProgressState progress, QueueState queue}) _createActBossRetryTask( - GameState state, - ProgressState progress, - QueueState queue, - ) { - final actProgressionService = ActProgressionService(config: config); - final actBoss = actProgressionService.createActBoss(state); - final combatCalculator = CombatCalculator(rng: state.rng); - final durationMillis = combatCalculator.estimateCombatDurationMs( - player: actBoss.playerStats, - monster: actBoss.monsterStats, - ); - - final taskResult = pq_logic.startTask( - progress, - l10n.taskDebugging(actBoss.monsterStats.name), - durationMillis, - ); - - final updatedProgress = taskResult.progress.copyWith( - currentTask: TaskInfo( - caption: taskResult.caption, - type: TaskType.kill, - monsterBaseName: actBoss.monsterStats.name, - monsterPart: '*', - monsterLevel: actBoss.monsterStats.level, - monsterGrade: MonsterGrade.boss, - monsterSize: getBossSizeForAct(state.progress.plotStageCount), - ), - currentCombat: actBoss, - ); - - return (progress: updatedProgress, queue: queue); - } - - /// 일반 몬스터 전투 태스크 생성 - ({ProgressState progress, QueueState queue}) _createMonsterTask( - GameState state, - ProgressState progress, - QueueState queue, - ) { - final level = state.traits.level; - - // 퀘스트 몬스터 데이터 확인 - final questMonster = state.progress.currentQuestMonster; - final questMonsterData = questMonster?.monsterData; - final questLevel = questMonsterData != null - ? int.tryParse(questMonsterData.split('|').elementAtOrNull(1) ?? '') ?? - 0 - : null; - - // 몬스터 생성 - final monsterResult = pq_logic.monsterTask( - config, - state.rng, - level, - questMonsterData, - questLevel, - ); - - // 몬스터 레벨 조정 (밸런스) - final actMinLevel = ActMonsterLevel.forPlotStage( - state.progress.plotStageCount, - ); - final baseLevel = math.max(level, actMinLevel); - final effectiveMonsterLevel = monsterResult.level - .clamp(math.max(1, baseLevel - 3), baseLevel + 3) - .toInt(); - - // 전투 스탯 생성 - final playerCombatStats = CombatStats.fromStats( - stats: state.stats, - equipment: state.equipment, - level: level, - monsterLevel: effectiveMonsterLevel, - ); - - final monsterCombatStats = MonsterCombatStats.fromLevel( - name: monsterResult.displayName, - level: effectiveMonsterLevel, - speedType: MonsterCombatStats.inferSpeedType(monsterResult.baseName), - plotStageCount: state.progress.plotStageCount, - ); - - // 전투 상태 및 지속시간 - final combatState = CombatState.start( - playerStats: playerCombatStats, - monsterStats: monsterCombatStats, - ); - - final combatCalculator = CombatCalculator(rng: state.rng); - final durationMillis = combatCalculator.estimateCombatDurationMs( - player: playerCombatStats, - monster: monsterCombatStats, - ); - - final taskResult = pq_logic.startTask( - progress, - l10n.taskDebugging(monsterResult.displayName), - durationMillis, - ); - - // 몬스터 사이즈 결정 - final monsterSize = getMonsterSizeForAct( - plotStageCount: state.progress.plotStageCount, - grade: monsterResult.grade, - rng: state.rng, - ); - - final updatedProgress = taskResult.progress.copyWith( - currentTask: TaskInfo( - caption: taskResult.caption, - type: TaskType.kill, - monsterBaseName: monsterResult.baseName, - monsterPart: monsterResult.part, - monsterLevel: effectiveMonsterLevel, - monsterGrade: monsterResult.grade, - monsterSize: monsterSize, - ), - currentCombat: combatState, - ); - - return (progress: updatedProgress, queue: queue); - } - /// Advances quest completion, applies reward, and enqueues next quest task. GameState completeQuest(GameState state) { final result = pq_logic.completeQuest( @@ -1069,184 +829,4 @@ class ProgressService { final progress = state.progress.copyWith(encumbrance: encumBar); return state.copyWith(progress: progress); } - - /// 킬 태스크 완료 시 전리품 획득 (원본 Main.pas:625-630) - /// 전리품 획득 결과 - /// - /// [state] 업데이트된 게임 상태 - /// [droppedPotion] 드랍된 물약 (없으면 null) - ({GameState state, Potion? droppedPotion}) _winLoot(GameState state) { - final taskInfo = state.progress.currentTask; - final monsterPart = taskInfo.monsterPart ?? ''; - final monsterBaseName = taskInfo.monsterBaseName ?? ''; - - var resultState = state; - - // 부위가 '*'이면 WinItem 호출 (특수 아이템) - if (monsterPart == '*') { - resultState = mutations.winItem(resultState); - } else if (monsterPart.isNotEmpty && monsterBaseName.isNotEmpty) { - // 원본: Add(Inventory, LowerCase(Split(fTask.Caption,1) + ' ' + - // ProperCase(Split(fTask.Caption,3))), 1); - // 예: "goblin Claw" 형태로 인벤토리 추가 - final itemName = - '${monsterBaseName.toLowerCase()} ${_properCase(monsterPart)}'; - - // 인벤토리에 추가 - final items = [...resultState.inventory.items]; - final existing = items.indexWhere((e) => e.name == itemName); - if (existing >= 0) { - items[existing] = items[existing].copyWith( - count: items[existing].count + 1, - ); - } else { - items.add(InventoryEntry(name: itemName, count: 1)); - } - - resultState = resultState.copyWith( - inventory: resultState.inventory.copyWith(items: items), - ); - } - - // 물약 드랍 시도 - final potionService = const PotionService(); - final rng = resultState.rng; - final monsterLevel = taskInfo.monsterLevel ?? resultState.traits.level; - final monsterGrade = taskInfo.monsterGrade ?? MonsterGrade.normal; - final (updatedPotionInventory, droppedPotion) = potionService.tryPotionDrop( - playerLevel: resultState.traits.level, - monsterLevel: monsterLevel, - monsterGrade: monsterGrade, - inventory: resultState.potionInventory, - roll: rng.nextInt(100), - typeRoll: rng.nextInt(100), - ); - - return ( - state: resultState.copyWith( - rng: rng, - potionInventory: updatedPotionInventory, - ), - droppedPotion: droppedPotion, - ); - } - - /// 첫 글자만 대문자로 변환 (원본 ProperCase) - String _properCase(String s) { - if (s.isEmpty) return s; - return s[0].toUpperCase() + s.substring(1); - } - - /// 플레이어 사망 처리 (Phase 4) - /// - /// 모든 장비 상실 및 사망 정보 기록 - /// 보스전 사망 시: 장비 보호 + 5분 레벨링 모드 진입 - GameState _processPlayerDeath( - GameState state, { - required String killerName, - required DeathCause cause, - }) { - // 사망 직전 전투 이벤트 저장 (최대 10개) - final lastCombatEvents = - state.progress.currentCombat?.recentEvents ?? const []; - - // 보스전 사망 여부 확인 (최종 보스 fighting 상태) - final isBossDeath = - state.progress.finalBossState == FinalBossState.fighting; - - // 보스전 사망이 아닐 경우에만 장비 손실 - var newEquipment = state.equipment; - var lostCount = 0; - String? lostItemName; - EquipmentSlot? lostItemSlot; - ItemRarity? lostItemRarity; - EquipmentItem? lostEquipmentItem; // 광고 부활 시 복구용 - - if (!isBossDeath) { - // 레벨 기반 장비 손실 확률 계산 - // Lv 1: 20%, Lv 5: ~56%, Lv 10+: 100% - // 공식: 20 + (level - 1) * 80 / 9 - final level = state.traits.level; - final lossChancePercent = level >= 10 - ? 100 - : (20 + ((level - 1) * 80 ~/ 9)).clamp(0, 100); - final roll = state.rng.nextInt(100); // 0~99 - final shouldLoseEquipment = roll < lossChancePercent; - - // ignore: avoid_print - print( - '[Death] Lv$level lossChance=$lossChancePercent% roll=$roll ' - 'shouldLose=$shouldLoseEquipment', - ); - - if (shouldLoseEquipment) { - // 무기(슬롯 0)를 제외한 장착된 장비 중 1개를 제물로 삭제 - final equippedNonWeaponSlots = []; - for (var i = 1; i < Equipment.slotCount; i++) { - final item = state.equipment.getItemByIndex(i); - if (item.isNotEmpty) { - equippedNonWeaponSlots.add(i); - } - } - - if (equippedNonWeaponSlots.isNotEmpty) { - lostCount = 1; - // 랜덤하게 1개 슬롯 선택 - final sacrificeIndex = - equippedNonWeaponSlots[state.rng.nextInt( - equippedNonWeaponSlots.length, - )]; - - // 제물로 바칠 아이템 정보 저장 - lostEquipmentItem = state.equipment.getItemByIndex(sacrificeIndex); - lostItemName = lostEquipmentItem.name; - lostItemSlot = EquipmentSlot.values[sacrificeIndex]; - lostItemRarity = lostEquipmentItem.rarity; - - // 해당 슬롯을 빈 장비로 교체 - newEquipment = newEquipment.setItemByIndex( - sacrificeIndex, - EquipmentItem.empty(lostItemSlot), - ); - - // ignore: avoid_print - print('[Death] Lost item: $lostItemName (slot: $lostItemSlot)'); - } - } - } - - // 사망 정보 생성 (전투 로그 포함) - final deathInfo = DeathInfo( - cause: cause, - killerName: killerName, - lostEquipmentCount: lostCount, - lostItemName: lostItemName, - lostItemSlot: lostItemSlot, - lostItemRarity: lostItemRarity, - lostItem: lostEquipmentItem, // 광고 부활 시 복구용 - goldAtDeath: state.inventory.gold, - levelAtDeath: state.traits.level, - timestamp: state.skillSystem.elapsedMs, - lastCombatEvents: lastCombatEvents, - ); - - // 보스전 사망 시 5분 레벨링 모드 진입 - final bossLevelingEndTime = isBossDeath - ? DateTime.now().millisecondsSinceEpoch + - (5 * 60 * 1000) // 5분 - : null; - - // 전투 상태 초기화 및 사망 횟수 증가 - final progress = state.progress.copyWith( - currentCombat: null, - deathCount: state.progress.deathCount + 1, - bossLevelingEndTime: bossLevelingEndTime, - ); - - return state.copyWith( - equipment: newEquipment, - progress: progress, - deathInfo: deathInfo, - ); - } } diff --git a/lib/src/core/engine/skill_auto_selector.dart b/lib/src/core/engine/skill_auto_selector.dart new file mode 100644 index 0000000..20f04d5 --- /dev/null +++ b/lib/src/core/engine/skill_auto_selector.dart @@ -0,0 +1,200 @@ +import 'package:asciineverdie/data/skill_data.dart'; +import 'package:asciineverdie/src/core/model/combat_stats.dart'; +import 'package:asciineverdie/src/core/model/game_state.dart'; +import 'package:asciineverdie/src/core/model/monster_combat_stats.dart'; +import 'package:asciineverdie/src/core/model/skill.dart'; +import 'package:asciineverdie/src/core/util/deterministic_random.dart'; + +/// 스킬 자동 선택 AI +/// +/// SkillService에서 분리된 전투 중 스킬 자동 선택 로직. +/// 상황별 우선순위에 따라 최적의 스킬을 선택한다. +class SkillAutoSelector { + const SkillAutoSelector({required this.rng}); + + final DeterministicRandom rng; + + /// 전투 중 자동 스킬 선택 + /// + /// 우선순위: + /// 1. HP < 30% -> 회복 스킬 (최우선) + /// 2. 70% 확률로 일반 공격 (MP 절약, 기본 전투) + /// 3. 30% 확률로 스킬 사용: + /// - 버프: HP > 80% & MP > 60% & 활성 버프 없음 + /// - 디버프: 몬스터 HP > 80% & 활성 디버프 없음 + /// - DOT: 몬스터 HP > 60% & 활성 DOT 없음 + /// - 공격: 보스전이면 강력한 스킬, 일반전이면 효율적 스킬 + /// 4. MP < 20% -> 일반 공격 + Skill? selectAutoSkill({ + required CombatStats player, + required MonsterCombatStats monster, + required SkillSystemState skillSystem, + required List availableSkillIds, + required bool Function(Skill) canUse, + List activeDoTs = const [], + List activeDebuffs = const [], + }) { + final mpRatio = player.mpRatio; + final hpRatio = player.hpRatio; + + // MP 20% 미만이면 일반 공격 + if (mpRatio < 0.2) return null; + + // 사용 가능한 스킬 필터링 + final availableSkills = availableSkillIds + .map((id) => SkillData.getSkillById(id)) + .whereType() + .where(canUse) + .toList(); + + if (availableSkills.isEmpty) return null; + + // HP < 30% -> 회복 스킬 최우선 (생존) + if (hpRatio < 0.3) { + final healSkill = _findBestHealSkill(availableSkills, player.mpCurrent); + if (healSkill != null) return healSkill; + } + + // 70% 확률로 일반 공격 (스킬은 특별한 상황에서만) + final useNormalAttack = rng.nextInt(100) < 70; + if (useNormalAttack) return null; + + // === 아래부터 30% 확률로 스킬 사용 === + + // 버프: HP > 80% & MP > 60% (매우 안전할 때만) + if (hpRatio > 0.8 && mpRatio > 0.6) { + final hasActiveBuff = skillSystem.activeBuffs.isNotEmpty; + if (!hasActiveBuff) { + final buffSkill = _findBestBuffSkill(availableSkills, player.mpCurrent); + if (buffSkill != null) return buffSkill; + } + } + + // 디버프: 몬스터 HP > 80% & 활성 디버프 없음 (전투 초반) + if (monster.hpRatio > 0.8 && activeDebuffs.isEmpty) { + final debuffSkill = _findBestDebuffSkill( + availableSkills, + player.mpCurrent, + ); + if (debuffSkill != null) return debuffSkill; + } + + // DOT: 몬스터 HP > 60% & 활성 DOT 없음 (장기전 유리) + if (monster.hpRatio > 0.6 && activeDoTs.isEmpty) { + final dotSkill = _findBestDotSkill(availableSkills, player.mpCurrent); + if (dotSkill != null) return dotSkill; + } + + // 보스전 판단 (몬스터 레벨 20 이상 & HP 50% 이상) + final isBossFight = monster.level >= 20 && monster.hpRatio > 0.5; + + if (isBossFight) { + return _findStrongestAttackSkill(availableSkills); + } + + // 일반 전투 -> MP 효율 좋은 공격 스킬 + return _findEfficientAttackSkill(availableSkills); + } + + /// 가장 좋은 DOT 스킬 찾기 + Skill? _findBestDotSkill(List skills, int currentMp) { + final dotSkills = skills + .where((s) => s.isDot && s.mpCost <= currentMp) + .toList(); + + if (dotSkills.isEmpty) return null; + + dotSkills.sort((a, b) { + final aTotal = + (a.baseDotDamage ?? 0) * + ((a.baseDotDurationMs ?? 0) ~/ (a.baseDotTickMs ?? 1000)); + final bTotal = + (b.baseDotDamage ?? 0) * + ((b.baseDotDurationMs ?? 0) ~/ (b.baseDotTickMs ?? 1000)); + return bTotal.compareTo(aTotal); + }); + + return dotSkills.first; + } + + /// 가장 좋은 회복 스킬 찾기 + Skill? _findBestHealSkill(List skills, int currentMp) { + final healSkills = skills + .where((s) => s.isHeal && s.mpCost <= currentMp) + .toList(); + + if (healSkills.isEmpty) return null; + + healSkills.sort((a, b) { + final aValue = a.healPercent * 100 + a.healAmount; + final bValue = b.healPercent * 100 + b.healAmount; + return bValue.compareTo(aValue); + }); + + return healSkills.first; + } + + /// 가장 강력한 공격 스킬 찾기 + Skill? _findStrongestAttackSkill(List skills) { + final attackSkills = skills.where((s) => s.isAttack).toList(); + if (attackSkills.isEmpty) return null; + + attackSkills.sort( + (a, b) => b.damageMultiplier.compareTo(a.damageMultiplier), + ); + return attackSkills.first; + } + + /// MP 효율 좋은 공격 스킬 찾기 + Skill? _findEfficientAttackSkill(List skills) { + final attackSkills = skills.where((s) => s.isAttack).toList(); + if (attackSkills.isEmpty) return null; + + attackSkills.sort((a, b) => b.mpEfficiency.compareTo(a.mpEfficiency)); + return attackSkills.first; + } + + /// 가장 좋은 버프 스킬 찾기 + Skill? _findBestBuffSkill(List skills, int currentMp) { + final buffSkills = skills + .where((s) => s.isBuff && s.mpCost <= currentMp && s.buff != null) + .toList(); + + if (buffSkills.isEmpty) return null; + + buffSkills.sort((a, b) { + final aValue = + (a.buff?.atkModifier ?? 0) + + (a.buff?.defModifier ?? 0) * 0.5 + + (a.buff?.criRateModifier ?? 0) * 0.3; + final bValue = + (b.buff?.atkModifier ?? 0) + + (b.buff?.defModifier ?? 0) * 0.5 + + (b.buff?.criRateModifier ?? 0) * 0.3; + return bValue.compareTo(aValue); + }); + + return buffSkills.first; + } + + /// 가장 좋은 디버프 스킬 찾기 + Skill? _findBestDebuffSkill(List skills, int currentMp) { + final debuffSkills = skills + .where((s) => s.isDebuff && s.mpCost <= currentMp && s.buff != null) + .toList(); + + if (debuffSkills.isEmpty) return null; + + debuffSkills.sort((a, b) { + final aValue = + (a.buff?.atkModifier ?? 0).abs() + + (a.buff?.defModifier ?? 0).abs() * 0.5; + final bValue = + (b.buff?.atkModifier ?? 0).abs() + + (b.buff?.defModifier ?? 0).abs() * 0.5; + return bValue.compareTo(aValue); + }); + + return debuffSkills.first; + } +} diff --git a/lib/src/core/engine/skill_service.dart b/lib/src/core/engine/skill_service.dart index 48b667d..3f0e815 100644 --- a/lib/src/core/engine/skill_service.dart +++ b/lib/src/core/engine/skill_service.dart @@ -1,4 +1,5 @@ import 'package:asciineverdie/data/skill_data.dart'; +import 'package:asciineverdie/src/core/engine/skill_auto_selector.dart'; import 'package:asciineverdie/src/core/model/combat_stats.dart'; import 'package:asciineverdie/src/core/model/game_state.dart'; import 'package:asciineverdie/src/core/model/monster_combat_stats.dart'; @@ -309,20 +310,12 @@ class SkillService { } // ============================================================================ - // 자동 스킬 선택 + // 자동 스킬 선택 (SkillAutoSelector에 위임) // ============================================================================ /// 전투 중 자동 스킬 선택 /// - /// 우선순위: - /// 1. HP < 30% → 회복 스킬 (최우선) - /// 2. 70% 확률로 일반 공격 (MP 절약, 기본 전투) - /// 3. 30% 확률로 스킬 사용: - /// - 버프: HP > 80% & MP > 60% & 활성 버프 없음 - /// - 디버프: 몬스터 HP > 80% & 활성 디버프 없음 - /// - DOT: 몬스터 HP > 60% & 활성 DOT 없음 - /// - 공격: 보스전이면 강력한 스킬, 일반전이면 효율적 스킬 - /// 4. MP < 20% → 일반 공격 + /// 세부 로직은 SkillAutoSelector에 위임. Skill? selectAutoSkill({ required CombatStats player, required MonsterCombatStats monster, @@ -331,186 +324,22 @@ class SkillService { List activeDoTs = const [], List activeDebuffs = const [], }) { - final currentMp = player.mpCurrent; - final mpRatio = player.mpRatio; - final hpRatio = player.hpRatio; - - // MP 20% 미만이면 일반 공격 - if (mpRatio < 0.2) return null; - - // 사용 가능한 스킬 필터링 - final availableSkills = availableSkillIds - .map((id) => SkillData.getSkillById(id)) - .whereType() - .where( - (skill) => - canUseSkill( - skill: skill, - currentMp: currentMp, - skillSystem: skillSystem, - ) == - null, - ) - .toList(); - - if (availableSkills.isEmpty) return null; - - // HP < 30% → 회복 스킬 최우선 (생존) - if (hpRatio < 0.3) { - final healSkill = _findBestHealSkill(availableSkills, currentMp); - if (healSkill != null) return healSkill; - } - - // 70% 확률로 일반 공격 (스킬은 특별한 상황에서만) - final useNormalAttack = rng.nextInt(100) < 70; - if (useNormalAttack) return null; - - // === 아래부터 30% 확률로 스킬 사용 === - - // 버프: HP > 80% & MP > 60% (매우 안전할 때만) - // 활성 버프가 있으면 건너뜀 (중복 방지) - if (hpRatio > 0.8 && mpRatio > 0.6) { - final hasActiveBuff = skillSystem.activeBuffs.isNotEmpty; - if (!hasActiveBuff) { - final buffSkill = _findBestBuffSkill(availableSkills, currentMp); - if (buffSkill != null) return buffSkill; - } - } - - // 디버프: 몬스터 HP > 80% & 활성 디버프 없음 (전투 초반) - if (monster.hpRatio > 0.8 && activeDebuffs.isEmpty) { - final debuffSkill = _findBestDebuffSkill(availableSkills, currentMp); - if (debuffSkill != null) return debuffSkill; - } - - // DOT: 몬스터 HP > 60% & 활성 DOT 없음 (장기전 유리) - if (monster.hpRatio > 0.6 && activeDoTs.isEmpty) { - final dotSkill = _findBestDotSkill(availableSkills, currentMp); - if (dotSkill != null) return dotSkill; - } - - // 보스전 판단 (몬스터 레벨 20 이상 & HP 50% 이상) - final isBossFight = monster.level >= 20 && monster.hpRatio > 0.5; - - if (isBossFight) { - // 가장 강력한 공격 스킬 - return _findStrongestAttackSkill(availableSkills); - } - - // 일반 전투 → MP 효율 좋은 공격 스킬 - return _findEfficientAttackSkill(availableSkills); - } - - /// 가장 좋은 DOT 스킬 찾기 - /// - /// 예상 총 데미지 (틱 × 데미지) 기준으로 선택 - Skill? _findBestDotSkill(List skills, int currentMp) { - final dotSkills = skills - .where((s) => s.isDot && s.mpCost <= currentMp) - .toList(); - - if (dotSkills.isEmpty) return null; - - // 예상 총 데미지 기준 정렬 - dotSkills.sort((a, b) { - final aTotal = - (a.baseDotDamage ?? 0) * - ((a.baseDotDurationMs ?? 0) ~/ (a.baseDotTickMs ?? 1000)); - final bTotal = - (b.baseDotDamage ?? 0) * - ((b.baseDotDurationMs ?? 0) ~/ (b.baseDotTickMs ?? 1000)); - return bTotal.compareTo(aTotal); - }); - - return dotSkills.first; - } - - /// 가장 좋은 회복 스킬 찾기 - Skill? _findBestHealSkill(List skills, int currentMp) { - final healSkills = skills - .where((s) => s.isHeal && s.mpCost <= currentMp) - .toList(); - - if (healSkills.isEmpty) return null; - - // 회복량 기준 정렬 (% 회복 > 고정 회복) - healSkills.sort((a, b) { - final aValue = a.healPercent * 100 + a.healAmount; - final bValue = b.healPercent * 100 + b.healAmount; - return bValue.compareTo(aValue); - }); - - return healSkills.first; - } - - /// 가장 강력한 공격 스킬 찾기 - Skill? _findStrongestAttackSkill(List skills) { - final attackSkills = skills.where((s) => s.isAttack).toList(); - if (attackSkills.isEmpty) return null; - - attackSkills.sort( - (a, b) => b.damageMultiplier.compareTo(a.damageMultiplier), + final selector = SkillAutoSelector(rng: rng); + return selector.selectAutoSkill( + player: player, + monster: monster, + skillSystem: skillSystem, + availableSkillIds: availableSkillIds, + canUse: (skill) => + canUseSkill( + skill: skill, + currentMp: player.mpCurrent, + skillSystem: skillSystem, + ) == + null, + activeDoTs: activeDoTs, + activeDebuffs: activeDebuffs, ); - return attackSkills.first; - } - - /// MP 효율 좋은 공격 스킬 찾기 - Skill? _findEfficientAttackSkill(List skills) { - final attackSkills = skills.where((s) => s.isAttack).toList(); - if (attackSkills.isEmpty) return null; - - attackSkills.sort((a, b) => b.mpEfficiency.compareTo(a.mpEfficiency)); - return attackSkills.first; - } - - /// 가장 좋은 버프 스킬 찾기 - /// - /// ATK 증가 버프 우선, 그 다음 복합 버프 - Skill? _findBestBuffSkill(List skills, int currentMp) { - final buffSkills = skills - .where((s) => s.isBuff && s.mpCost <= currentMp && s.buff != null) - .toList(); - - if (buffSkills.isEmpty) return null; - - // ATK 증가량 기준 정렬 - buffSkills.sort((a, b) { - final aValue = - (a.buff?.atkModifier ?? 0) + - (a.buff?.defModifier ?? 0) * 0.5 + - (a.buff?.criRateModifier ?? 0) * 0.3; - final bValue = - (b.buff?.atkModifier ?? 0) + - (b.buff?.defModifier ?? 0) * 0.5 + - (b.buff?.criRateModifier ?? 0) * 0.3; - return bValue.compareTo(aValue); - }); - - return buffSkills.first; - } - - /// 가장 좋은 디버프 스킬 찾기 - /// - /// 적 ATK 감소 디버프 우선 - Skill? _findBestDebuffSkill(List skills, int currentMp) { - final debuffSkills = skills - .where((s) => s.isDebuff && s.mpCost <= currentMp && s.buff != null) - .toList(); - - if (debuffSkills.isEmpty) return null; - - // 디버프 효과 크기 기준 정렬 (음수 값이므로 절대값으로 비교) - debuffSkills.sort((a, b) { - final aValue = - (a.buff?.atkModifier ?? 0).abs() + - (a.buff?.defModifier ?? 0).abs() * 0.5; - final bValue = - (b.buff?.atkModifier ?? 0).abs() + - (b.buff?.defModifier ?? 0).abs() * 0.5; - return bValue.compareTo(aValue); - }); - - return debuffSkills.first; } // ============================================================================ diff --git a/lib/src/core/engine/task_generator.dart b/lib/src/core/engine/task_generator.dart new file mode 100644 index 0000000..ed6152b --- /dev/null +++ b/lib/src/core/engine/task_generator.dart @@ -0,0 +1,259 @@ +import 'dart:math' as math; + +import 'package:asciineverdie/data/game_text_l10n.dart' as l10n; +import 'package:asciineverdie/src/shared/animation/monster_size.dart'; +import 'package:asciineverdie/src/core/engine/act_progression_service.dart'; +import 'package:asciineverdie/src/core/engine/combat_calculator.dart'; +import 'package:asciineverdie/src/core/model/combat_state.dart'; +import 'package:asciineverdie/src/core/model/combat_stats.dart'; +import 'package:asciineverdie/src/core/model/game_state.dart'; +import 'package:asciineverdie/src/core/model/monster_combat_stats.dart'; +import 'package:asciineverdie/src/core/model/monster_grade.dart'; +import 'package:asciineverdie/src/core/model/pq_config.dart'; +import 'package:asciineverdie/src/core/util/balance_constants.dart'; +import 'package:asciineverdie/src/core/util/pq_logic.dart' as pq_logic; + +/// 태스크 생성 서비스 +/// +/// ProgressService에서 분리된 다음 태스크 생성 로직 담당: +/// - 시장 이동, 전환 태스크, 보스 리트라이, 몬스터 전투 생성 +class TaskGenerator { + const TaskGenerator({required this.config}); + + final PqConfig config; + + /// 큐가 비어있을 때 다음 태스크 생성 (원본 Dequeue 667-684줄) + ({ProgressState progress, QueueState queue}) generateNextTask( + GameState state, + ) { + var progress = state.progress; + final queue = state.queue; + final oldTaskType = progress.currentTask.type; + + // 1. Encumbrance 초과 시 시장 이동 + if (_shouldGoToMarket(progress)) { + return _createMarketTask(progress, queue); + } + + // 2. 전환 태스크 (buying/heading) + if (_needsTransitionTask(oldTaskType)) { + return _createTransitionTask(state, progress, queue); + } + + // 3. Act Boss 리트라이 + if (state.progress.pendingActCompletion) { + return _createActBossRetryTask(state, progress, queue); + } + + // 4. 최종 보스 전투 + if (state.progress.finalBossState == FinalBossState.fighting && + !state.progress.isInBossLevelingMode) { + if (state.progress.bossLevelingEndTime != null) { + progress = progress.copyWith(clearBossLevelingEndTime: true); + } + final actProgressionService = ActProgressionService(config: config); + return actProgressionService.startFinalBossFight(state, progress, queue); + } + + // 5. 일반 몬스터 전투 + return _createMonsterTask(state, progress, queue); + } + + /// 시장 이동 조건 확인 + bool _shouldGoToMarket(ProgressState progress) { + return progress.encumbrance.position >= progress.encumbrance.max && + progress.encumbrance.max > 0; + } + + /// 전환 태스크 필요 여부 확인 + bool _needsTransitionTask(TaskType oldTaskType) { + return oldTaskType != TaskType.kill && + oldTaskType != TaskType.neutral && + oldTaskType != TaskType.buying; + } + + /// 시장 이동 태스크 생성 + ({ProgressState progress, QueueState queue}) _createMarketTask( + ProgressState progress, + QueueState queue, + ) { + final taskResult = pq_logic.startTask( + progress, + l10n.taskHeadingToMarket(), + 4 * 1000, + ); + final updatedProgress = taskResult.progress.copyWith( + currentTask: TaskInfo(caption: taskResult.caption, type: TaskType.market), + currentCombat: null, + ); + return (progress: updatedProgress, queue: queue); + } + + /// 전환 태스크 생성 (buying 또는 heading) + ({ProgressState progress, QueueState queue}) _createTransitionTask( + GameState state, + ProgressState progress, + QueueState queue, + ) { + final gold = state.inventory.gold; + final equipPrice = state.traits.level * 50; + + // Gold 충분 시 장비 구매 + if (gold > equipPrice) { + final taskResult = pq_logic.startTask( + progress, + l10n.taskUpgradingHardware(), + 5 * 1000, + ); + final updatedProgress = taskResult.progress.copyWith( + currentTask: TaskInfo( + caption: taskResult.caption, + type: TaskType.buying, + ), + currentCombat: null, + ); + return (progress: updatedProgress, queue: queue); + } + + // Gold 부족 시 전장 이동 + final taskResult = pq_logic.startTask( + progress, + l10n.taskEnteringDebugZone(), + 4 * 1000, + ); + final updatedProgress = taskResult.progress.copyWith( + currentTask: TaskInfo( + caption: taskResult.caption, + type: TaskType.neutral, + ), + currentCombat: null, + ); + return (progress: updatedProgress, queue: queue); + } + + /// Act Boss 재도전 태스크 생성 + ({ProgressState progress, QueueState queue}) _createActBossRetryTask( + GameState state, + ProgressState progress, + QueueState queue, + ) { + final actProgressionService = ActProgressionService(config: config); + final actBoss = actProgressionService.createActBoss(state); + final combatCalculator = CombatCalculator(rng: state.rng); + final durationMillis = combatCalculator.estimateCombatDurationMs( + player: actBoss.playerStats, + monster: actBoss.monsterStats, + ); + + final taskResult = pq_logic.startTask( + progress, + l10n.taskDebugging(actBoss.monsterStats.name), + durationMillis, + ); + + final updatedProgress = taskResult.progress.copyWith( + currentTask: TaskInfo( + caption: taskResult.caption, + type: TaskType.kill, + monsterBaseName: actBoss.monsterStats.name, + monsterPart: '*', + monsterLevel: actBoss.monsterStats.level, + monsterGrade: MonsterGrade.boss, + monsterSize: getBossSizeForAct(state.progress.plotStageCount), + ), + currentCombat: actBoss, + ); + + return (progress: updatedProgress, queue: queue); + } + + /// 일반 몬스터 전투 태스크 생성 + ({ProgressState progress, QueueState queue}) _createMonsterTask( + GameState state, + ProgressState progress, + QueueState queue, + ) { + final level = state.traits.level; + + // 퀘스트 몬스터 데이터 확인 + final questMonster = state.progress.currentQuestMonster; + final questMonsterData = questMonster?.monsterData; + final questLevel = questMonsterData != null + ? int.tryParse(questMonsterData.split('|').elementAtOrNull(1) ?? '') ?? + 0 + : null; + + // 몬스터 생성 + final monsterResult = pq_logic.monsterTask( + config, + state.rng, + level, + questMonsterData, + questLevel, + ); + + // 몬스터 레벨 조정 (밸런스) + final actMinLevel = ActMonsterLevel.forPlotStage( + state.progress.plotStageCount, + ); + final baseLevel = math.max(level, actMinLevel); + final effectiveMonsterLevel = monsterResult.level + .clamp(math.max(1, baseLevel - 3), baseLevel + 3) + .toInt(); + + // 전투 스탯 생성 + final playerCombatStats = CombatStats.fromStats( + stats: state.stats, + equipment: state.equipment, + level: level, + monsterLevel: effectiveMonsterLevel, + ); + + final monsterCombatStats = MonsterCombatStats.fromLevel( + name: monsterResult.displayName, + level: effectiveMonsterLevel, + speedType: MonsterCombatStats.inferSpeedType(monsterResult.baseName), + plotStageCount: state.progress.plotStageCount, + ); + + // 전투 상태 및 지속시간 + final combatState = CombatState.start( + playerStats: playerCombatStats, + monsterStats: monsterCombatStats, + ); + + final combatCalculator = CombatCalculator(rng: state.rng); + final durationMillis = combatCalculator.estimateCombatDurationMs( + player: playerCombatStats, + monster: monsterCombatStats, + ); + + final taskResult = pq_logic.startTask( + progress, + l10n.taskDebugging(monsterResult.displayName), + durationMillis, + ); + + // 몬스터 사이즈 결정 + final monsterSize = getMonsterSizeForAct( + plotStageCount: state.progress.plotStageCount, + grade: monsterResult.grade, + rng: state.rng, + ); + + final updatedProgress = taskResult.progress.copyWith( + currentTask: TaskInfo( + caption: taskResult.caption, + type: TaskType.kill, + monsterBaseName: monsterResult.baseName, + monsterPart: monsterResult.part, + monsterLevel: effectiveMonsterLevel, + monsterGrade: monsterResult.grade, + monsterSize: monsterSize, + ), + currentCombat: combatState, + ); + + return (progress: updatedProgress, queue: queue); + } +}