diff --git a/lib/src/core/engine/arena_service.dart b/lib/src/core/engine/arena_service.dart new file mode 100644 index 0000000..43cd657 --- /dev/null +++ b/lib/src/core/engine/arena_service.dart @@ -0,0 +1,314 @@ +import 'package:asciineverdie/src/core/engine/combat_calculator.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/hall_of_fame.dart'; +import 'package:asciineverdie/src/core/model/monster_combat_stats.dart'; +import 'package:asciineverdie/src/core/util/deterministic_random.dart'; + +/// 아레나 서비스 +/// +/// 로컬 아레나 대전 시스템의 핵심 로직 담당: +/// - 순위 계산 및 상대 결정 +/// - 전투 실행 +/// - 장비 교환 +class ArenaService { + ArenaService({DeterministicRandom? rng}) + : _rng = rng ?? DeterministicRandom(DateTime.now().millisecondsSinceEpoch); + + final DeterministicRandom _rng; + + // ============================================================================ + // 상대 결정 + // ============================================================================ + + /// 상대 결정 (바로 위 순위, 1위면 2위와 대결) + /// + /// [hallOfFame] 명예의 전당 + /// [challengerId] 도전자 캐릭터 ID + /// Returns: 상대 캐릭터 (없으면 null) + HallOfFameEntry? findOpponent(HallOfFame hallOfFame, String challengerId) { + final ranked = hallOfFame.rankedEntries; + if (ranked.length < 2) return null; + + final currentRank = hallOfFame.getRank(challengerId); + if (currentRank <= 0) return null; + + // 1위면 2위와 대결 + if (currentRank == 1) { + return ranked[1]; + } + + // 그 외는 바로 위 순위와 대결 + return ranked[currentRank - 2]; + } + + // ============================================================================ + // 전투 실행 + // ============================================================================ + + /// 아레나 전투 실행 + /// + /// [match] 대전 정보 + /// Returns: 대전 결과 (승패, 장비 교환 후 캐릭터) + ArenaMatchResult executeCombat(ArenaMatch match) { + final calculator = CombatCalculator(rng: _rng); + + // 도전자 스탯 (풀 HP로 시작) + final challengerStats = match.challenger.finalStats; + final opponentStats = match.opponent.finalStats; + + if (challengerStats == null || opponentStats == null) { + // 스탯이 없으면 도전자 패배 처리 + return ArenaMatchResult( + match: match, + isVictory: false, + turns: 0, + updatedChallenger: match.challenger, + updatedOpponent: match.opponent, + ); + } + + // 플레이어 스탯 (풀 HP로 초기화) + var playerCombatStats = challengerStats.copyWith( + hpCurrent: challengerStats.hpMax, + mpCurrent: challengerStats.mpMax, + ); + + // 상대를 몬스터 형태로 변환 + var opponentMonsterStats = MonsterCombatStats.fromCombatStats( + opponentStats, + match.opponent.characterName, + ); + + // 전투 시뮬레이션 + int turns = 0; + int playerAccum = 0; + int opponentAccum = 0; + const tickMs = 200; + + while (playerCombatStats.isAlive && opponentMonsterStats.isAlive) { + playerAccum += tickMs; + opponentAccum += tickMs; + + // 플레이어 공격 + if (playerAccum >= playerCombatStats.attackDelayMs) { + final result = calculator.playerAttackMonster( + attacker: playerCombatStats, + defender: opponentMonsterStats, + ); + opponentMonsterStats = result.updatedDefender; + playerAccum = 0; + turns++; + } + + // 상대 공격 (살아있을 때만) + if (opponentMonsterStats.isAlive && + opponentAccum >= opponentMonsterStats.attackDelayMs) { + final result = calculator.monsterAttackPlayer( + attacker: opponentMonsterStats, + defender: playerCombatStats, + ); + playerCombatStats = result.updatedDefender; + opponentAccum = 0; + } + + // 무한 루프 방지 + if (turns > 1000) break; + } + + final isVictory = playerCombatStats.isAlive; + + // 장비 교환 + final (updatedChallenger, updatedOpponent) = _exchangeEquipment( + match: match, + isVictory: isVictory, + ); + + return ArenaMatchResult( + match: match, + isVictory: isVictory, + turns: turns, + updatedChallenger: updatedChallenger, + updatedOpponent: updatedOpponent, + ); + } + + /// 전투 시뮬레이션 (애니메이션용 스트림) + /// + /// [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; + } + + var playerCombatStats = challengerStats.copyWith( + hpCurrent: challengerStats.hpMax, + mpCurrent: challengerStats.mpMax, + ); + + var opponentMonsterStats = MonsterCombatStats.fromCombatStats( + opponentStats, + match.opponent.characterName, + ); + + int playerAccum = 0; + int opponentAccum = 0; + const tickMs = 200; + int turns = 0; + + // 초기 상태 전송 + yield ArenaCombatTurn( + challengerHp: playerCombatStats.hpCurrent, + opponentHp: opponentMonsterStats.hpCurrent, + challengerHpMax: playerCombatStats.hpMax, + opponentHpMax: opponentMonsterStats.hpMax, + ); + + while (playerCombatStats.isAlive && opponentMonsterStats.isAlive) { + playerAccum += tickMs; + opponentAccum += tickMs; + + int? challengerDamage; + int? opponentDamage; + bool isChallengerCritical = false; + bool isOpponentCritical = false; + bool isChallengerEvaded = false; + bool isOpponentEvaded = false; + bool isChallengerBlocked = false; + bool isOpponentBlocked = false; + + // 플레이어 공격 + if (playerAccum >= playerCombatStats.attackDelayMs) { + final result = calculator.playerAttackMonster( + attacker: playerCombatStats, + defender: opponentMonsterStats, + ); + opponentMonsterStats = result.updatedDefender; + playerAccum = 0; + + if (result.result.isHit) { + challengerDamage = result.result.damage; + isChallengerCritical = result.result.isCritical; + } else { + isOpponentEvaded = true; + } + } + + // 상대 공격 + if (opponentMonsterStats.isAlive && + opponentAccum >= opponentMonsterStats.attackDelayMs) { + final result = calculator.monsterAttackPlayer( + attacker: opponentMonsterStats, + defender: playerCombatStats, + ); + playerCombatStats = result.updatedDefender; + opponentAccum = 0; + + if (result.result.isHit) { + opponentDamage = result.result.damage; + isOpponentCritical = result.result.isCritical; + isChallengerBlocked = result.result.isBlocked; + } else { + isChallengerEvaded = true; + } + } + + // 공격이 발생했을 때만 턴 전송 + if (challengerDamage != null || opponentDamage != null) { + turns++; + yield ArenaCombatTurn( + challengerDamage: challengerDamage, + opponentDamage: opponentDamage, + challengerHp: playerCombatStats.hpCurrent, + opponentHp: opponentMonsterStats.hpCurrent, + challengerHpMax: playerCombatStats.hpMax, + opponentHpMax: opponentMonsterStats.hpMax, + isChallengerCritical: isChallengerCritical, + isOpponentCritical: isOpponentCritical, + isChallengerEvaded: isChallengerEvaded, + isOpponentEvaded: isOpponentEvaded, + isChallengerBlocked: isChallengerBlocked, + isOpponentBlocked: isOpponentBlocked, + ); + + // 애니메이션을 위한 딜레이 + await Future.delayed(const Duration(milliseconds: 100)); + } + + // 무한 루프 방지 + if (turns > 1000) break; + } + } + + // ============================================================================ + // 장비 교환 + // ============================================================================ + + /// 장비 교환 (같은 슬롯끼리) + /// + /// 승자가 선택한 슬롯의 장비를 서로 교환 + (HallOfFameEntry, HallOfFameEntry) _exchangeEquipment({ + required ArenaMatch match, + required bool isVictory, + }) { + final slot = match.bettingSlot; + + // 도전자 장비 목록 복사 + final challengerEquipment = + List.from(match.challenger.finalEquipment ?? []); + + // 상대 장비 목록 복사 + final opponentEquipment = + List.from(match.opponent.finalEquipment ?? []); + + // 해당 슬롯의 장비 찾기 + final challengerItem = _findItemBySlot(challengerEquipment, slot); + final opponentItem = _findItemBySlot(opponentEquipment, slot); + + // 장비 교환 + _replaceItemInList(challengerEquipment, slot, opponentItem); + _replaceItemInList(opponentEquipment, slot, challengerItem); + + // 업데이트된 엔트리 생성 + final updatedChallenger = match.challenger.copyWith( + finalEquipment: challengerEquipment, + ); + final updatedOpponent = match.opponent.copyWith( + finalEquipment: opponentEquipment, + ); + + return (updatedChallenger, updatedOpponent); + } + + /// 슬롯으로 장비 찾기 + EquipmentItem _findItemBySlot( + List equipment, EquipmentSlot slot) { + for (final item in equipment) { + if (item.slot == slot) return item; + } + return EquipmentItem.empty(slot); + } + + /// 장비 목록에서 특정 슬롯의 아이템 교체 + void _replaceItemInList( + List equipment, + EquipmentSlot slot, + EquipmentItem newItem, + ) { + for (var i = 0; i < equipment.length; i++) { + if (equipment[i].slot == slot) { + equipment[i] = newItem; + return; + } + } + // 슬롯이 없으면 추가 + equipment.add(newItem); + } +}