fix(model): copyWith currentCombat null 초기화 버그 수정 및 테스트 추가
- ProgressState.copyWith에 clearCurrentCombat 파라미터 추가 - death_handler, resurrection_service에서 clearCurrentCombat 사용 - death_handler 테스트 14개 추가 - item_service 테스트 33개 추가 - skill_data 주석 스킬 개수 70→68 수정
This commit is contained in:
@@ -2,7 +2,7 @@ import 'package:asciineverdie/src/core/model/skill.dart';
|
||||
|
||||
/// 게임 내 스킬 정의
|
||||
///
|
||||
/// PQ 스펠 70개를 전투 스킬로 매핑
|
||||
/// PQ 스펠을 기반으로 68개 전투 스킬로 재구성
|
||||
/// 스펠 이름(영문)으로 스킬 조회 가능
|
||||
class SkillData {
|
||||
SkillData._();
|
||||
|
||||
@@ -72,7 +72,7 @@ class DeathHandler {
|
||||
|
||||
// 전투 상태 초기화 및 사망 횟수 증가
|
||||
final progress = state.progress.copyWith(
|
||||
currentCombat: null,
|
||||
clearCurrentCombat: true,
|
||||
deathCount: state.progress.deathCount + 1,
|
||||
bossLevelingEndTime: bossLevelingEndTime,
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
355
test/core/engine/death_handler_test.dart
Normal file
355
test/core/engine/death_handler_test.dart
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
617
test/core/engine/item_service_test.dart
Normal file
617
test/core/engine/item_service_test.dart
Normal file
@@ -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 = <ItemRarity, int>{};
|
||||
|
||||
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 = <ItemRarity>{};
|
||||
|
||||
// 충분한 시행으로 모든 희귀도 등장 확인
|
||||
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<bool>());
|
||||
});
|
||||
|
||||
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 = <EquipmentItem>[];
|
||||
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));
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user