import 'package:asciineverdie/src/core/engine/death_handler.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/util/deterministic_random.dart'; import 'package:flutter_test/flutter_test.dart'; import '../../helpers/mock_factories.dart'; void main() { const handler = DeathHandler(); /// 테스트용 장비 생성 헬퍼 EquipmentItem makeItem(String name, EquipmentSlot slot) { return EquipmentItem( name: name, slot: slot, level: 5, weight: 3, stats: const ItemStats(def: 10), rarity: ItemRarity.rare, ); } /// 모든 슬롯에 장비가 장착된 Equipment 생성 Equipment fullyEquipped() { return Equipment( items: [ EquipmentItem.defaultWeapon(), // 0: 무기(weapon) makeItem('Iron Shield', EquipmentSlot.shield), makeItem('Iron Helm', EquipmentSlot.helm), makeItem('Iron Hauberk', EquipmentSlot.hauberk), makeItem('Iron Brassairts', EquipmentSlot.brassairts), makeItem('Iron Vambraces', EquipmentSlot.vambraces), makeItem('Iron Gauntlets', EquipmentSlot.gauntlets), makeItem('Iron Gambeson', EquipmentSlot.gambeson), makeItem('Iron Cuisses', EquipmentSlot.cuisses), makeItem('Iron Greaves', EquipmentSlot.greaves), makeItem('Iron Sollerets', EquipmentSlot.sollerets), ], bestIndex: 0, ); } /// 테스트용 GameState 생성 /// /// [seed]: 결정론적 랜덤 시드(deterministic random seed) /// [level]: 캐릭터 레벨 /// [isBossFight]: 보스전(boss fight) 여부 /// [equipment]: 커스텀 장비 GameState createState({ int seed = 42, int level = 1, bool isBossFight = false, Equipment? equipment, }) { final state = GameState( rng: DeterministicRandom(seed), traits: Traits.empty().copyWith(level: level), equipment: equipment ?? fullyEquipped(), progress: ProgressState.empty().copyWith( currentCombat: MockFactories.createCombat(), finalBossState: isBossFight ? FinalBossState.fighting : FinalBossState.notSpawned, ), inventory: const Inventory(gold: 5000, items: []), ); return state; } group('DeathHandler', () { group('deathInfo 생성', () { test('사망 시 deathInfo가 올바르게 생성된다', () { // 준비(arrange): Lv10 캐릭터 - 100% 장비 손실 확률 final state = createState(level: 10); // 실행(act) final result = handler.processPlayerDeath( state, killerName: 'Dark Goblin', cause: DeathCause.monster, ); // 검증(assert): deathInfo 필드 확인 expect(result.deathInfo, isNotNull); expect(result.deathInfo!.cause, DeathCause.monster); expect(result.deathInfo!.killerName, 'Dark Goblin'); expect(result.deathInfo!.goldAtDeath, 5000); expect(result.deathInfo!.levelAtDeath, 10); expect(result.isDead, isTrue); }); test('selfDamage 원인으로 사망 시 cause가 올바르다', () { final state = createState(level: 1); final result = handler.processPlayerDeath( state, killerName: 'Self', cause: DeathCause.selfDamage, ); expect(result.deathInfo!.cause, DeathCause.selfDamage); }); }); group('보스전(boss fight) 사망', () { test('보스전 사망 시 장비가 보호된다 (lostCount == 0)', () { final equipment = fullyEquipped(); final state = createState( level: 10, isBossFight: true, equipment: equipment, ); final result = handler.processPlayerDeath( state, killerName: 'Final Boss', cause: DeathCause.monster, ); // 검증: 장비 손실 없음 expect(result.deathInfo!.lostEquipmentCount, 0); expect(result.deathInfo!.lostItemName, isNull); expect(result.deathInfo!.lostItem, isNull); // 검증: 모든 장비가 그대로 유지 for (var i = 0; i < Equipment.slotCount; i++) { expect( result.equipment.getItemByIndex(i).name, equipment.getItemByIndex(i).name, ); } }); test('보스전 사망 시 bossLevelingEndTime이 설정된다 (5분)', () { final state = createState(isBossFight: true); final before = DateTime.now().millisecondsSinceEpoch; final result = handler.processPlayerDeath( state, killerName: 'Final Boss', cause: DeathCause.monster, ); final after = DateTime.now().millisecondsSinceEpoch; final endTime = result.progress.bossLevelingEndTime; // 검증: 5분(300,000ms) 후 시간이 설정됨 expect(endTime, isNotNull); // before + 5분 <= endTime <= after + 5분 (타이밍 허용) const fiveMinMs = 5 * 60 * 1000; expect(endTime!, greaterThanOrEqualTo(before + fiveMinMs)); expect(endTime, lessThanOrEqualTo(after + fiveMinMs)); }); test('일반 사망 시 bossLevelingEndTime은 null이다', () { final state = createState(level: 1); final result = handler.processPlayerDeath( state, killerName: 'Goblin', cause: DeathCause.monster, ); expect(result.progress.bossLevelingEndTime, isNull); }); }); group('장비 손실(equipment loss) 확률', () { test('Lv1: 20% 확률 - roll < 20이면 장비 손실', () { // Lv1 공식: 20 + (1-1)*80/9 = 20% // 시드를 탐색하여 roll < 20인 경우를 찾아야 함 // DeterministicRandom(seed).nextInt(100)의 결과가 20 미만인 시드 사용 int? lossySeed; for (var s = 0; s < 1000; s++) { final rng = DeterministicRandom(s); final roll = rng.nextInt(100); if (roll < 20) { lossySeed = s; break; } } expect(lossySeed, isNotNull, reason: '장비 손실 시드를 찾을 수 없음'); final state = createState(seed: lossySeed!, level: 1); final result = handler.processPlayerDeath( state, killerName: 'Goblin', cause: DeathCause.monster, ); expect(result.deathInfo!.lostEquipmentCount, 1); }); test('Lv1: 20% 확률 - roll >= 20이면 장비 보호', () { // roll >= 20인 시드 찾기 int? safeSeed; for (var s = 0; s < 1000; s++) { final rng = DeterministicRandom(s); final roll = rng.nextInt(100); if (roll >= 20) { safeSeed = s; break; } } expect(safeSeed, isNotNull, reason: '장비 보호 시드를 찾을 수 없음'); final state = createState(seed: safeSeed!, level: 1); final result = handler.processPlayerDeath( state, killerName: 'Goblin', cause: DeathCause.monster, ); expect(result.deathInfo!.lostEquipmentCount, 0); expect(result.deathInfo!.lostItemName, isNull); }); test('Lv10+: 100% 확률로 장비 손실', () { // Lv10 이상은 항상 100% 손실 final state = createState(seed: 42, level: 10); final result = handler.processPlayerDeath( state, killerName: 'Dragon', cause: DeathCause.monster, ); expect(result.deathInfo!.lostEquipmentCount, 1); expect(result.deathInfo!.lostItemName, isNotNull); expect(result.deathInfo!.lostItemSlot, isNotNull); expect(result.deathInfo!.lostItem, isNotNull); }); test('Lv5: 중간 확률 (~55%) 공식 검증', () { // Lv5 공식: 20 + (5-1)*80/9 = 20 + 35 = 55 // (정수 나눗셈: (4*80)~/9 = 320~/9 = 35) final level = 5; final expected = 20 + ((level - 1) * 80 ~/ 9); expect(expected, 55); }); }); group('무기(weapon) 슬롯 보호', () { test('무기(슬롯 0)는 손실 대상에서 제외된다', () { // Lv10(100% 손실)으로 여러 시드를 반복 테스트 // 무기는 절대 사라지지 않아야 함 for (var s = 0; s < 50; s++) { final state = createState(seed: s, level: 10); final result = handler.processPlayerDeath( state, killerName: 'Monster', cause: DeathCause.monster, ); // 무기(weapon)는 항상 유지 expect( result.equipment.weaponItem.isNotEmpty, isTrue, reason: 'seed=$s에서 무기가 사라짐', ); // 손실된 아이템이 있다면 weapon 슬롯이 아니어야 함 if (result.deathInfo!.lostItemSlot != null) { expect( result.deathInfo!.lostItemSlot, isNot(EquipmentSlot.weapon), reason: 'seed=$s에서 무기 슬롯이 손실됨', ); } } }); }); group('빈 장비(empty equipment)', () { test('무기 외 모든 장비가 비어있으면 손실 없음', () { // 무기만 있고 나머지는 빈 장비 final emptyEquipment = Equipment.empty(); // Equipment.empty()는 기본 무기(Keyboard)만 있음 final state = createState( seed: 42, level: 10, // 100% 손실 확률 equipment: emptyEquipment, ); final result = handler.processPlayerDeath( state, killerName: 'Monster', cause: DeathCause.monster, ); // 검증: 빈 슬롯만 있으므로 손실 불가 expect(result.deathInfo!.lostEquipmentCount, 0); expect(result.deathInfo!.lostItemName, isNull); }); }); group('deathCount 증가', () { test('사망 시 deathCount가 1 증가한다', () { final state = createState(); expect(state.progress.deathCount, 0); final result = handler.processPlayerDeath( state, killerName: 'Goblin', cause: DeathCause.monster, ); expect(result.progress.deathCount, 1); }); test('연속 사망 시 deathCount가 누적된다', () { // 첫 번째 사망 (deathCount: 0 -> 1) var state = createState(seed: 100); state = handler.processPlayerDeath( state, killerName: 'Goblin', cause: DeathCause.monster, ); expect(state.progress.deathCount, 1); // 부활 후 전투 재개 시뮬레이션 (deathInfo 초기화) state = state.copyWith(clearDeathInfo: true); // 두 번째 사망 (deathCount: 1 -> 2) state = handler.processPlayerDeath( state, killerName: 'Dragon', cause: DeathCause.monster, ); expect(state.progress.deathCount, 2); }); }); group('currentCombat 초기화', () { test('사망 후 currentCombat이 null로 초기화되어야 한다', () { // 전투 중(combat active) 상태에서 사망 final state = createState(); expect(state.progress.currentCombat, isNotNull); final result = handler.processPlayerDeath( state, killerName: 'Monster', cause: DeathCause.monster, ); // clearCurrentCombat 파라미터로 전투 상태 초기화 expect(result.progress.currentCombat, isNull); }); }); }); }