From a41984d998693da94f0e339b10662cf754f7076c Mon Sep 17 00:00:00 2001 From: JiWoong Sul Date: Thu, 15 Jan 2026 01:53:51 +0900 Subject: [PATCH] =?UTF-8?q?test:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EB=A6=AC=ED=8C=A9?= =?UTF-8?q?=ED=86=A0=EB=A7=81=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - combat_calculator_test, skill_service_test, stat_calculator_test 추가 - mock_factories.dart 헬퍼 추가 - progress_loop_test, game_session_controller_test 서비스 분리 반영 --- test/core/engine/combat_calculator_test.dart | 337 +++++++++ test/core/engine/progress_loop_test.dart | 51 +- test/core/engine/skill_service_test.dart | 679 ++++++++++++++++++ test/core/engine/stat_calculator_test.dart | 356 +++++++++ test/features/game_play_screen_test.dart | 46 +- .../game_session_controller_test.dart | 50 +- test/helpers/mock_factories.dart | 195 +++++ 7 files changed, 1576 insertions(+), 138 deletions(-) create mode 100644 test/core/engine/combat_calculator_test.dart create mode 100644 test/core/engine/skill_service_test.dart create mode 100644 test/core/engine/stat_calculator_test.dart create mode 100644 test/helpers/mock_factories.dart diff --git a/test/core/engine/combat_calculator_test.dart b/test/core/engine/combat_calculator_test.dart new file mode 100644 index 0000000..7609154 --- /dev/null +++ b/test/core/engine/combat_calculator_test.dart @@ -0,0 +1,337 @@ +import 'package:asciineverdie/src/core/engine/combat_calculator.dart'; +import 'package:asciineverdie/src/core/model/combat_stats.dart'; +import 'package:asciineverdie/src/core/model/monster_combat_stats.dart'; +import 'package:asciineverdie/src/core/util/deterministic_random.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('CombatCalculator', () { + group('playerAttackMonster', () { + test('데미지 = (ATK * variation) - (DEF * 0.4)', () { + // 고정 시드로 예측 가능한 결과 + final rng = DeterministicRandom( 42); + final calculator = CombatCalculator(rng: rng); + + final player = CombatStats.empty().copyWith( + atk: 100, + accuracy: 1.0, // 100% 명중 + criRate: 0.0, // 크리티컬 없음 + criDamage: 1.5, + ); + + final monster = MonsterCombatStats( + name: 'Test Monster', + level: 1, + atk: 10, + def: 50, // DEF 50 → 50 * 0.4 = 20 감소 + hpMax: 100, + hpCurrent: 100, + criRate: 0.05, + criDamage: 1.5, + evasion: 0.0, // 회피 없음 + accuracy: 0.8, + attackDelayMs: 1000, + expReward: 100, + ); + + final result = calculator.playerAttackMonster( + attacker: player, + defender: monster, + ); + + // ATK 100 * (0.8~1.2) - DEF 50 * 0.4 = 80~120 - 20 = 60~100 + expect(result.result.damage, greaterThanOrEqualTo(1)); + expect(result.result.isHit, isTrue); + expect(result.result.isEvaded, isFalse); + expect(result.updatedDefender.hpCurrent, lessThan(monster.hpCurrent)); + }); + + test('크리티컬 발동 시 데미지 배율 적용', () { + // 크리티컬이 항상 발동하도록 criRate = 1.0 + final rng = DeterministicRandom( 123); + final calculator = CombatCalculator(rng: rng); + + final player = CombatStats.empty().copyWith( + atk: 100, + accuracy: 1.0, // 100% 명중 + criRate: 1.0, // 100% 크리티컬 + criDamage: 2.0, // 2배 데미지 + ); + + final monster = MonsterCombatStats( + name: 'Test Monster', + level: 1, + atk: 10, + def: 0, // DEF 0 + hpMax: 500, + hpCurrent: 500, + criRate: 0.05, + criDamage: 1.5, + evasion: 0.0, // 회피 없음 + accuracy: 0.8, + attackDelayMs: 1000, + expReward: 100, + ); + + final result = calculator.playerAttackMonster( + attacker: player, + defender: monster, + ); + + // 크리티컬 시 데미지가 2배 적용 + expect(result.result.isCritical, isTrue); + // ATK 100 * (0.8~1.2) * 2.0 = 160~240 + expect(result.result.damage, greaterThanOrEqualTo(160)); + }); + + test('회피 발동 시 0 데미지', () { + final rng = DeterministicRandom( 42); + final calculator = CombatCalculator(rng: rng); + + final player = CombatStats.empty().copyWith( + atk: 100, + accuracy: 0.0, // 0% 명중 → 항상 회피 + criRate: 0.0, + criDamage: 1.5, + ); + + final monster = MonsterCombatStats( + name: 'Test Monster', + level: 1, + atk: 10, + def: 0, + hpMax: 100, + hpCurrent: 100, + criRate: 0.05, + criDamage: 1.5, + evasion: 1.0, // 100% 회피 + accuracy: 0.8, + attackDelayMs: 1000, + expReward: 100, + ); + + final result = calculator.playerAttackMonster( + attacker: player, + defender: monster, + ); + + // 회피 시 데미지 0 + expect(result.result.damage, equals(0)); + expect(result.result.isHit, isFalse); + expect(result.result.isEvaded, isTrue); + expect(result.updatedDefender.hpCurrent, equals(monster.hpCurrent)); + }); + }); + + group('monsterAttackPlayer', () { + test('블록 발동 시 70% 감소', () { + // 블록이 발동하는 시드 찾기 + // blockRate = 1.0으로 항상 블록 + final rng = DeterministicRandom( 99); + final calculator = CombatCalculator(rng: rng); + + final monster = MonsterCombatStats( + name: 'Test Monster', + level: 1, + atk: 100, + def: 0, + hpMax: 100, + hpCurrent: 100, + criRate: 0.0, // 크리티컬 없음 + criDamage: 1.5, + evasion: 0.0, + accuracy: 1.0, // 100% 명중 + attackDelayMs: 1000, + expReward: 100, + ); + + final player = CombatStats.empty().copyWith( + hpMax: 500, + hpCurrent: 500, + def: 0, + evasion: 0.0, + blockRate: 1.0, // 100% 블록 + parryRate: 0.0, + ); + + final result = calculator.monsterAttackPlayer( + attacker: monster, + defender: player, + ); + + // 블록 시 데미지 30% (70% 감소) + // ATK 100 * (0.8~1.2) * 0.3 = 24~36 + expect(result.result.isBlocked, isTrue); + expect(result.result.damage, lessThanOrEqualTo(40)); + }); + + test('패리 발동 시 50% 감소', () { + final rng = DeterministicRandom( 77); + final calculator = CombatCalculator(rng: rng); + + final monster = MonsterCombatStats( + name: 'Test Monster', + level: 1, + atk: 100, + def: 0, + hpMax: 100, + hpCurrent: 100, + criRate: 0.0, + criDamage: 1.5, + evasion: 0.0, + accuracy: 1.0, + attackDelayMs: 1000, + expReward: 100, + ); + + final player = CombatStats.empty().copyWith( + hpMax: 500, + hpCurrent: 500, + def: 0, + evasion: 0.0, + blockRate: 0.0, // 블록 없음 + parryRate: 1.0, // 100% 패리 + ); + + final result = calculator.monsterAttackPlayer( + attacker: monster, + defender: player, + ); + + // 패리 시 데미지 50% + // ATK 100 * (0.8~1.2) * 0.5 = 40~60 + expect(result.result.isParried, isTrue); + expect(result.result.damage, lessThanOrEqualTo(65)); + }); + }); + + group('estimateCombatDurationMs', () { + test('범위 2000~30000ms 내 반환', () { + final rng = DeterministicRandom( 42); + final calculator = CombatCalculator(rng: rng); + + final player = CombatStats.empty().copyWith( + atk: 50, + accuracy: 0.9, + attackDelayMs: 1000, + ); + + final monster = MonsterCombatStats( + name: 'Test Monster', + level: 1, + atk: 10, + def: 10, + hpMax: 100, + hpCurrent: 100, + criRate: 0.05, + criDamage: 1.5, + evasion: 0.0, + accuracy: 0.8, + attackDelayMs: 1000, + expReward: 100, + ); + + final duration = calculator.estimateCombatDurationMs( + player: player, + monster: monster, + ); + + expect(duration, greaterThanOrEqualTo(2000)); + expect(duration, lessThanOrEqualTo(30000)); + }); + + test('고레벨 몬스터는 더 긴 전투 시간', () { + final rng = DeterministicRandom( 42); + final calculator = CombatCalculator(rng: rng); + + final player = CombatStats.empty().copyWith( + atk: 50, + accuracy: 0.9, + attackDelayMs: 1000, + ); + + final weakMonster = MonsterCombatStats( + name: 'Weak Monster', + level: 1, + atk: 10, + def: 5, + hpMax: 50, + hpCurrent: 50, + criRate: 0.05, + criDamage: 1.5, + evasion: 0.0, + accuracy: 0.8, + attackDelayMs: 1000, + expReward: 50, + ); + + final strongMonster = MonsterCombatStats( + name: 'Strong Monster', + level: 10, + atk: 50, + def: 20, + hpMax: 500, + hpCurrent: 500, + criRate: 0.05, + criDamage: 1.5, + evasion: 0.0, + accuracy: 0.8, + attackDelayMs: 1000, + expReward: 200, + ); + + final weakDuration = calculator.estimateCombatDurationMs( + player: player, + monster: weakMonster, + ); + + final strongDuration = calculator.estimateCombatDurationMs( + player: player, + monster: strongMonster, + ); + + // 강한 몬스터가 더 긴 전투 시간 + expect(strongDuration, greaterThan(weakDuration)); + }); + }); + + group('evaluateDifficulty', () { + test('범위 0.0~1.0 내 반환', () { + final rng = DeterministicRandom( 42); + final calculator = CombatCalculator(rng: rng); + + final player = CombatStats.empty().copyWith( + hpMax: 100, + hpCurrent: 100, + atk: 50, + def: 20, + accuracy: 0.9, + attackDelayMs: 1000, + ); + + final monster = MonsterCombatStats( + name: 'Test Monster', + level: 1, + atk: 30, + def: 10, + hpMax: 80, + hpCurrent: 80, + criRate: 0.05, + criDamage: 1.5, + evasion: 0.0, + accuracy: 0.8, + attackDelayMs: 1000, + expReward: 100, + ); + + final difficulty = calculator.evaluateDifficulty( + player: player, + monster: monster, + ); + + expect(difficulty, greaterThanOrEqualTo(0.0)); + expect(difficulty, lessThanOrEqualTo(1.0)); + }); + }); + }); +} diff --git a/test/core/engine/progress_loop_test.dart b/test/core/engine/progress_loop_test.dart index 3ab1f1c..08212f0 100644 --- a/test/core/engine/progress_loop_test.dart +++ b/test/core/engine/progress_loop_test.dart @@ -1,63 +1,18 @@ -import 'package:asciineverdie/src/core/engine/game_mutations.dart'; import 'package:asciineverdie/src/core/engine/progress_loop.dart'; -import 'package:asciineverdie/src/core/engine/progress_service.dart'; -import 'package:asciineverdie/src/core/engine/reward_service.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/pq_config.dart'; -import 'package:asciineverdie/src/core/storage/save_manager.dart'; -import 'package:asciineverdie/src/core/storage/save_repository.dart'; -import 'package:asciineverdie/src/core/storage/save_service.dart'; import 'package:asciineverdie/src/core/util/balance_constants.dart'; import 'package:flutter_test/flutter_test.dart'; -class _FakeSaveManager implements SaveManager { - final List savedStates = []; - - @override - Future saveState( - GameState state, { - String? fileName, - bool cheatsEnabled = false, - }) async { - savedStates.add(state); - return const SaveOutcome.success(); - } - - @override - Future<(SaveOutcome, GameState?, bool)> loadState({String? fileName}) async { - return (const SaveOutcome.success(), null, false); - } - - @override - Future> listSaves() async => []; - - @override - Future deleteSave({String? fileName}) async { - return const SaveOutcome.success(); - } - - @override - Future saveExists({String? fileName}) async => false; -} +import '../../helpers/mock_factories.dart'; void main() { - late ProgressService service; - - setUp(() { - const config = PqConfig(); - final mutations = GameMutations(config); - service = ProgressService( - config: config, - mutations: mutations, - rewards: RewardService(mutations, config), - ); - }); + late final service = MockFactories.createProgressService(); test('autosaves on level-up and stop when configured', () async { - final saveManager = _FakeSaveManager(); + final saveManager = FakeSaveManager(); // 레벨 1에서 레벨업에 필요한 경험치 final requiredExp = ExpConstants.requiredExp(1); diff --git a/test/core/engine/skill_service_test.dart b/test/core/engine/skill_service_test.dart new file mode 100644 index 0000000..4453302 --- /dev/null +++ b/test/core/engine/skill_service_test.dart @@ -0,0 +1,679 @@ +import 'package:asciineverdie/data/skill_data.dart'; +import 'package:asciineverdie/src/core/engine/skill_service.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'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('SkillService', () { + group('canUseSkill', () { + test('GCD 활성화 시 onGlobalCooldown 반환', () { + final rng = DeterministicRandom(42); + final service = SkillService(rng: rng); + + const skill = SkillData.stackTrace; + final skillSystem = SkillSystemState.empty().copyWith( + elapsedMs: 1000, + globalCooldownEndMs: 2500, // GCD 활성화 중 + ); + + final result = service.canUseSkill( + skill: skill, + currentMp: 100, + skillSystem: skillSystem, + ); + + expect(result, equals(SkillFailReason.onGlobalCooldown)); + }); + + test('MP 부족 시 notEnoughMp 반환', () { + final rng = DeterministicRandom(42); + final service = SkillService(rng: rng); + + const skill = SkillData.stackTrace; // MP 10 필요 + final skillSystem = SkillSystemState.empty().copyWith(elapsedMs: 5000); + + final result = service.canUseSkill( + skill: skill, + currentMp: 5, // MP 부족 + skillSystem: skillSystem, + ); + + expect(result, equals(SkillFailReason.notEnoughMp)); + }); + + test('쿨타임 중 onCooldown 반환', () { + final rng = DeterministicRandom(42); + final service = SkillService(rng: rng); + + const skill = SkillData.stackTrace; // 쿨타임 3000ms + final skillSystem = SkillSystemState.empty().copyWith( + elapsedMs: 2000, + skillStates: [ + const SkillState( + skillId: 'stack_trace', + lastUsedMs: 1000, // 1초 전 사용 + rank: 1, + ), + ], + ); + + final result = service.canUseSkill( + skill: skill, + currentMp: 100, + skillSystem: skillSystem, + ); + + expect(result, equals(SkillFailReason.onCooldown)); + }); + + test('모든 조건 충족 시 null 반환', () { + final rng = DeterministicRandom(42); + final service = SkillService(rng: rng); + + const skill = SkillData.stackTrace; + final skillSystem = SkillSystemState.empty().copyWith(elapsedMs: 5000); + + final result = service.canUseSkill( + skill: skill, + currentMp: 100, + skillSystem: skillSystem, + ); + + expect(result, isNull); + }); + }); + + group('useAttackSkill', () { + test('기본 공격 데미지 계산', () { + final rng = DeterministicRandom(42); + final service = SkillService(rng: rng); + + const skill = SkillData.stackTrace; // 2.0x 배율 + final player = CombatStats.empty().copyWith( + atk: 100, + mpCurrent: 50, + mpMax: 100, + ); + final monster = MonsterCombatStats( + name: 'Test Monster', + level: 1, + atk: 10, + def: 50, // DEF 50 → 50 * 0.3 = 15 감소 + hpMax: 500, + hpCurrent: 500, + criRate: 0.05, + criDamage: 1.5, + evasion: 0.0, + accuracy: 0.8, + attackDelayMs: 1000, + expReward: 100, + ); + final skillSystem = SkillSystemState.empty().copyWith(elapsedMs: 5000); + + final result = service.useAttackSkill( + skill: skill, + player: player, + monster: monster, + skillSystem: skillSystem, + ); + + // ATK 100 * 2.0 - DEF 50 * 0.3 = 200 - 15 = 185 + expect(result.result.success, isTrue); + expect(result.result.damage, equals(185)); + expect(result.updatedPlayer.mpCurrent, equals(40)); // 50 - 10 + expect(result.updatedMonster.hpCurrent, equals(315)); // 500 - 185 + }); + + test('버프 적용 시 데미지 증가', () { + final rng = DeterministicRandom(42); + final service = SkillService(rng: rng); + + const skill = SkillData.stackTrace; + final player = CombatStats.empty().copyWith( + atk: 100, + mpCurrent: 50, + mpMax: 100, + ); + final monster = MonsterCombatStats( + name: 'Test Monster', + level: 1, + atk: 10, + def: 0, + hpMax: 500, + hpCurrent: 500, + criRate: 0.05, + criDamage: 1.5, + evasion: 0.0, + accuracy: 0.8, + attackDelayMs: 1000, + expReward: 100, + ); + final skillSystem = SkillSystemState.empty().copyWith( + elapsedMs: 5000, + activeBuffs: [ + const ActiveBuff( + effect: BuffEffect( + id: 'test_buff', + name: 'Test Buff', + durationMs: 10000, + atkModifier: 0.5, // +50% ATK + ), + startedMs: 4000, + sourceSkillId: 'test', + ), + ], + ); + + final result = service.useAttackSkill( + skill: skill, + player: player, + monster: monster, + skillSystem: skillSystem, + ); + + // ATK 100 * 2.0 * 1.5 = 300 + expect(result.result.damage, equals(300)); + }); + }); + + group('useAttackSkillWithRank', () { + test('랭크 1 데미지 (기본 배율)', () { + final rng = DeterministicRandom(42); + final service = SkillService(rng: rng); + + const skill = SkillData.stackTrace; + final player = CombatStats.empty().copyWith( + atk: 100, + mpCurrent: 50, + mpMax: 100, + ); + final monster = MonsterCombatStats( + name: 'Test Monster', + level: 1, + atk: 10, + def: 0, + hpMax: 500, + hpCurrent: 500, + criRate: 0.05, + criDamage: 1.5, + evasion: 0.0, + accuracy: 0.8, + attackDelayMs: 1000, + expReward: 100, + ); + final skillSystem = SkillSystemState.empty().copyWith(elapsedMs: 5000); + + final result = service.useAttackSkillWithRank( + skill: skill, + player: player, + monster: monster, + skillSystem: skillSystem, + rank: 1, + ); + + // 랭크 1: 1.0x → ATK 100 * 2.0 * 1.0 = 200 + expect(result.result.damage, equals(200)); + expect(result.updatedPlayer.mpCurrent, equals(40)); // MP 10 소모 + }); + + test('랭크 5 데미지 스케일링', () { + final rng = DeterministicRandom(42); + final service = SkillService(rng: rng); + + const skill = SkillData.stackTrace; + final player = CombatStats.empty().copyWith( + atk: 100, + mpCurrent: 50, + mpMax: 100, + ); + final monster = MonsterCombatStats( + name: 'Test Monster', + level: 1, + atk: 10, + def: 0, + hpMax: 500, + hpCurrent: 500, + criRate: 0.05, + criDamage: 1.5, + evasion: 0.0, + accuracy: 0.8, + attackDelayMs: 1000, + expReward: 100, + ); + final skillSystem = SkillSystemState.empty().copyWith(elapsedMs: 5000); + + final result = service.useAttackSkillWithRank( + skill: skill, + player: player, + monster: monster, + skillSystem: skillSystem, + rank: 5, + ); + + // 랭크 5: 1.6x (1.0 + 4 * 0.15) + // ATK 100 * 2.0 * 1.6 = 320 + expect(result.result.damage, equals(320)); + + // MP 비용: 10 * (1.0 - 4 * 0.03) = 10 * 0.88 = 9 (반올림) + expect(result.updatedPlayer.mpCurrent, equals(41)); // 50 - 9 + }); + }); + + group('useHealSkill', () { + test('고정 회복량', () { + final rng = DeterministicRandom(42); + final service = SkillService(rng: rng); + + const skill = SkillData.hotReload; // healAmount: 30 + final player = CombatStats.empty().copyWith( + hpMax: 200, + hpCurrent: 100, + mpMax: 100, + mpCurrent: 50, + ); + final skillSystem = SkillSystemState.empty().copyWith(elapsedMs: 5000); + + final result = service.useHealSkill( + skill: skill, + player: player, + skillSystem: skillSystem, + ); + + expect(result.result.success, isTrue); + expect(result.result.healedAmount, equals(30)); + expect(result.updatedPlayer.hpCurrent, equals(130)); // 100 + 30 + expect(result.updatedPlayer.mpCurrent, equals(35)); // 50 - 15 + }); + + test('퍼센트 회복량', () { + final rng = DeterministicRandom(42); + final service = SkillService(rng: rng); + + const skill = SkillData.garbageCollection; // healPercent: 0.3 + final player = CombatStats.empty().copyWith( + hpMax: 200, + hpCurrent: 100, + mpMax: 100, + mpCurrent: 50, + ); + final skillSystem = SkillSystemState.empty().copyWith(elapsedMs: 5000); + + final result = service.useHealSkill( + skill: skill, + player: player, + skillSystem: skillSystem, + ); + + // healPercent 0.3 * hpMax 200 = 60 + expect(result.result.healedAmount, equals(60)); + expect(result.updatedPlayer.hpCurrent, equals(160)); // 100 + 60 + }); + + test('HP 캡 적용', () { + final rng = DeterministicRandom(42); + final service = SkillService(rng: rng); + + const skill = SkillData.snapshotRestore; // healPercent: 0.5 + final player = CombatStats.empty().copyWith( + hpMax: 200, + hpCurrent: 180, // 거의 만피 + mpMax: 100, + mpCurrent: 80, + ); + final skillSystem = SkillSystemState.empty().copyWith(elapsedMs: 5000); + + final result = service.useHealSkill( + skill: skill, + player: player, + skillSystem: skillSystem, + ); + + // healPercent 0.5 * hpMax 200 = 100 + // 하지만 hpMax 캡으로 200까지만 + expect(result.updatedPlayer.hpCurrent, equals(200)); + }); + }); + + group('useDotSkill', () { + test('DOT 효과 생성', () { + final rng = DeterministicRandom(42); + final service = SkillService(rng: rng); + + const skill = SkillData.memoryDump; + // baseDotDamage: 10, baseDotDurationMs: 6000, baseDotTickMs: 1000 + final player = CombatStats.empty().copyWith( + mpMax: 100, + mpCurrent: 50, + ); + final skillSystem = SkillSystemState.empty().copyWith(elapsedMs: 5000); + + final result = service.useDotSkill( + skill: skill, + player: player, + skillSystem: skillSystem, + playerInt: 10, // 기본 + playerWis: 10, // 기본 + ); + + expect(result.result.success, isTrue); + expect(result.dotEffect, isNotNull); + expect(result.dotEffect!.baseDamage, equals(10)); + expect(result.dotEffect!.damagePerTick, equals(10)); // INT 10 → 1.0x + expect(result.dotEffect!.tickIntervalMs, equals(1000)); // WIS 10 → 1.0x + expect(result.dotEffect!.totalDurationMs, equals(6000)); + + // 예상 총 데미지: 6틱 × 10 = 60 + expect(result.result.damage, equals(60)); + }); + + test('INT 보정 적용 (데미지 증가)', () { + final rng = DeterministicRandom(42); + final service = SkillService(rng: rng); + + const skill = SkillData.memoryDump; + final player = CombatStats.empty().copyWith( + mpMax: 100, + mpCurrent: 50, + ); + final skillSystem = SkillSystemState.empty().copyWith(elapsedMs: 5000); + + final result = service.useDotSkill( + skill: skill, + player: player, + skillSystem: skillSystem, + playerInt: 20, // INT +10 → +30% 데미지 + playerWis: 10, + ); + + // INT 20: 1.0 + (20-10) * 0.03 = 1.3x + // baseDotDamage 10 * 1.3 = 13 + expect(result.dotEffect!.damagePerTick, equals(13)); + }); + + test('WIS 보정 적용 (틱 간격 감소)', () { + final rng = DeterministicRandom(42); + final service = SkillService(rng: rng); + + const skill = SkillData.memoryDump; + final player = CombatStats.empty().copyWith( + mpMax: 100, + mpCurrent: 50, + ); + final skillSystem = SkillSystemState.empty().copyWith(elapsedMs: 5000); + + final result = service.useDotSkill( + skill: skill, + player: player, + skillSystem: skillSystem, + playerInt: 10, + playerWis: 20, // WIS +10 → 틱 간격 감소 + ); + + // WIS 20: 1.0 + (20-10) * 0.02 = 1.2x + // baseDotTickMs 1000 / 1.2 = 833ms + expect(result.dotEffect!.tickIntervalMs, equals(833)); + }); + }); + + group('calculateMpRegen', () { + test('비전투 중 MP 회복 (50ms당 1)', () { + final rng = DeterministicRandom(42); + final service = SkillService(rng: rng); + + final result = service.calculateMpRegen( + elapsedMs: 1000, // 1초 + isInCombat: false, + wis: 10, + ); + + // 1000ms / 50ms = 20 + expect(result, equals(20)); + }); + + test('전투 중 MP 회복 (WIS 기반)', () { + final rng = DeterministicRandom(42); + final service = SkillService(rng: rng); + + final result = service.calculateMpRegen( + elapsedMs: 1000, // 1초 + isInCombat: true, + wis: 10, + ); + + // 전투 중: 500ms당 (1 + WIS/20) + // WIS 10: 1 + 10/20 = 1 (정수 연산) + // 1000ms / 500ms = 2틱 + // 2틱 * 1 = 2 + expect(result, equals(2)); + }); + + test('높은 WIS 전투 중 MP 회복', () { + final rng = DeterministicRandom(42); + final service = SkillService(rng: rng); + + final result = service.calculateMpRegen( + elapsedMs: 1000, + isInCombat: true, + wis: 40, + ); + + // WIS 40: 1 + 40/20 = 3 + // 2틱 * 3 = 6 + expect(result, equals(6)); + }); + }); + + group('selectAutoSkill', () { + test('MP 부족 시 null 반환 (일반 공격)', () { + final rng = DeterministicRandom(42); + final service = SkillService(rng: rng); + + final player = CombatStats.empty().copyWith( + hpMax: 100, + hpCurrent: 100, + mpMax: 100, + mpCurrent: 10, // 10% MP + ); + final monster = MonsterCombatStats( + name: 'Test Monster', + level: 1, + atk: 10, + def: 10, + hpMax: 100, + hpCurrent: 100, + criRate: 0.05, + criDamage: 1.5, + evasion: 0.0, + accuracy: 0.8, + attackDelayMs: 1000, + expReward: 100, + ); + final skillSystem = SkillSystemState.empty().copyWith(elapsedMs: 5000); + + final result = service.selectAutoSkill( + player: player, + monster: monster, + skillSystem: skillSystem, + availableSkillIds: ['stack_trace', 'garbage_collection'], + ); + + expect(result, isNull); + }); + + test('HP 30% 미만 시 회복 스킬 우선', () { + // 시드 조정하여 일반 공격 확률을 넘어가도록 + final rng = DeterministicRandom(999); + final service = SkillService(rng: rng); + + final player = CombatStats.empty().copyWith( + hpMax: 100, + hpCurrent: 20, // 20% HP + mpMax: 100, + mpCurrent: 80, + ); + final monster = MonsterCombatStats( + name: 'Test Monster', + level: 1, + atk: 10, + def: 10, + hpMax: 100, + hpCurrent: 100, + criRate: 0.05, + criDamage: 1.5, + evasion: 0.0, + accuracy: 0.8, + attackDelayMs: 1000, + expReward: 100, + ); + final skillSystem = SkillSystemState.empty().copyWith(elapsedMs: 5000); + + final result = service.selectAutoSkill( + player: player, + monster: monster, + skillSystem: skillSystem, + availableSkillIds: ['stack_trace', 'garbage_collection'], + ); + + // HP < 30%이면 회복 스킬 반환 (garbage_collection) + expect(result?.type, equals(SkillType.heal)); + }); + }); + + group('useBuffSkill', () { + test('버프 적용', () { + final rng = DeterministicRandom(42); + final service = SkillService(rng: rng); + + const skill = SkillData.debugMode; // ATK +25% 버프 + final player = CombatStats.empty().copyWith( + mpMax: 100, + mpCurrent: 50, + ); + final skillSystem = SkillSystemState.empty().copyWith(elapsedMs: 5000); + + final result = service.useBuffSkill( + skill: skill, + player: player, + skillSystem: skillSystem, + ); + + expect(result.result.success, isTrue); + expect(result.result.appliedBuff, isNotNull); + expect( + result.result.appliedBuff!.effect.atkModifier, + equals(0.25), + ); + expect(result.updatedSkillSystem.activeBuffs.length, equals(1)); + expect(result.updatedPlayer.mpCurrent, equals(30)); // 50 - 20 + }); + + test('중복 버프 제거 후 새 버프 적용', () { + final rng = DeterministicRandom(42); + final service = SkillService(rng: rng); + + const skill = SkillData.debugMode; + final player = CombatStats.empty().copyWith( + mpMax: 100, + mpCurrent: 50, + ); + final existingBuff = const ActiveBuff( + effect: BuffEffect( + id: 'debug_mode_buff', + name: 'Debug Mode', + durationMs: 10000, + atkModifier: 0.25, + ), + startedMs: 1000, + sourceSkillId: 'debug_mode', + ); + final skillSystem = SkillSystemState.empty().copyWith( + elapsedMs: 5000, + activeBuffs: [existingBuff], + ); + + final result = service.useBuffSkill( + skill: skill, + player: player, + skillSystem: skillSystem, + ); + + // 기존 버프 제거 후 새 버프 추가 → 총 1개 + expect(result.updatedSkillSystem.activeBuffs.length, equals(1)); + expect( + result.updatedSkillSystem.activeBuffs.first.startedMs, + equals(5000), // 새 버프 + ); + }); + }); + + group('cleanupExpiredBuffs', () { + test('만료된 버프 제거', () { + final rng = DeterministicRandom(42); + final service = SkillService(rng: rng); + + final skillSystem = SkillSystemState.empty().copyWith( + elapsedMs: 20000, + activeBuffs: [ + const ActiveBuff( + effect: BuffEffect( + id: 'buff1', + name: 'Active Buff', + durationMs: 15000, // 아직 활성 + ), + startedMs: 10000, + sourceSkillId: 'test1', + ), + const ActiveBuff( + effect: BuffEffect( + id: 'buff2', + name: 'Expired Buff', + durationMs: 5000, // 만료됨 (시작 5000 + 지속 5000 = 10000 < 20000) + ), + startedMs: 5000, + sourceSkillId: 'test2', + ), + ], + ); + + final result = service.cleanupExpiredBuffs(skillSystem); + + expect(result.activeBuffs.length, equals(1)); + expect(result.activeBuffs.first.effect.id, equals('buff1')); + }); + }); + + group('getRankMultiplier', () { + test('랭크별 배율 계산', () { + expect(getRankMultiplier(1), equals(1.0)); + expect(getRankMultiplier(2), closeTo(1.15, 0.001)); + expect(getRankMultiplier(3), closeTo(1.30, 0.001)); + expect(getRankMultiplier(5), closeTo(1.60, 0.001)); + expect(getRankMultiplier(10), closeTo(2.35, 0.001)); + }); + }); + + group('getRankCooldownMultiplier', () { + test('랭크별 쿨타임 감소율', () { + expect(getRankCooldownMultiplier(1), equals(1.0)); + expect(getRankCooldownMultiplier(2), closeTo(0.95, 0.001)); + expect(getRankCooldownMultiplier(5), closeTo(0.80, 0.001)); + // 최대 50% 감소 + expect(getRankCooldownMultiplier(20), closeTo(0.5, 0.001)); + }); + }); + + group('getRankMpMultiplier', () { + test('랭크별 MP 비용 감소율', () { + expect(getRankMpMultiplier(1), equals(1.0)); + expect(getRankMpMultiplier(2), closeTo(0.97, 0.001)); + expect(getRankMpMultiplier(5), closeTo(0.88, 0.001)); + // 최대 30% 감소 + expect(getRankMpMultiplier(20), closeTo(0.7, 0.001)); + }); + }); + }); +} diff --git a/test/core/engine/stat_calculator_test.dart b/test/core/engine/stat_calculator_test.dart new file mode 100644 index 0000000..301b224 --- /dev/null +++ b/test/core/engine/stat_calculator_test.dart @@ -0,0 +1,356 @@ +import 'package:asciineverdie/src/core/engine/stat_calculator.dart'; +import 'package:asciineverdie/src/core/model/class_traits.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/race_traits.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + const calculator = StatCalculator(); + + group('StatCalculator', () { + group('applyModifiers', () { + test('종족 스탯 보정 적용', () { + final baseStats = const Stats( + str: 10, + con: 10, + dex: 10, + intelligence: 10, + wis: 10, + cha: 10, + hpMax: 100, + mpMax: 50, + ); + + // +2 STR, -1 INT 종족 + final race = RaceTraits( + raceId: 'test_race', + name: 'Test Race', + statModifiers: const { + StatType.str: 2, + StatType.intelligence: -1, + }, + ); + + // 보정 없는 클래스 + const klass = ClassTraits( + classId: 'test_class', + name: 'Test Class', + statModifiers: {}, + ); + + final result = calculator.applyModifiers( + baseStats: baseStats, + race: race, + klass: klass, + ); + + expect(result.str, equals(12)); // 10 + 2 + expect(result.intelligence, equals(9)); // 10 - 1 + expect(result.con, equals(10)); // 변화 없음 + }); + + test('클래스 스탯 보정 적용', () { + final baseStats = const Stats( + str: 10, + con: 10, + dex: 10, + intelligence: 10, + wis: 10, + cha: 10, + hpMax: 100, + mpMax: 50, + ); + + // 보정 없는 종족 + const race = RaceTraits( + raceId: 'test_race', + name: 'Test Race', + statModifiers: {}, + ); + + // +3 CON, +1 DEX 클래스 + const klass = ClassTraits( + classId: 'test_class', + name: 'Test Class', + statModifiers: { + StatType.con: 3, + StatType.dex: 1, + }, + ); + + final result = calculator.applyModifiers( + baseStats: baseStats, + race: race, + klass: klass, + ); + + expect(result.con, equals(13)); // 10 + 3 + expect(result.dex, equals(11)); // 10 + 1 + }); + + test('HP 보너스 패시브 적용 (종족)', () { + final baseStats = const Stats( + str: 10, + con: 10, + dex: 10, + intelligence: 10, + wis: 10, + cha: 10, + hpMax: 100, + mpMax: 50, + ); + + // +20% HP 종족 + final race = RaceTraits( + raceId: 'test_race', + name: 'Test Race', + statModifiers: const {}, + passives: const [ + PassiveAbility(type: PassiveType.hpBonus, value: 0.2), + ], + ); + + const klass = ClassTraits( + classId: 'test_class', + name: 'Test Class', + statModifiers: {}, + ); + + final result = calculator.applyModifiers( + baseStats: baseStats, + race: race, + klass: klass, + ); + + expect(result.hpMax, equals(120)); // 100 * 1.2 + }); + + test('MP 보너스 패시브 적용 (종족)', () { + final baseStats = const Stats( + str: 10, + con: 10, + dex: 10, + intelligence: 10, + wis: 10, + cha: 10, + hpMax: 100, + mpMax: 50, + ); + + // +20% MP 종족 + final race = RaceTraits( + raceId: 'test_race', + name: 'Test Race', + statModifiers: const {}, + passives: const [ + PassiveAbility(type: PassiveType.mpBonus, value: 0.2), + ], + ); + + const klass = ClassTraits( + classId: 'test_class', + name: 'Test Class', + statModifiers: {}, + ); + + final result = calculator.applyModifiers( + baseStats: baseStats, + race: race, + klass: klass, + ); + + expect(result.mpMax, equals(60)); // 50 * 1.2 + }); + }); + + group('applyPassives', () { + test('크리티컬 보너스 패시브 적용', () { + final combatStats = CombatStats.empty().copyWith( + atk: 50, + def: 20, + criRate: 0.1, + ); + + // +5% 크리티컬 종족 + final race = RaceTraits( + raceId: 'test_race', + name: 'Test Race', + statModifiers: const {}, + passives: const [ + PassiveAbility(type: PassiveType.criticalBonus, value: 0.05), + ], + ); + + const klass = ClassTraits( + classId: 'test_class', + name: 'Test Class', + statModifiers: {}, + ); + + final result = calculator.applyPassives( + combatStats: combatStats, + race: race, + klass: klass, + ); + + expect(result.criRate, closeTo(0.15, 0.001)); // 0.1 + 0.05 + }); + + test('크리티컬 확률 캡 (0.8) 적용', () { + final combatStats = CombatStats.empty().copyWith( + atk: 50, + def: 20, + criRate: 0.7, + ); + + // +20% 크리티컬 종족 (합계 0.9 → 캡 0.8) + final race = RaceTraits( + raceId: 'test_race', + name: 'Test Race', + statModifiers: const {}, + passives: const [ + PassiveAbility(type: PassiveType.criticalBonus, value: 0.2), + ], + ); + + const klass = ClassTraits( + classId: 'test_class', + name: 'Test Class', + statModifiers: {}, + ); + + final result = calculator.applyPassives( + combatStats: combatStats, + race: race, + klass: klass, + ); + + expect(result.criRate, equals(0.8)); // 캡 적용 + }); + + test('회피율 캡 (0.6) 적용', () { + final combatStats = CombatStats.empty().copyWith( + atk: 50, + def: 20, + evasion: 0.5, + ); + + const race = RaceTraits( + raceId: 'test_race', + name: 'Test Race', + statModifiers: {}, + ); + + // +15% 회피 클래스 (합계 0.65 → 캡 0.6) + const klass = ClassTraits( + classId: 'test_class', + name: 'Test Class', + statModifiers: {}, + passives: [ + ClassPassive( + type: ClassPassiveType.evasionBonus, + value: 0.15, + ), + ], + ); + + final result = calculator.applyPassives( + combatStats: combatStats, + race: race, + klass: klass, + ); + + expect(result.evasion, equals(0.6)); // 캡 적용 + }); + + test('물리 공격력 보너스 적용', () { + final combatStats = CombatStats.empty().copyWith( + atk: 100, + def: 20, + ); + + const race = RaceTraits( + raceId: 'test_race', + name: 'Test Race', + statModifiers: {}, + ); + + // +20% 물리 공격 클래스 + const klass = ClassTraits( + classId: 'test_class', + name: 'Test Class', + statModifiers: {}, + passives: [ + ClassPassive( + type: ClassPassiveType.physicalDamageBonus, + value: 0.2, + ), + ], + ); + + final result = calculator.applyPassives( + combatStats: combatStats, + race: race, + klass: klass, + ); + + expect(result.atk, equals(120)); // 100 * 1.2 + }); + }); + + group('calculateExpMultiplier', () { + test('경험치 배율 반환', () { + final race = RaceTraits( + raceId: 'test_race', + name: 'Test Race', + statModifiers: const {}, + expMultiplier: 1.10, // +10% + ); + + final result = calculator.calculateExpMultiplier(race); + + expect(result, equals(1.10)); + }); + }); + + group('calculatePostCombatHeal', () { + test('전투 후 회복량 계산', () { + // +5% 전투 후 회복 클래스 + const klass = ClassTraits( + classId: 'test_class', + name: 'Test Class', + statModifiers: {}, + passives: [ + ClassPassive( + type: ClassPassiveType.postCombatHeal, + value: 0.05, + ), + ], + ); + + final result = calculator.calculatePostCombatHeal( + klass: klass, + maxHp: 200, + ); + + expect(result, equals(10)); // 200 * 0.05 + }); + + test('패시브 없으면 0 반환', () { + const klass = ClassTraits( + classId: 'test_class', + name: 'Test Class', + statModifiers: {}, + ); + + final result = calculator.calculatePostCombatHeal( + klass: klass, + maxHp: 200, + ); + + expect(result, equals(0)); + }); + }); + }); +} diff --git a/test/features/game_play_screen_test.dart b/test/features/game_play_screen_test.dart index fcb8603..d6e292c 100644 --- a/test/features/game_play_screen_test.dart +++ b/test/features/game_play_screen_test.dart @@ -1,17 +1,12 @@ import 'package:asciineverdie/l10n/app_localizations.dart'; -import 'package:asciineverdie/src/core/engine/game_mutations.dart'; -import 'package:asciineverdie/src/core/engine/progress_service.dart'; -import 'package:asciineverdie/src/core/engine/reward_service.dart'; import 'package:asciineverdie/src/core/model/game_state.dart'; -import 'package:asciineverdie/src/core/model/pq_config.dart'; -import 'package:asciineverdie/src/core/storage/save_manager.dart'; -import 'package:asciineverdie/src/core/storage/save_repository.dart'; -import 'package:asciineverdie/src/core/storage/save_service.dart'; import 'package:asciineverdie/src/features/game/game_play_screen.dart'; import 'package:asciineverdie/src/features/game/game_session_controller.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import '../helpers/mock_factories.dart'; + /// 테스트용 MaterialApp 래퍼 (localization 포함) Widget _buildTestApp(Widget child) { return MaterialApp( @@ -21,33 +16,6 @@ Widget _buildTestApp(Widget child) { ); } -class _FakeSaveManager implements SaveManager { - @override - Future saveState( - GameState state, { - String? fileName, - bool cheatsEnabled = false, - }) async { - return const SaveOutcome.success(); - } - - @override - Future<(SaveOutcome, GameState?, bool)> loadState({String? fileName}) async { - return (const SaveOutcome.success(), null, false); - } - - @override - Future> listSaves() async => []; - - @override - Future deleteSave({String? fileName}) async { - return const SaveOutcome.success(); - } - - @override - Future saveExists({String? fileName}) async => false; -} - GameState _createTestState() { return GameState.withSeed( seed: 42, @@ -83,15 +51,9 @@ GameState _createTestState() { } GameSessionController _createController() { - const config = PqConfig(); - final mutations = GameMutations(config); return GameSessionController( - progressService: ProgressService( - config: config, - mutations: mutations, - rewards: RewardService(mutations, config), - ), - saveManager: _FakeSaveManager(), + progressService: MockFactories.createProgressService(), + saveManager: FakeSaveManager(), tickInterval: const Duration(seconds: 10), // 느린 틱 ); } diff --git a/test/features/game_session_controller_test.dart b/test/features/game_session_controller_test.dart index 4c8a4da..f813f1a 100644 --- a/test/features/game_session_controller_test.dart +++ b/test/features/game_session_controller_test.dart @@ -1,58 +1,12 @@ -import 'package:asciineverdie/src/core/engine/game_mutations.dart'; -import 'package:asciineverdie/src/core/engine/progress_service.dart'; -import 'package:asciineverdie/src/core/engine/reward_service.dart'; import 'package:asciineverdie/src/core/model/game_state.dart'; -import 'package:asciineverdie/src/core/model/pq_config.dart'; -import 'package:asciineverdie/src/core/storage/save_manager.dart'; -import 'package:asciineverdie/src/core/storage/save_repository.dart'; -import 'package:asciineverdie/src/core/storage/save_service.dart'; import 'package:asciineverdie/src/features/game/game_session_controller.dart'; import 'package:fake_async/fake_async.dart'; import 'package:flutter_test/flutter_test.dart'; -class FakeSaveManager implements SaveManager { - final List savedStates = []; - (SaveOutcome, GameState?, bool) Function(String?)? onLoad; - SaveOutcome saveOutcome = const SaveOutcome.success(); - - @override - Future saveState( - GameState state, { - String? fileName, - bool cheatsEnabled = false, - }) async { - savedStates.add(state); - return saveOutcome; - } - - @override - Future<(SaveOutcome, GameState?, bool)> loadState({String? fileName}) async { - if (onLoad != null) { - return onLoad!(fileName); - } - return (const SaveOutcome.success(), null, false); - } - - @override - Future> listSaves() async => []; - - @override - Future deleteSave({String? fileName}) async { - return const SaveOutcome.success(); - } - - @override - Future saveExists({String? fileName}) async => false; -} +import '../helpers/mock_factories.dart'; void main() { - const config = PqConfig(); - final mutations = GameMutations(config); - final progressService = ProgressService( - config: config, - mutations: mutations, - rewards: RewardService(mutations, config), - ); + final progressService = MockFactories.createProgressService(); GameSessionController buildController( FakeAsync async, diff --git a/test/helpers/mock_factories.dart b/test/helpers/mock_factories.dart new file mode 100644 index 0000000..ed747c3 --- /dev/null +++ b/test/helpers/mock_factories.dart @@ -0,0 +1,195 @@ +import 'package:asciineverdie/src/core/engine/game_mutations.dart'; +import 'package:asciineverdie/src/core/engine/progress_service.dart'; +import 'package:asciineverdie/src/core/engine/reward_service.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/pq_config.dart'; +import 'package:asciineverdie/src/core/storage/save_manager.dart'; +import 'package:asciineverdie/src/core/storage/save_repository.dart'; +import 'package:asciineverdie/src/core/storage/save_service.dart'; +import 'package:asciineverdie/src/core/util/balance_constants.dart'; + +export 'package:asciineverdie/src/core/storage/save_repository.dart' + show SaveOutcome; +export 'package:asciineverdie/src/core/storage/save_service.dart' + show SaveFileInfo; + +/// 테스트용 Fake SaveManager +/// +/// 여러 테스트 파일에서 중복되던 Mock을 통합 +class FakeSaveManager implements SaveManager { + final List savedStates = []; + + /// 커스텀 로드 동작 설정 + (SaveOutcome, GameState?, bool) Function(String?)? onLoad; + + /// 저장 결과 설정 (기본: 성공) + SaveOutcome saveOutcome = const SaveOutcome.success(); + + @override + Future saveState( + GameState state, { + String? fileName, + bool cheatsEnabled = false, + }) async { + savedStates.add(state); + return saveOutcome; + } + + @override + Future<(SaveOutcome, GameState?, bool)> loadState({String? fileName}) async { + if (onLoad != null) { + return onLoad!(fileName); + } + return (const SaveOutcome.success(), null, false); + } + + @override + Future> listSaves() async => []; + + @override + Future deleteSave({String? fileName}) async { + return const SaveOutcome.success(); + } + + @override + Future saveExists({String? fileName}) async => false; +} + +/// 테스트용 팩토리 클래스 +/// +/// 테스트에서 자주 사용되는 객체 생성을 중앙화 +class MockFactories { + MockFactories._(); + + /// 기본 PqConfig 생성 + static const PqConfig config = PqConfig(); + + /// GameMutations 생성 + static GameMutations createMutations([PqConfig? cfg]) { + return GameMutations(cfg ?? config); + } + + /// ProgressService 생성 + static ProgressService createProgressService([PqConfig? cfg]) { + final c = cfg ?? config; + final mutations = GameMutations(c); + return ProgressService( + config: c, + mutations: mutations, + rewards: RewardService(mutations, c), + ); + } + + /// 기본 GameState 생성 + /// + /// [seed]: 결정론적 랜덤 시드 + /// [level]: 캐릭터 레벨 + static GameState createGameState({ + int seed = 42, + int level = 1, + }) { + return GameState.withSeed(seed: seed); + } + + /// 테스트용 CombatState 생성 + static CombatState createCombat({ + int playerHpMax = 100, + int playerHpCurrent = 100, + int monsterHpMax = 50, + int monsterHpCurrent = 50, + int monsterLevel = 1, + String monsterName = 'Test Monster', + }) { + final playerStats = CombatStats.empty().copyWith( + hpMax: playerHpMax, + hpCurrent: playerHpCurrent, + mpMax: 50, + mpCurrent: 50, + atk: 20, + def: 10, + attackDelayMs: 1000, + ); + + final monsterStats = MonsterCombatStats( + name: monsterName, + level: monsterLevel, + atk: 10, + def: 5, + hpMax: monsterHpMax, + hpCurrent: monsterHpCurrent, + criRate: 0.05, + criDamage: 1.5, + evasion: 0.0, + accuracy: 0.8, + attackDelayMs: 1000, + expReward: 100, + ); + + return CombatState( + playerStats: playerStats, + monsterStats: monsterStats, + playerAttackAccumulatorMs: 0, + monsterAttackAccumulatorMs: 0, + totalDamageDealt: 0, + totalDamageTaken: 0, + turnsElapsed: 0, + isActive: true, + ); + } + + /// 테스트용 MonsterCombatStats 생성 + static MonsterCombatStats createMonsterStats({ + String name = 'Test Monster', + int level = 1, + int atk = 10, + int def = 5, + int hpMax = 100, + int? hpCurrent, + double criRate = 0.05, + double criDamage = 1.5, + double evasion = 0.0, + double accuracy = 0.8, + int attackDelayMs = 1000, + int expReward = 100, + }) { + return MonsterCombatStats( + name: name, + level: level, + atk: atk, + def: def, + hpMax: hpMax, + hpCurrent: hpCurrent ?? hpMax, + criRate: criRate, + criDamage: criDamage, + evasion: evasion, + accuracy: accuracy, + attackDelayMs: attackDelayMs, + expReward: expReward, + ); + } + + /// 밸런스 상수 기반 몬스터 스탯 생성 + static MonsterCombatStats createBalancedMonsterStats({ + required int level, + MonsterType type = MonsterType.normal, + }) { + final base = MonsterBaseStats.generate(level, type); + return MonsterCombatStats( + name: 'Balanced Monster Lv$level', + level: level, + atk: base.atk, + def: base.def, + hpMax: base.hp, + hpCurrent: base.hp, + criRate: 0.05, + criDamage: 1.5, + evasion: 0.0, + accuracy: 0.8, + attackDelayMs: 1000, + expReward: base.exp, + ); + } +}