- analysis_options.yaml: freezed/g.dart 생성 파일 분석 제외
- game_text_l10n.dart: if문 중괄호 추가, 불필요한 ${} 제거
- iap_service.dart: 불필요한 dart:typed_data import 제거
- pq_logic.dart, combat_text_frames.dart: dangling library doc → library; 추가
- save_picker_dialog.dart: __ → _ (unnecessary_underscores)
- desktop_equipment_panel.dart: 불필요한 import 제거
- test 파일: _localVar → localVar 네이밍, ignore_for_file 추가
355 lines
12 KiB
Dart
355 lines
12 KiB
Dart
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);
|
|
});
|
|
});
|
|
});
|
|
}
|