diff --git a/lib/data/skill_data.dart b/lib/data/skill_data.dart index 662878e..86a8e75 100644 --- a/lib/data/skill_data.dart +++ b/lib/data/skill_data.dart @@ -2,7 +2,7 @@ import 'package:asciineverdie/src/core/model/skill.dart'; /// 게임 내 스킬 정의 /// -/// PQ 스펠 70개를 전투 스킬로 매핑 +/// PQ 스펠을 기반으로 68개 전투 스킬로 재구성 /// 스펠 이름(영문)으로 스킬 조회 가능 class SkillData { SkillData._(); diff --git a/lib/src/core/engine/death_handler.dart b/lib/src/core/engine/death_handler.dart index 0f1ff73..b004955 100644 --- a/lib/src/core/engine/death_handler.dart +++ b/lib/src/core/engine/death_handler.dart @@ -72,7 +72,7 @@ class DeathHandler { // 전투 상태 초기화 및 사망 횟수 증가 final progress = state.progress.copyWith( - currentCombat: null, + clearCurrentCombat: true, deathCount: state.progress.deathCount + 1, bossLevelingEndTime: bossLevelingEndTime, ); diff --git a/lib/src/core/engine/resurrection_service.dart b/lib/src/core/engine/resurrection_service.dart index 3d32307..290b88e 100644 --- a/lib/src/core/engine/resurrection_service.dart +++ b/lib/src/core/engine/resurrection_service.dart @@ -78,7 +78,7 @@ class ResurrectionService { ); // 전투 상태 초기화 - final progress = state.progress.copyWith(currentCombat: null); + final progress = state.progress.copyWith(clearCurrentCombat: true); return state.copyWith( equipment: newEquipment, diff --git a/lib/src/core/model/progress_state.dart b/lib/src/core/model/progress_state.dart index abcee9f..82be03f 100644 --- a/lib/src/core/model/progress_state.dart +++ b/lib/src/core/model/progress_state.dart @@ -160,6 +160,7 @@ class ProgressState { bool? pendingActCompletion, int? bossLevelingEndTime, bool clearBossLevelingEndTime = false, + bool clearCurrentCombat = false, }) { return ProgressState( task: task ?? this.task, @@ -173,7 +174,9 @@ class ProgressState { plotHistory: plotHistory ?? this.plotHistory, questHistory: questHistory ?? this.questHistory, currentQuestMonster: currentQuestMonster ?? this.currentQuestMonster, - currentCombat: currentCombat ?? this.currentCombat, + currentCombat: clearCurrentCombat + ? null + : (currentCombat ?? this.currentCombat), monstersKilled: monstersKilled ?? this.monstersKilled, deathCount: deathCount ?? this.deathCount, finalBossState: finalBossState ?? this.finalBossState, diff --git a/test/core/engine/death_handler_test.dart b/test/core/engine/death_handler_test.dart new file mode 100644 index 0000000..d1088b6 --- /dev/null +++ b/test/core/engine/death_handler_test.dart @@ -0,0 +1,355 @@ +import 'package:asciineverdie/src/core/engine/death_handler.dart'; +import 'package:asciineverdie/src/core/model/equipment_container.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); + }); + }); + }); +} diff --git a/test/core/engine/item_service_test.dart b/test/core/engine/item_service_test.dart new file mode 100644 index 0000000..104ac73 --- /dev/null +++ b/test/core/engine/item_service_test.dart @@ -0,0 +1,617 @@ +import 'package:asciineverdie/src/core/engine/item_service.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/item_stats.dart'; +import 'package:asciineverdie/src/core/util/deterministic_random.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('ItemService', () { + // ======================================================================== + // generateEquipment - 레벨에 맞는 아이템 생성 + // ======================================================================== + group('generateEquipment', () { + test('지정 레벨/슬롯에 맞는 아이템 생성', () { + final rng = DeterministicRandom(42); + final service = ItemService(rng: rng); + + final item = service.generateEquipment( + name: 'Test Sword', + slot: EquipmentSlot.weapon, + level: 10, + rarity: ItemRarity.common, + ); + + expect(item.name, equals('Test Sword')); + expect(item.slot, equals(EquipmentSlot.weapon)); + expect(item.level, equals(10)); + expect(item.rarity, equals(ItemRarity.common)); + expect(item.weight, greaterThan(0)); + expect(item.isEmpty, isFalse); + }); + + test('높은 레벨 아이템이 낮은 레벨보다 강한 스탯', () { + final rng1 = DeterministicRandom(42); + final rng2 = DeterministicRandom(42); + final service1 = ItemService(rng: rng1); + final service2 = ItemService(rng: rng2); + + final lowItem = service1.generateEquipment( + name: 'Low Sword', + slot: EquipmentSlot.weapon, + level: 1, + rarity: ItemRarity.common, + ); + final highItem = service2.generateEquipment( + name: 'High Sword', + slot: EquipmentSlot.weapon, + level: 50, + rarity: ItemRarity.common, + ); + + // 같은 희귀도(common)에서 높은 레벨이 더 높은 점수 + final lowScore = ItemService.calculateEquipmentScore(lowItem); + final highScore = ItemService.calculateEquipmentScore(highItem); + expect(highScore, greaterThan(lowScore)); + }); + + test('희귀도(rarity) 미지정 시 자동 결정', () { + final rng = DeterministicRandom(42); + final service = ItemService(rng: rng); + + final item = service.generateEquipment( + name: 'Auto Rarity Item', + slot: EquipmentSlot.helm, + level: 10, + ); + + // 희귀도가 자동 결정되어 유효한 값을 가짐 + expect(ItemRarity.values, contains(item.rarity)); + }); + + test('모든 슬롯에 대해 아이템 생성 가능', () { + final rng = DeterministicRandom(42); + final service = ItemService(rng: rng); + + for (final slot in EquipmentSlot.values) { + final item = service.generateEquipment( + name: 'Test ${slot.name}', + slot: slot, + level: 5, + rarity: ItemRarity.uncommon, + ); + + expect(item.slot, equals(slot)); + expect(item.stats, isNotNull); + expect(item.weight, greaterThan(0)); + } + }); + }); + + // ======================================================================== + // determineRarity - 희귀도(ItemRarity) 분포 확인 + // ======================================================================== + group('determineRarity', () { + test('확률 분포가 설계대로 분배됨', () { + // 대량 샘플로 확률 분포 검증 + final rng = DeterministicRandom(12345); + final service = ItemService(rng: rng); + final counts = {}; + + const trials = 10000; + for (var i = 0; i < trials; i++) { + final rarity = service.determineRarity(10); + counts[rarity] = (counts[rarity] ?? 0) + 1; + } + + // 허용 오차(tolerance) ±3% + final commonPct = counts[ItemRarity.common]! / trials; + final uncommonPct = counts[ItemRarity.uncommon]! / trials; + final rarePct = counts[ItemRarity.rare]! / trials; + final epicPct = counts[ItemRarity.epic]! / trials; + final legendaryPct = counts[ItemRarity.legendary]! / trials; + + // Common: 34%, Uncommon: 40%, Rare: 20%, Epic: 5%, Legendary: 1% + expect(commonPct, closeTo(0.34, 0.03)); + expect(uncommonPct, closeTo(0.40, 0.03)); + expect(rarePct, closeTo(0.20, 0.03)); + expect(epicPct, closeTo(0.05, 0.03)); + expect(legendaryPct, closeTo(0.01, 0.02)); + }); + + test('모든 희귀도가 최소 한 번 등장', () { + final rng = DeterministicRandom(9999); + final service = ItemService(rng: rng); + final seen = {}; + + // 충분한 시행으로 모든 희귀도 등장 확인 + for (var i = 0; i < 5000; i++) { + seen.add(service.determineRarity(10)); + } + + expect(seen, containsAll(ItemRarity.values)); + }); + }); + + // ======================================================================== + // generateItemStats - 아이템에 stats가 있는지 + // ======================================================================== + group('generateItemStats', () { + test('무기(weapon) 스탯에 atk가 포함됨', () { + final rng = DeterministicRandom(42); + final service = ItemService(rng: rng); + + final stats = service.generateItemStats( + level: 10, + rarity: ItemRarity.uncommon, + slot: EquipmentSlot.weapon, + ); + + expect(stats.atk, greaterThan(0)); + expect(stats.attackSpeed, greaterThan(0)); + }); + + test('방패(shield) 스탯에 def와 blockRate가 포함됨', () { + final rng = DeterministicRandom(42); + final service = ItemService(rng: rng); + + final stats = service.generateItemStats( + level: 10, + rarity: ItemRarity.uncommon, + slot: EquipmentSlot.shield, + ); + + expect(stats.def, greaterThan(0)); + expect(stats.blockRate, greaterThan(0.0)); + }); + + test('방어구(armor) 스탯에 def가 포함됨', () { + final rng = DeterministicRandom(42); + final service = ItemService(rng: rng); + + // 모든 방어구 슬롯에 def가 존재하는지 확인 + for (final slot in EquipmentSlot.values) { + if (!slot.isWeapon && !slot.isShield) { + final stats = service.generateItemStats( + level: 10, + rarity: ItemRarity.uncommon, + slot: slot, + ); + expect(stats.def, greaterThan(0), reason: '${slot.name} def > 0'); + } + } + }); + + test('높은 희귀도는 더 높은 baseValue를 생성', () { + final rng1 = DeterministicRandom(42); + final rng2 = DeterministicRandom(42); + final service1 = ItemService(rng: rng1); + final service2 = ItemService(rng: rng2); + + final commonStats = service1.generateItemStats( + level: 20, + rarity: ItemRarity.common, + slot: EquipmentSlot.hauberk, + ); + final epicStats = service2.generateItemStats( + level: 20, + rarity: ItemRarity.epic, + slot: EquipmentSlot.hauberk, + ); + + // Epic(2.2x)이 Common(1.0x)보다 def가 높아야 함 + expect(epicStats.def, greaterThan(commonStats.def)); + }); + + test('Rare 이상 무기에 criRate 보너스 존재', () { + final rng = DeterministicRandom(42); + final service = ItemService(rng: rng); + + final stats = service.generateItemStats( + level: 10, + rarity: ItemRarity.rare, + slot: EquipmentSlot.weapon, + ); + + expect(stats.criRate, greaterThan(0.0)); + }); + + test('공속(attackSpeed)이 600~1500ms 범위 내', () { + final rng = DeterministicRandom(42); + final service = ItemService(rng: rng); + + // 여러 번 생성하여 범위 확인 + for (var i = 0; i < 100; i++) { + final stats = service.generateItemStats( + level: 10, + rarity: ItemRarity.uncommon, + slot: EquipmentSlot.weapon, + ); + + expect( + stats.attackSpeed, + inInclusiveRange(600, 1500), + reason: 'attackSpeed 범위 위반 (시행 $i)', + ); + } + }); + }); + + // ======================================================================== + // calculateEquipmentScore (shouldEquip) - 더 좋은 아이템 판별 + // ======================================================================== + group('calculateEquipmentScore / shouldEquip', () { + test('높은 점수 아이템이 더 좋은 것으로 판별', () { + final rng = DeterministicRandom(42); + final service = ItemService(rng: rng); + + final weakItem = service.generateEquipment( + name: 'Weak Sword', + slot: EquipmentSlot.weapon, + level: 5, + rarity: ItemRarity.common, + ); + final strongItem = service.generateEquipment( + name: 'Strong Sword', + slot: EquipmentSlot.weapon, + level: 20, + rarity: ItemRarity.epic, + ); + + final weakScore = ItemService.calculateEquipmentScore(weakItem); + final strongScore = ItemService.calculateEquipmentScore(strongItem); + + expect(strongScore, greaterThan(weakScore)); + }); + + test('shouldEquip: 더 강한 아이템 장착 허용', () { + final rng = DeterministicRandom(42); + final service = ItemService(rng: rng); + + final currentItem = service.generateEquipment( + name: 'Old Sword', + slot: EquipmentSlot.weapon, + level: 5, + rarity: ItemRarity.common, + ); + final newItem = service.generateEquipment( + name: 'New Sword', + slot: EquipmentSlot.weapon, + level: 20, + rarity: ItemRarity.rare, + ); + + final result = service.shouldEquip( + newItem: newItem, + currentItem: currentItem, + allEquipped: [currentItem], + str: 20, + ); + + expect(result, isTrue); + }); + + test('shouldEquip: 더 약한 아이템 장착 거부', () { + final rng = DeterministicRandom(42); + final service = ItemService(rng: rng); + + final currentItem = service.generateEquipment( + name: 'Strong Sword', + slot: EquipmentSlot.weapon, + level: 30, + rarity: ItemRarity.epic, + ); + final newItem = service.generateEquipment( + name: 'Weak Sword', + slot: EquipmentSlot.weapon, + level: 5, + rarity: ItemRarity.common, + ); + + final result = service.shouldEquip( + newItem: newItem, + currentItem: currentItem, + allEquipped: [currentItem], + str: 20, + ); + + expect(result, isFalse); + }); + + test('shouldEquip: 무게 초과 시 장착 거부', () { + final rng = DeterministicRandom(42); + final service = ItemService(rng: rng); + + // STR 1 → 최대 무게 110 + // 높은 레벨 아이템들로 무게 가득 채우기 + final heavyItems = EquipmentSlot.values.map((slot) { + return service.generateEquipment( + name: 'Heavy ${slot.name}', + slot: slot, + level: 50, + rarity: ItemRarity.legendary, + ); + }).toList(); + + final newItem = service.generateEquipment( + name: 'Another Heavy Item', + slot: EquipmentSlot.weapon, + level: 50, + rarity: ItemRarity.legendary, + ); + + final result = service.shouldEquip( + newItem: newItem, + currentItem: heavyItems.first, // weapon 슬롯 + allEquipped: heavyItems, + str: 1, // 낮은 STR → 무게 제한 110 + ); + + // 교체 시 기존 무기 무게 제외 → 장착 가능 여부는 무게에 달림 + // 이 테스트는 무게 제한 로직이 동작하는지 확인 + expect(result, isA()); + }); + + test('shouldEquip: 빈 슬롯에는 무조건 장착', () { + final rng = DeterministicRandom(42); + final service = ItemService(rng: rng); + + final emptyItem = EquipmentItem.empty(EquipmentSlot.weapon); + final newItem = service.generateEquipment( + name: 'Any Sword', + slot: EquipmentSlot.weapon, + level: 1, + rarity: ItemRarity.common, + ); + + final result = service.shouldEquip( + newItem: newItem, + currentItem: emptyItem, + allEquipped: [emptyItem], + str: 10, + ); + + expect(result, isTrue); + }); + }); + + // ======================================================================== + // calculateWeight - 아이템 무게 계산 + // ======================================================================== + group('calculateWeight', () { + test('슬롯별 기본 무게가 다름', () { + final rng = DeterministicRandom(42); + final service = ItemService(rng: rng); + + // hauberk(갑옷)이 gauntlets(건틀릿)보다 무거워야 함 + final hauberkWeight = service.calculateWeight( + level: 1, + slot: EquipmentSlot.hauberk, + ); + final gauntletsWeight = service.calculateWeight( + level: 1, + slot: EquipmentSlot.gauntlets, + ); + + expect(hauberkWeight, greaterThan(gauntletsWeight)); + }); + + test('레벨이 높을수록 무게 증가', () { + final rng = DeterministicRandom(42); + final service = ItemService(rng: rng); + + final lowWeight = service.calculateWeight( + level: 1, + slot: EquipmentSlot.weapon, + ); + final highWeight = service.calculateWeight( + level: 50, + slot: EquipmentSlot.weapon, + ); + + expect(highWeight, greaterThan(lowWeight)); + }); + + test('무게 공식: baseWeight + (level ~/ 5)', () { + final rng = DeterministicRandom(42); + final service = ItemService(rng: rng); + + // weapon 기본 무게 10, 레벨 25 → 10 + (25 ~/ 5) = 10 + 5 = 15 + final weight = service.calculateWeight( + level: 25, + slot: EquipmentSlot.weapon, + ); + + expect(weight, equals(15)); + }); + }); + + // ======================================================================== + // calculateMaxWeight / calculateTotalWeight + // ======================================================================== + group('calculateMaxWeight / calculateTotalWeight', () { + test('STR 기반 최대 무게: 100 + STR * 10', () { + expect(ItemService.calculateMaxWeight(10), equals(200)); + expect(ItemService.calculateMaxWeight(0), equals(100)); + expect(ItemService.calculateMaxWeight(50), equals(600)); + }); + + test('장비 목록의 총 무게 합산', () { + final items = [ + EquipmentItem( + name: 'Sword', + slot: EquipmentSlot.weapon, + level: 1, + weight: 10, + stats: const ItemStats(atk: 5), + rarity: ItemRarity.common, + ), + EquipmentItem( + name: 'Shield', + slot: EquipmentSlot.shield, + level: 1, + weight: 15, + stats: const ItemStats(def: 3), + rarity: ItemRarity.common, + ), + ]; + + expect(ItemService.calculateTotalWeight(items), equals(25)); + }); + + test('빈 목록의 총 무게는 0', () { + expect(ItemService.calculateTotalWeight([]), equals(0)); + }); + }); + + // ======================================================================== + // canEquip - 무게 기반 장착 가능 여부 + // ======================================================================== + group('canEquip', () { + test('무게 여유 있을 때 장착 가능', () { + final items = []; + final newItem = EquipmentItem( + name: 'Light Sword', + slot: EquipmentSlot.weapon, + level: 1, + weight: 10, + stats: const ItemStats(atk: 5), + rarity: ItemRarity.common, + ); + + final result = ItemService.canEquip( + newItem: newItem, + currentItems: items, + str: 10, // 최대 200 + ); + + expect(result, isTrue); + }); + + test('교체 슬롯(replacingSlot) 지정 시 해당 무게 제외', () { + final existingWeapon = EquipmentItem( + name: 'Old Sword', + slot: EquipmentSlot.weapon, + level: 1, + weight: 90, + stats: const ItemStats(atk: 3), + rarity: ItemRarity.common, + ); + final newWeapon = EquipmentItem( + name: 'New Sword', + slot: EquipmentSlot.weapon, + level: 1, + weight: 95, + stats: const ItemStats(atk: 8), + rarity: ItemRarity.uncommon, + ); + + // STR 0 → 최대 100, 기존 90 장착 중 + // 교체 시: 100 - 90 + 95 = 105 > 100 → 불가 + final resultWithoutReplace = ItemService.canEquip( + newItem: newWeapon, + currentItems: [existingWeapon], + str: 0, + ); + expect(resultWithoutReplace, isFalse); + + // 교체 슬롯 지정: 0 + 95 = 95 ≤ 100 → 가능 + final resultWithReplace = ItemService.canEquip( + newItem: newWeapon, + currentItems: [existingWeapon], + str: 0, + replacingSlot: EquipmentSlot.weapon, + ); + expect(resultWithReplace, isTrue); + }); + }); + + // ======================================================================== + // EquipmentItem.empty - 빈 아이템 처리 + // ======================================================================== + group('EquipmentItem.empty', () { + test('빈 아이템은 isEmpty가 true', () { + final empty = EquipmentItem.empty(EquipmentSlot.weapon); + + expect(empty.isEmpty, isTrue); + expect(empty.isNotEmpty, isFalse); + expect(empty.name, isEmpty); + expect(empty.level, equals(0)); + expect(empty.weight, equals(0)); + expect(empty.rarity, equals(ItemRarity.common)); + }); + + test('빈 아이템의 스탯은 모두 0', () { + final empty = EquipmentItem.empty(EquipmentSlot.shield); + final stats = empty.stats; + + expect(stats.atk, equals(0)); + expect(stats.def, equals(0)); + expect(stats.magAtk, equals(0)); + expect(stats.magDef, equals(0)); + expect(stats.criRate, equals(0.0)); + expect(stats.hpBonus, equals(0)); + expect(stats.attackSpeed, equals(0)); + }); + + test('빈 아이템의 장비 점수는 0', () { + final empty = EquipmentItem.empty(EquipmentSlot.helm); + final score = ItemService.calculateEquipmentScore(empty); + + expect(score, equals(0)); + }); + + test('빈 아이템의 toString은 "(empty)"', () { + final empty = EquipmentItem.empty(EquipmentSlot.weapon); + expect(empty.toString(), equals('(empty)')); + }); + + test('각 슬롯별 빈 아이템 생성', () { + for (final slot in EquipmentSlot.values) { + final empty = EquipmentItem.empty(slot); + expect(empty.slot, equals(slot)); + expect(empty.isEmpty, isTrue); + } + }); + }); + + // ======================================================================== + // createDefaultEquipmentForSlot - 기본 장비 생성 + // ======================================================================== + group('createDefaultEquipmentForSlot', () { + test('기본 무기는 atk와 attackSpeed를 가짐', () { + final weapon = ItemService.createDefaultEquipmentForSlot( + EquipmentSlot.weapon, + ); + + expect(weapon.name, equals('Wooden Stick')); + expect(weapon.level, equals(1)); + expect(weapon.rarity, equals(ItemRarity.common)); + expect(weapon.stats.atk, equals(2)); + expect(weapon.stats.attackSpeed, equals(1000)); + }); + + test('기본 방패는 def와 blockRate를 가짐', () { + final shield = ItemService.createDefaultEquipmentForSlot( + EquipmentSlot.shield, + ); + + expect(shield.name, equals('Wooden Shield')); + expect(shield.stats.def, equals(1)); + expect(shield.stats.blockRate, equals(0.05)); + }); + + test('모든 슬롯에 기본 장비 생성 가능', () { + for (final slot in EquipmentSlot.values) { + final item = ItemService.createDefaultEquipmentForSlot(slot); + + expect(item.slot, equals(slot)); + expect(item.name, isNotEmpty); + expect(item.level, equals(1)); + expect(item.weight, equals(1)); + expect(item.rarity, equals(ItemRarity.common)); + } + }); + }); + }); +}