test: shop_service, arena_service, potion_service 테스트 추가
- shop_service 17개 (가격 계산, 장비 생성, 자동 구매) - arena_service 12개 (매칭, 장비 교환, 점수 계산) - potion_service 29개 (사용, 구매, 드랍, 긴급 선택)
This commit is contained in:
326
test/core/engine/arena_service_test.dart
Normal file
326
test/core/engine/arena_service_test.dart
Normal file
@@ -0,0 +1,326 @@
|
||||
import 'package:asciineverdie/src/core/engine/arena_service.dart';
|
||||
import 'package:asciineverdie/src/core/model/arena_match.dart';
|
||||
import 'package:asciineverdie/src/core/model/combat_stats.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/hall_of_fame.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() {
|
||||
late ArenaService service;
|
||||
|
||||
setUp(() {
|
||||
service = ArenaService(rng: DeterministicRandom(42));
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// 상대 결정 (matchmaking)
|
||||
// ============================================================================
|
||||
|
||||
group('findOpponent', () {
|
||||
test('엔트리가 2개 미만이면 null 반환', () {
|
||||
final hof = HallOfFame(entries: [_createEntry(id: 'a', level: 10)]);
|
||||
final result = service.findOpponent(hof, 'a');
|
||||
expect(result, isNull);
|
||||
});
|
||||
|
||||
test('1위 도전자는 2위와 대결', () {
|
||||
// 점수(score) 높은 순: a(Lv50) > b(Lv30) > c(Lv10)
|
||||
final hof = HallOfFame(
|
||||
entries: [
|
||||
_createEntry(id: 'a', level: 50),
|
||||
_createEntry(id: 'b', level: 30),
|
||||
_createEntry(id: 'c', level: 10),
|
||||
],
|
||||
);
|
||||
|
||||
final opponent = service.findOpponent(hof, 'a');
|
||||
expect(opponent, isNotNull);
|
||||
expect(opponent!.id, 'b');
|
||||
});
|
||||
|
||||
test('2위 이하는 바로 위 순위와 대결', () {
|
||||
final hof = HallOfFame(
|
||||
entries: [
|
||||
_createEntry(id: 'a', level: 50),
|
||||
_createEntry(id: 'b', level: 30),
|
||||
_createEntry(id: 'c', level: 10),
|
||||
],
|
||||
);
|
||||
|
||||
// c는 3위 → 2위(b)와 대결
|
||||
final opponent = service.findOpponent(hof, 'c');
|
||||
expect(opponent, isNotNull);
|
||||
expect(opponent!.id, 'b');
|
||||
});
|
||||
|
||||
test('존재하지 않는 ID는 null 반환', () {
|
||||
final hof = HallOfFame(
|
||||
entries: [
|
||||
_createEntry(id: 'a', level: 50),
|
||||
_createEntry(id: 'b', level: 30),
|
||||
],
|
||||
);
|
||||
|
||||
final opponent = service.findOpponent(hof, 'nonexistent');
|
||||
expect(opponent, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// 장비 교환 (equipment exchange)
|
||||
// ============================================================================
|
||||
|
||||
group('createResultFromSimulation', () {
|
||||
test('도전자 승리 시 상대 베팅 슬롯 장비를 획득', () {
|
||||
final challengerEquip = _createEquipmentList(helmName: 'Basic Helm');
|
||||
final opponentEquip = _createEquipmentList(helmName: 'Epic Helm');
|
||||
|
||||
final match = ArenaMatch(
|
||||
challenger: _createEntry(
|
||||
id: 'challenger',
|
||||
level: 10,
|
||||
equipment: challengerEquip,
|
||||
),
|
||||
opponent: _createEntry(
|
||||
id: 'opponent',
|
||||
level: 10,
|
||||
equipment: opponentEquip,
|
||||
),
|
||||
// 도전자가 상대의 helm 슬롯을 노림
|
||||
challengerBettingSlot: EquipmentSlot.helm,
|
||||
// 상대가 도전자의 shield 슬롯을 노림
|
||||
opponentBettingSlot: EquipmentSlot.shield,
|
||||
);
|
||||
|
||||
final result = service.createResultFromSimulation(
|
||||
match: match,
|
||||
challengerHp: 50,
|
||||
opponentHp: 0,
|
||||
turns: 10,
|
||||
);
|
||||
|
||||
// 도전자 승리
|
||||
expect(result.isVictory, isTrue);
|
||||
|
||||
// 도전자가 상대의 helm(Epic Helm)을 획득
|
||||
final challengerHelm = result.updatedChallenger.finalEquipment!
|
||||
.firstWhere((e) => e.slot == EquipmentSlot.helm);
|
||||
expect(challengerHelm.name, 'Epic Helm');
|
||||
|
||||
// 상대의 helm은 기본 장비로 대체
|
||||
final opponentHelm = result.updatedOpponent.finalEquipment!.firstWhere(
|
||||
(e) => e.slot == EquipmentSlot.helm,
|
||||
);
|
||||
expect(opponentHelm.name, isNot('Epic Helm'));
|
||||
});
|
||||
|
||||
test('도전자 패배 시 도전자 베팅 슬롯 장비를 상대가 획득', () {
|
||||
final challengerEquip = _createEquipmentList(shieldName: 'Rare Shield');
|
||||
final opponentEquip = _createEquipmentList(shieldName: 'Old Shield');
|
||||
|
||||
final match = ArenaMatch(
|
||||
challenger: _createEntry(
|
||||
id: 'challenger',
|
||||
level: 10,
|
||||
equipment: challengerEquip,
|
||||
),
|
||||
opponent: _createEntry(
|
||||
id: 'opponent',
|
||||
level: 10,
|
||||
equipment: opponentEquip,
|
||||
),
|
||||
challengerBettingSlot: EquipmentSlot.helm,
|
||||
// 상대가 도전자의 shield를 노림
|
||||
opponentBettingSlot: EquipmentSlot.shield,
|
||||
);
|
||||
|
||||
final result = service.createResultFromSimulation(
|
||||
match: match,
|
||||
challengerHp: 0,
|
||||
opponentHp: 50,
|
||||
turns: 10,
|
||||
);
|
||||
|
||||
// 도전자 패배
|
||||
expect(result.isVictory, isFalse);
|
||||
|
||||
// 상대가 도전자의 shield(Rare Shield)를 획득
|
||||
final opponentShield = result.updatedOpponent.finalEquipment!.firstWhere(
|
||||
(e) => e.slot == EquipmentSlot.shield,
|
||||
);
|
||||
expect(opponentShield.name, 'Rare Shield');
|
||||
|
||||
// 도전자의 shield는 기본 장비로 대체
|
||||
final challengerShield = result.updatedChallenger.finalEquipment!
|
||||
.firstWhere((e) => e.slot == EquipmentSlot.shield);
|
||||
expect(challengerShield.name, isNot('Rare Shield'));
|
||||
});
|
||||
|
||||
test('양쪽 모두 HP > 0이면 패배 처리', () {
|
||||
final match = _createSimpleMatch();
|
||||
final result = service.createResultFromSimulation(
|
||||
match: match,
|
||||
challengerHp: 50,
|
||||
opponentHp: 50,
|
||||
turns: 100,
|
||||
);
|
||||
|
||||
// 도전자 HP > 0이지만 상대도 > 0이므로 패배
|
||||
expect(result.isVictory, isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// 베팅 슬롯 선택 (betting slot selection)
|
||||
// ============================================================================
|
||||
|
||||
group('selectOpponentBettingSlot', () {
|
||||
test('장비가 없으면 기본 슬롯(helm) 반환', () {
|
||||
final entry = _createEntry(id: 'test', level: 5, equipment: []);
|
||||
final slot = service.selectOpponentBettingSlot(entry);
|
||||
expect(slot, EquipmentSlot.helm);
|
||||
});
|
||||
|
||||
test('무기(weapon)는 약탈 대상에서 제외', () {
|
||||
// 무기만 높은 점수(score)를 가진 장비
|
||||
final equipment = [
|
||||
const EquipmentItem(
|
||||
name: 'God Keyboard',
|
||||
slot: EquipmentSlot.weapon,
|
||||
level: 99,
|
||||
weight: 5,
|
||||
stats: ItemStats(atk: 999),
|
||||
rarity: ItemRarity.legendary,
|
||||
),
|
||||
const EquipmentItem(
|
||||
name: 'Basic Shield',
|
||||
slot: EquipmentSlot.shield,
|
||||
level: 1,
|
||||
weight: 8,
|
||||
stats: ItemStats(def: 1),
|
||||
rarity: ItemRarity.common,
|
||||
),
|
||||
];
|
||||
|
||||
final entry = _createEntry(id: 'test', level: 50, equipment: equipment);
|
||||
final slot = service.selectOpponentBettingSlot(entry);
|
||||
|
||||
// 무기가 아닌 shield가 선택되어야 함
|
||||
expect(slot, isNot(EquipmentSlot.weapon));
|
||||
});
|
||||
});
|
||||
|
||||
group('getBettableSlots', () {
|
||||
test('무기(weapon)를 제외한 슬롯 목록 반환', () {
|
||||
final slots = service.getBettableSlots();
|
||||
expect(slots, isNot(contains(EquipmentSlot.weapon)));
|
||||
expect(slots.length, EquipmentSlot.values.length - 1);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// 아레나 점수 계산 (arena score)
|
||||
// ============================================================================
|
||||
|
||||
group('HallOfFameArenaX.calculateArenaScore', () {
|
||||
test('레벨이 높을수록 점수가 높다', () {
|
||||
final low = _createEntry(id: 'low', level: 5);
|
||||
final high = _createEntry(id: 'high', level: 50);
|
||||
|
||||
final lowScore = HallOfFameArenaX.calculateArenaScore(low);
|
||||
final highScore = HallOfFameArenaX.calculateArenaScore(high);
|
||||
|
||||
expect(highScore, greaterThan(lowScore));
|
||||
});
|
||||
|
||||
test('장비가 좋을수록 점수가 높다', () {
|
||||
final weak = _createEntry(id: 'weak', level: 10);
|
||||
final strong = _createEntry(
|
||||
id: 'strong',
|
||||
level: 10,
|
||||
equipment: [
|
||||
const EquipmentItem(
|
||||
name: 'Epic Sword',
|
||||
slot: EquipmentSlot.weapon,
|
||||
level: 50,
|
||||
weight: 5,
|
||||
stats: ItemStats(atk: 100),
|
||||
rarity: ItemRarity.epic,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
final weakScore = HallOfFameArenaX.calculateArenaScore(weak);
|
||||
final strongScore = HallOfFameArenaX.calculateArenaScore(strong);
|
||||
|
||||
expect(strongScore, greaterThan(weakScore));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 테스트 헬퍼 (test helpers)
|
||||
// ============================================================================
|
||||
|
||||
/// 테스트용 HallOfFameEntry 생성
|
||||
HallOfFameEntry _createEntry({
|
||||
required String id,
|
||||
required int level,
|
||||
List<EquipmentItem>? equipment,
|
||||
CombatStats? stats,
|
||||
}) {
|
||||
return HallOfFameEntry(
|
||||
id: id,
|
||||
characterName: 'Test Character $id',
|
||||
race: 'Human',
|
||||
klass: 'Fighter',
|
||||
level: level,
|
||||
totalPlayTimeMs: 3600000,
|
||||
totalDeaths: 0,
|
||||
monstersKilled: 100,
|
||||
questsCompleted: 10,
|
||||
clearedAt: DateTime(2025, 1, 1),
|
||||
finalStats: stats,
|
||||
finalEquipment: equipment,
|
||||
);
|
||||
}
|
||||
|
||||
/// 테스트용 장비 목록 생성 (helm, shield 이름 커스텀 가능)
|
||||
List<EquipmentItem> _createEquipmentList({
|
||||
String helmName = 'Test Helm',
|
||||
String shieldName = 'Test Shield',
|
||||
}) {
|
||||
return [
|
||||
EquipmentItem.defaultWeapon(),
|
||||
EquipmentItem(
|
||||
name: shieldName,
|
||||
slot: EquipmentSlot.shield,
|
||||
level: 5,
|
||||
weight: 8,
|
||||
stats: const ItemStats(def: 5),
|
||||
rarity: ItemRarity.common,
|
||||
),
|
||||
EquipmentItem(
|
||||
name: helmName,
|
||||
slot: EquipmentSlot.helm,
|
||||
level: 5,
|
||||
weight: 4,
|
||||
stats: const ItemStats(def: 3),
|
||||
rarity: ItemRarity.common,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/// 간단한 ArenaMatch 생성
|
||||
ArenaMatch _createSimpleMatch() {
|
||||
final equip = _createEquipmentList();
|
||||
return ArenaMatch(
|
||||
challenger: _createEntry(id: 'c', level: 10, equipment: equip),
|
||||
opponent: _createEntry(id: 'o', level: 10, equipment: equip),
|
||||
challengerBettingSlot: EquipmentSlot.helm,
|
||||
opponentBettingSlot: EquipmentSlot.shield,
|
||||
);
|
||||
}
|
||||
510
test/core/engine/potion_service_test.dart
Normal file
510
test/core/engine/potion_service_test.dart
Normal file
@@ -0,0 +1,510 @@
|
||||
import 'package:asciineverdie/data/potion_data.dart';
|
||||
import 'package:asciineverdie/src/core/engine/potion_service.dart';
|
||||
import 'package:asciineverdie/src/core/model/monster_grade.dart';
|
||||
import 'package:asciineverdie/src/core/model/potion.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
const service = PotionService();
|
||||
|
||||
// ============================================================================
|
||||
// 물약 사용 가능 여부 (canUsePotion)
|
||||
// ============================================================================
|
||||
|
||||
group('canUsePotion', () {
|
||||
test('보유 물약이 있으면 사용 가능', () {
|
||||
final inventory = const PotionInventory().addPotion(
|
||||
PotionData.minorHealthPatch.id,
|
||||
3,
|
||||
);
|
||||
final (canUse, reason) = service.canUsePotion(
|
||||
PotionData.minorHealthPatch.id,
|
||||
inventory,
|
||||
);
|
||||
expect(canUse, isTrue);
|
||||
expect(reason, isNull);
|
||||
});
|
||||
|
||||
test('존재하지 않는 물약 ID는 potionNotFound', () {
|
||||
final (canUse, reason) = service.canUsePotion(
|
||||
'nonexistent_potion',
|
||||
PotionInventory.empty,
|
||||
);
|
||||
expect(canUse, isFalse);
|
||||
expect(reason, PotionUseFailReason.potionNotFound);
|
||||
});
|
||||
|
||||
test('보유 수량이 0이면 outOfStock', () {
|
||||
final (canUse, reason) = service.canUsePotion(
|
||||
PotionData.minorHealthPatch.id,
|
||||
PotionInventory.empty,
|
||||
);
|
||||
expect(canUse, isFalse);
|
||||
expect(reason, PotionUseFailReason.outOfStock);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// 물약 사용 (usePotion)
|
||||
// ============================================================================
|
||||
|
||||
group('usePotion', () {
|
||||
test('HP 물약 사용 시 HP가 회복된다', () {
|
||||
final potion = PotionData.minorHealthPatch; // 고정 30, 비율 0%
|
||||
final inventory = PotionInventory.empty.addPotion(potion.id);
|
||||
|
||||
final result = service.usePotion(
|
||||
potionId: potion.id,
|
||||
inventory: inventory,
|
||||
currentHp: 50,
|
||||
maxHp: 100,
|
||||
currentMp: 30,
|
||||
maxMp: 50,
|
||||
);
|
||||
|
||||
expect(result.success, isTrue);
|
||||
expect(result.newHp, 80); // 50 + 30 = 80
|
||||
expect(result.healedAmount, 30);
|
||||
// MP는 변하지 않음
|
||||
expect(result.newMp, 30);
|
||||
// 인벤토리에서 수량 감소
|
||||
expect(result.newInventory!.hasPotion(potion.id), isFalse);
|
||||
});
|
||||
|
||||
test('HP 물약은 maxHp를 초과하지 않는다', () {
|
||||
final potion = PotionData.minorHealthPatch;
|
||||
final inventory = PotionInventory.empty.addPotion(potion.id);
|
||||
|
||||
final result = service.usePotion(
|
||||
potionId: potion.id,
|
||||
inventory: inventory,
|
||||
currentHp: 90,
|
||||
maxHp: 100,
|
||||
currentMp: 50,
|
||||
maxMp: 50,
|
||||
);
|
||||
|
||||
expect(result.success, isTrue);
|
||||
expect(result.newHp, 100); // 90 + 30 = 120 → clamp → 100
|
||||
expect(result.healedAmount, 10); // 실제 회복량 = 100 - 90
|
||||
});
|
||||
|
||||
test('MP 물약 사용 시 MP가 회복된다', () {
|
||||
final potion = PotionData.minorManaCache; // 고정 20, 비율 0%
|
||||
final inventory = PotionInventory.empty.addPotion(potion.id);
|
||||
|
||||
final result = service.usePotion(
|
||||
potionId: potion.id,
|
||||
inventory: inventory,
|
||||
currentHp: 100,
|
||||
maxHp: 100,
|
||||
currentMp: 10,
|
||||
maxMp: 50,
|
||||
);
|
||||
|
||||
expect(result.success, isTrue);
|
||||
expect(result.newMp, 30); // 10 + 20 = 30
|
||||
expect(result.healedAmount, 20);
|
||||
// HP는 변하지 않음
|
||||
expect(result.newHp, 100);
|
||||
});
|
||||
|
||||
test('비율 회복(percent heal) 물약은 최대치 비례로 회복', () {
|
||||
final potion = PotionData.healthPatch; // 고정 50, 비율 10%
|
||||
final inventory = PotionInventory.empty.addPotion(potion.id);
|
||||
|
||||
final result = service.usePotion(
|
||||
potionId: potion.id,
|
||||
inventory: inventory,
|
||||
currentHp: 100,
|
||||
maxHp: 1000,
|
||||
currentMp: 50,
|
||||
maxMp: 50,
|
||||
);
|
||||
|
||||
// 고정 50 + (1000 * 0.10) = 50 + 100 = 150
|
||||
expect(result.success, isTrue);
|
||||
expect(result.newHp, 250); // 100 + 150
|
||||
expect(result.healedAmount, 150);
|
||||
});
|
||||
|
||||
test('healingMultiplier 적용 시 회복량 증가', () {
|
||||
final potion = PotionData.minorHealthPatch; // 고정 30
|
||||
final inventory = PotionInventory.empty.addPotion(potion.id);
|
||||
|
||||
final result = service.usePotion(
|
||||
potionId: potion.id,
|
||||
inventory: inventory,
|
||||
currentHp: 50,
|
||||
maxHp: 200,
|
||||
currentMp: 50,
|
||||
maxMp: 50,
|
||||
healingMultiplier: 1.5, // +50% 회복력 보너스(bonus)
|
||||
);
|
||||
|
||||
// 30 * 1.5 = 45
|
||||
expect(result.success, isTrue);
|
||||
expect(result.newHp, 95); // 50 + 45
|
||||
expect(result.healedAmount, 45);
|
||||
});
|
||||
|
||||
test('보유 물약이 없으면 실패', () {
|
||||
final result = service.usePotion(
|
||||
potionId: PotionData.minorHealthPatch.id,
|
||||
inventory: PotionInventory.empty,
|
||||
currentHp: 50,
|
||||
maxHp: 100,
|
||||
currentMp: 30,
|
||||
maxMp: 50,
|
||||
);
|
||||
|
||||
expect(result.success, isFalse);
|
||||
expect(result.failReason, PotionUseFailReason.outOfStock);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// 물약 구매 (purchasePotion)
|
||||
// ============================================================================
|
||||
|
||||
group('purchasePotion', () {
|
||||
test('골드가 충분하면 구매 성공', () {
|
||||
final potion = PotionData.minorHealthPatch; // 가격 25
|
||||
final result = service.purchasePotion(
|
||||
potionId: potion.id,
|
||||
inventory: PotionInventory.empty,
|
||||
gold: 100,
|
||||
);
|
||||
|
||||
expect(result.success, isTrue);
|
||||
expect(result.totalCost, 25);
|
||||
expect(result.newGold, 75);
|
||||
expect(result.newInventory!.getQuantity(potion.id), 1);
|
||||
});
|
||||
|
||||
test('복수 구매 시 총 비용 정확 계산', () {
|
||||
final potion = PotionData.minorHealthPatch; // 가격 25
|
||||
final result = service.purchasePotion(
|
||||
potionId: potion.id,
|
||||
inventory: PotionInventory.empty,
|
||||
gold: 1000,
|
||||
count: 5,
|
||||
);
|
||||
|
||||
expect(result.success, isTrue);
|
||||
expect(result.totalCost, 125); // 25 * 5
|
||||
expect(result.newGold, 875);
|
||||
expect(result.newInventory!.getQuantity(potion.id), 5);
|
||||
});
|
||||
|
||||
test('골드 부족 시 구매 실패', () {
|
||||
final potion = PotionData.ultraHealthPatch; // 가격 1200
|
||||
final result = service.purchasePotion(
|
||||
potionId: potion.id,
|
||||
inventory: PotionInventory.empty,
|
||||
gold: 100,
|
||||
);
|
||||
|
||||
expect(result.success, isFalse);
|
||||
expect(result.failReason, PotionPurchaseFailReason.insufficientGold);
|
||||
});
|
||||
|
||||
test('존재하지 않는 물약 ID는 구매 실패', () {
|
||||
final result = service.purchasePotion(
|
||||
potionId: 'invalid_potion',
|
||||
inventory: PotionInventory.empty,
|
||||
gold: 99999,
|
||||
);
|
||||
|
||||
expect(result.success, isFalse);
|
||||
expect(result.failReason, PotionPurchaseFailReason.potionNotFound);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// canPurchasePotion - 구매 가능 여부 체크
|
||||
// ============================================================================
|
||||
|
||||
group('canPurchasePotion', () {
|
||||
test('골드 충분 시 구매 가능', () {
|
||||
final (canBuy, reason) = service.canPurchasePotion(
|
||||
PotionData.minorHealthPatch.id,
|
||||
100,
|
||||
);
|
||||
expect(canBuy, isTrue);
|
||||
expect(reason, isNull);
|
||||
});
|
||||
|
||||
test('골드 부족 시 insufficientGold', () {
|
||||
final (canBuy, reason) = service.canPurchasePotion(
|
||||
PotionData.ultraHealthPatch.id,
|
||||
10,
|
||||
);
|
||||
expect(canBuy, isFalse);
|
||||
expect(reason, PotionPurchaseFailReason.insufficientGold);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// 자동 구매 (autoPurchasePotions)
|
||||
// ============================================================================
|
||||
|
||||
group('autoPurchasePotions', () {
|
||||
test('레벨에 맞는 티어(tier) 물약을 자동 구매', () {
|
||||
final result = service.autoPurchasePotions(
|
||||
playerLevel: 5, // 티어 1
|
||||
inventory: PotionInventory.empty,
|
||||
gold: 10000,
|
||||
);
|
||||
|
||||
expect(result.success, isTrue);
|
||||
expect(result.quantity, greaterThan(0));
|
||||
expect(result.totalCost, greaterThan(0));
|
||||
// 사용 골드 비율은 20% 이내
|
||||
expect(result.totalCost, lessThanOrEqualTo(2000));
|
||||
});
|
||||
|
||||
test('골드가 0이면 구매 실패', () {
|
||||
final result = service.autoPurchasePotions(
|
||||
playerLevel: 5,
|
||||
inventory: PotionInventory.empty,
|
||||
gold: 0,
|
||||
);
|
||||
|
||||
expect(result.success, isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// 물약 드랍 (potion drop)
|
||||
// ============================================================================
|
||||
|
||||
group('tryPotionDrop', () {
|
||||
test('roll이 드랍 확률(drop chance) 미만이면 물약 드랍', () {
|
||||
final (inventory, potion) = service.tryPotionDrop(
|
||||
playerLevel: 10,
|
||||
monsterLevel: 10,
|
||||
monsterGrade: MonsterGrade.normal,
|
||||
inventory: PotionInventory.empty,
|
||||
roll: 0, // 항상 드랍 성공
|
||||
typeRoll: 30, // HP 물약 (< 60)
|
||||
);
|
||||
|
||||
expect(potion, isNotNull);
|
||||
expect(potion!.isHpPotion, isTrue);
|
||||
expect(inventory.hasPotion(potion.id), isTrue);
|
||||
});
|
||||
|
||||
test('roll이 드랍 확률 이상이면 드랍 실패', () {
|
||||
final (inventory, potion) = service.tryPotionDrop(
|
||||
playerLevel: 1,
|
||||
monsterLevel: 1,
|
||||
monsterGrade: MonsterGrade.normal,
|
||||
inventory: PotionInventory.empty,
|
||||
roll: 99, // 거의 무조건 실패
|
||||
typeRoll: 30,
|
||||
);
|
||||
|
||||
expect(potion, isNull);
|
||||
// 인벤토리 변화 없음
|
||||
expect(inventory.potions, isEmpty);
|
||||
});
|
||||
|
||||
test('typeRoll >= 60이면 MP 물약 드랍', () {
|
||||
final (_, potion) = service.tryPotionDrop(
|
||||
playerLevel: 10,
|
||||
monsterLevel: 10,
|
||||
monsterGrade: MonsterGrade.normal,
|
||||
inventory: PotionInventory.empty,
|
||||
roll: 0,
|
||||
typeRoll: 70, // MP 물약 (>= 60)
|
||||
);
|
||||
|
||||
expect(potion, isNotNull);
|
||||
expect(potion!.isMpPotion, isTrue);
|
||||
});
|
||||
|
||||
test('보스(boss) 몬스터는 드랍 확률 +15%', () {
|
||||
// 일반 몬스터 Lv1 기본 드랍 확률 = 15%
|
||||
// 보스 보너스 +15% → 총 30%
|
||||
|
||||
// roll=25: 일반은 실패(25 >= 15), 보스는 성공(25 < 30)
|
||||
final (_, normalPotion) = service.tryPotionDrop(
|
||||
playerLevel: 1,
|
||||
monsterLevel: 1,
|
||||
monsterGrade: MonsterGrade.normal,
|
||||
inventory: PotionInventory.empty,
|
||||
roll: 25,
|
||||
typeRoll: 30,
|
||||
);
|
||||
|
||||
final (_, bossPotion) = service.tryPotionDrop(
|
||||
playerLevel: 1,
|
||||
monsterLevel: 1,
|
||||
monsterGrade: MonsterGrade.boss,
|
||||
inventory: PotionInventory.empty,
|
||||
roll: 25,
|
||||
typeRoll: 30,
|
||||
);
|
||||
|
||||
expect(normalPotion, isNull);
|
||||
expect(bossPotion, isNotNull);
|
||||
});
|
||||
|
||||
test('몬스터 레벨 > 플레이어 레벨이면 추가 확률 부여', () {
|
||||
// 기본 15% + 레벨 차이 10 * 1% = 25%
|
||||
// roll=20: 레벨 차이 없으면 실패(20 >= 15), 있으면 성공(20 < 25)
|
||||
final (_, withoutDiffPotion) = service.tryPotionDrop(
|
||||
playerLevel: 10,
|
||||
monsterLevel: 10,
|
||||
monsterGrade: MonsterGrade.normal,
|
||||
inventory: PotionInventory.empty,
|
||||
roll: 20,
|
||||
typeRoll: 30,
|
||||
);
|
||||
|
||||
final (_, withDiffPotion) = service.tryPotionDrop(
|
||||
playerLevel: 10,
|
||||
monsterLevel: 20,
|
||||
monsterGrade: MonsterGrade.normal,
|
||||
inventory: PotionInventory.empty,
|
||||
roll: 20,
|
||||
typeRoll: 30,
|
||||
);
|
||||
|
||||
// Lv10 기본 확률 = 15 + 10*0.5 = 20% → roll 20 >= 20 → 실패
|
||||
expect(withoutDiffPotion, isNull);
|
||||
// 레벨 차이 10 → 추가 10% → 30% → roll 20 < 30 → 성공
|
||||
expect(withDiffPotion, isNotNull);
|
||||
});
|
||||
|
||||
test('기존 인벤토리에 물약 추가', () {
|
||||
final existingInventory = PotionInventory.empty.addPotion(
|
||||
PotionData.minorHealthPatch.id,
|
||||
5,
|
||||
);
|
||||
|
||||
final (inventory, potion) = service.tryPotionDrop(
|
||||
playerLevel: 1,
|
||||
monsterLevel: 1,
|
||||
monsterGrade: MonsterGrade.normal,
|
||||
inventory: existingInventory,
|
||||
roll: 0, // 드랍 성공
|
||||
typeRoll: 30, // HP 물약
|
||||
);
|
||||
|
||||
expect(potion, isNotNull);
|
||||
// 기존 5개 + 새로 1개
|
||||
final droppedId = potion!.id;
|
||||
if (droppedId == PotionData.minorHealthPatch.id) {
|
||||
expect(inventory.getQuantity(droppedId), 6);
|
||||
} else {
|
||||
// 다른 티어(tier) 물약이 드랍될 수 있음
|
||||
expect(inventory.getQuantity(droppedId), 1);
|
||||
expect(inventory.getQuantity(PotionData.minorHealthPatch.id), 5);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// 인벤토리 관리 (inventory management)
|
||||
// ============================================================================
|
||||
|
||||
group('addPotionDrop', () {
|
||||
test('물약 드랍 추가 시 수량 증가', () {
|
||||
final result = service.addPotionDrop(
|
||||
PotionInventory.empty,
|
||||
PotionData.minorHealthPatch.id,
|
||||
3,
|
||||
);
|
||||
expect(result.getQuantity(PotionData.minorHealthPatch.id), 3);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// 긴급 물약 선택 (emergency potion selection)
|
||||
// ============================================================================
|
||||
|
||||
group('selectEmergencyHpPotion', () {
|
||||
test('HP 손실이 회복량 이상이면 물약 선택', () {
|
||||
final inventory = PotionInventory.empty.addPotion(
|
||||
PotionData.minorHealthPatch.id,
|
||||
5,
|
||||
);
|
||||
|
||||
// maxHp=100, currentHp=50 → 손실 50 >= 회복량 30
|
||||
final potion = service.selectEmergencyHpPotion(
|
||||
currentHp: 50,
|
||||
maxHp: 100,
|
||||
inventory: inventory,
|
||||
playerLevel: 5,
|
||||
);
|
||||
|
||||
expect(potion, isNotNull);
|
||||
expect(potion!.id, PotionData.minorHealthPatch.id);
|
||||
});
|
||||
|
||||
test('HP가 만충(full)이면 null', () {
|
||||
final inventory = PotionInventory.empty.addPotion(
|
||||
PotionData.minorHealthPatch.id,
|
||||
5,
|
||||
);
|
||||
|
||||
final potion = service.selectEmergencyHpPotion(
|
||||
currentHp: 100,
|
||||
maxHp: 100,
|
||||
inventory: inventory,
|
||||
playerLevel: 5,
|
||||
);
|
||||
|
||||
expect(potion, isNull);
|
||||
});
|
||||
|
||||
test('보유 물약이 없으면 null', () {
|
||||
final potion = service.selectEmergencyHpPotion(
|
||||
currentHp: 10,
|
||||
maxHp: 100,
|
||||
inventory: PotionInventory.empty,
|
||||
playerLevel: 5,
|
||||
);
|
||||
|
||||
expect(potion, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('selectEmergencyMpPotion', () {
|
||||
test('MP 손실이 회복량 이상이면 물약 선택', () {
|
||||
final inventory = PotionInventory.empty.addPotion(
|
||||
PotionData.minorManaCache.id,
|
||||
5,
|
||||
);
|
||||
|
||||
// maxMp=100, currentMp=50 → 손실 50 >= 회복량 20
|
||||
final potion = service.selectEmergencyMpPotion(
|
||||
currentMp: 50,
|
||||
maxMp: 100,
|
||||
inventory: inventory,
|
||||
playerLevel: 5,
|
||||
);
|
||||
|
||||
expect(potion, isNotNull);
|
||||
expect(potion!.id, PotionData.minorManaCache.id);
|
||||
});
|
||||
|
||||
test('MP가 만충(full)이면 null', () {
|
||||
final inventory = PotionInventory.empty.addPotion(
|
||||
PotionData.minorManaCache.id,
|
||||
5,
|
||||
);
|
||||
|
||||
final potion = service.selectEmergencyMpPotion(
|
||||
currentMp: 50,
|
||||
maxMp: 50,
|
||||
inventory: inventory,
|
||||
playerLevel: 5,
|
||||
);
|
||||
|
||||
expect(potion, isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
261
test/core/engine/shop_service_test.dart
Normal file
261
test/core/engine/shop_service_test.dart
Normal file
@@ -0,0 +1,261 @@
|
||||
import 'package:asciineverdie/src/core/engine/shop_service.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/item_stats.dart';
|
||||
import 'package:asciineverdie/src/core/util/deterministic_random.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
late ShopService service;
|
||||
|
||||
setUp(() {
|
||||
// 결정론적 시드(deterministic seed)로 재현 가능한 테스트
|
||||
service = ShopService(rng: DeterministicRandom(42));
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// 가격 계산 (price calculation)
|
||||
// ============================================================================
|
||||
|
||||
group('calculateBuyPrice', () {
|
||||
test('빈 아이템(empty item)은 가격 0', () {
|
||||
final emptyItem = EquipmentItem.empty(EquipmentSlot.helm);
|
||||
expect(service.calculateBuyPrice(emptyItem), 0);
|
||||
});
|
||||
|
||||
test('Common 아이템 가격 = 레벨 * 50 * 1.0', () {
|
||||
const item = EquipmentItem(
|
||||
name: 'Test Helm',
|
||||
slot: EquipmentSlot.helm,
|
||||
level: 10,
|
||||
weight: 4,
|
||||
stats: ItemStats(def: 5),
|
||||
rarity: ItemRarity.common,
|
||||
);
|
||||
// 10 * 50 * 1.0 = 500
|
||||
expect(service.calculateBuyPrice(item), 500);
|
||||
});
|
||||
|
||||
test('Rare 아이템 가격 = 레벨 * 50 * 5.0', () {
|
||||
const item = EquipmentItem(
|
||||
name: 'Superior Helm',
|
||||
slot: EquipmentSlot.helm,
|
||||
level: 10,
|
||||
weight: 4,
|
||||
stats: ItemStats(def: 10),
|
||||
rarity: ItemRarity.rare,
|
||||
);
|
||||
// 10 * 50 * 5.0 = 2500
|
||||
expect(service.calculateBuyPrice(item), 2500);
|
||||
});
|
||||
|
||||
test('Legendary 아이템 가격 = 레벨 * 50 * 50.0', () {
|
||||
const item = EquipmentItem(
|
||||
name: 'Legendary Keyboard',
|
||||
slot: EquipmentSlot.weapon,
|
||||
level: 5,
|
||||
weight: 5,
|
||||
stats: ItemStats(atk: 30),
|
||||
rarity: ItemRarity.legendary,
|
||||
);
|
||||
// 5 * 50 * 50.0 = 12500
|
||||
expect(service.calculateBuyPrice(item), 12500);
|
||||
});
|
||||
});
|
||||
|
||||
group('calculateSellPrice', () {
|
||||
test('판매 가격 = 구매 가격 * 0.3', () {
|
||||
const item = EquipmentItem(
|
||||
name: 'Test Shield',
|
||||
slot: EquipmentSlot.shield,
|
||||
level: 10,
|
||||
weight: 8,
|
||||
stats: ItemStats(def: 5),
|
||||
rarity: ItemRarity.common,
|
||||
);
|
||||
final buyPrice = service.calculateBuyPrice(item); // 500
|
||||
final sellPrice = service.calculateSellPrice(item);
|
||||
expect(sellPrice, (buyPrice * 0.3).round());
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// 장비 생성 (item generation)
|
||||
// ============================================================================
|
||||
|
||||
group('generateShopItem', () {
|
||||
test('생성된 아이템의 슬롯(slot)이 요청과 일치', () {
|
||||
final item = service.generateShopItem(
|
||||
playerLevel: 10,
|
||||
slot: EquipmentSlot.helm,
|
||||
);
|
||||
expect(item.slot, EquipmentSlot.helm);
|
||||
expect(item.isNotEmpty, isTrue);
|
||||
});
|
||||
|
||||
test('아이템 레벨은 플레이어 레벨 ±2 범위 이내', () {
|
||||
// 여러 시드(seed)로 반복 검증
|
||||
for (var seed = 0; seed < 50; seed++) {
|
||||
final svc = ShopService(rng: DeterministicRandom(seed));
|
||||
final item = svc.generateShopItem(
|
||||
playerLevel: 10,
|
||||
slot: EquipmentSlot.weapon,
|
||||
);
|
||||
expect(item.level, inInclusiveRange(8, 12));
|
||||
}
|
||||
});
|
||||
|
||||
test('targetRarity 지정 시 해당 희귀도로 생성', () {
|
||||
final item = service.generateShopItem(
|
||||
playerLevel: 5,
|
||||
slot: EquipmentSlot.shield,
|
||||
targetRarity: ItemRarity.rare,
|
||||
);
|
||||
expect(item.rarity, ItemRarity.rare);
|
||||
});
|
||||
|
||||
test('무기(weapon) 슬롯은 atk 스탯을 가진다', () {
|
||||
final item = service.generateShopItem(
|
||||
playerLevel: 10,
|
||||
slot: EquipmentSlot.weapon,
|
||||
targetRarity: ItemRarity.common,
|
||||
);
|
||||
expect(item.stats.atk, greaterThan(0));
|
||||
});
|
||||
|
||||
test('방패(shield) 슬롯은 def 스탯을 가진다', () {
|
||||
final item = service.generateShopItem(
|
||||
playerLevel: 10,
|
||||
slot: EquipmentSlot.shield,
|
||||
targetRarity: ItemRarity.common,
|
||||
);
|
||||
expect(item.stats.def, greaterThan(0));
|
||||
});
|
||||
|
||||
test('레벨 1에서도 최소 레벨 1의 아이템 생성', () {
|
||||
final item = service.generateShopItem(
|
||||
playerLevel: 1,
|
||||
slot: EquipmentSlot.helm,
|
||||
);
|
||||
expect(item.level, greaterThanOrEqualTo(1));
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// buyItem - 단일 구매 (single purchase)
|
||||
// ============================================================================
|
||||
|
||||
group('buyItem', () {
|
||||
test('골드가 충분하면 구매 성공', () {
|
||||
final result = service.buyItem(
|
||||
playerLevel: 5,
|
||||
currentGold: 100000,
|
||||
slot: EquipmentSlot.helm,
|
||||
);
|
||||
expect(result, isNotNull);
|
||||
expect(result!.item.slot, EquipmentSlot.helm);
|
||||
expect(result.price, greaterThan(0));
|
||||
expect(result.remainingGold, 100000 - result.price);
|
||||
});
|
||||
|
||||
test('골드가 부족하면 null 반환', () {
|
||||
final result = service.buyItem(
|
||||
playerLevel: 50,
|
||||
currentGold: 0,
|
||||
slot: EquipmentSlot.hauberk,
|
||||
preferredRarity: ItemRarity.epic,
|
||||
);
|
||||
expect(result, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// sellItem - 장비 판매 (sell)
|
||||
// ============================================================================
|
||||
|
||||
group('sellItem', () {
|
||||
test('판매 시 골드가 증가한다', () {
|
||||
const item = EquipmentItem(
|
||||
name: 'Old Shield',
|
||||
slot: EquipmentSlot.shield,
|
||||
level: 5,
|
||||
weight: 8,
|
||||
stats: ItemStats(def: 3),
|
||||
rarity: ItemRarity.common,
|
||||
);
|
||||
final result = service.sellItem(item, 1000);
|
||||
|
||||
expect(result.price, greaterThan(0));
|
||||
expect(result.newGold, 1000 + result.price);
|
||||
expect(result.item, item);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// autoBuyForEmptySlots - 빈 슬롯 자동 구매 (auto-buy)
|
||||
// ============================================================================
|
||||
|
||||
group('autoBuyForEmptySlots', () {
|
||||
test('빈 슬롯이 있으면 자동으로 구매한다', () {
|
||||
// 빈 장비(empty equipment)에서 시작
|
||||
final emptyEquipment = Equipment.empty();
|
||||
final result = service.autoBuyForEmptySlots(
|
||||
playerLevel: 5,
|
||||
currentGold: 100000,
|
||||
currentEquipment: emptyEquipment,
|
||||
);
|
||||
|
||||
// 무기(weapon)는 기본 장비가 있으므로 나머지 10개 슬롯 구매 시도
|
||||
expect(result.purchasedCount, greaterThan(0));
|
||||
expect(result.totalCost, greaterThan(0));
|
||||
expect(result.remainingGold, 100000 - result.totalCost);
|
||||
});
|
||||
|
||||
test('골드가 0이면 구매하지 않는다', () {
|
||||
final emptyEquipment = Equipment.empty();
|
||||
final result = service.autoBuyForEmptySlots(
|
||||
playerLevel: 5,
|
||||
currentGold: 0,
|
||||
currentEquipment: emptyEquipment,
|
||||
);
|
||||
|
||||
expect(result.purchasedCount, 0);
|
||||
expect(result.totalCost, 0);
|
||||
});
|
||||
|
||||
test('모든 슬롯이 차있으면 구매하지 않는다', () {
|
||||
// 모든 슬롯에 장비가 있는 상태
|
||||
final fullEquipment = _createFullEquipment();
|
||||
final result = service.autoBuyForEmptySlots(
|
||||
playerLevel: 5,
|
||||
currentGold: 100000,
|
||||
currentEquipment: fullEquipment,
|
||||
);
|
||||
|
||||
expect(result.purchasedCount, 0);
|
||||
expect(result.totalCost, 0);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// 테스트용 풀 장비 생성 헬퍼
|
||||
Equipment _createFullEquipment() {
|
||||
return Equipment(
|
||||
items: [
|
||||
EquipmentItem.defaultWeapon(),
|
||||
...List.generate(
|
||||
Equipment.slotCount - 1,
|
||||
(i) => EquipmentItem(
|
||||
name: 'Test Item ${i + 1}',
|
||||
slot: EquipmentSlot.values[i + 1],
|
||||
level: 5,
|
||||
weight: 3,
|
||||
stats: const ItemStats(def: 5),
|
||||
rarity: ItemRarity.common,
|
||||
),
|
||||
),
|
||||
],
|
||||
bestIndex: 0,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user