test: 테스트 파일 추가 및 리팩토링 반영
- combat_calculator_test, skill_service_test, stat_calculator_test 추가 - mock_factories.dart 헬퍼 추가 - progress_loop_test, game_session_controller_test 서비스 분리 반영
This commit is contained in:
337
test/core/engine/combat_calculator_test.dart
Normal file
337
test/core/engine/combat_calculator_test.dart
Normal file
@@ -0,0 +1,337 @@
|
|||||||
|
import 'package:asciineverdie/src/core/engine/combat_calculator.dart';
|
||||||
|
import 'package:asciineverdie/src/core/model/combat_stats.dart';
|
||||||
|
import 'package:asciineverdie/src/core/model/monster_combat_stats.dart';
|
||||||
|
import 'package:asciineverdie/src/core/util/deterministic_random.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('CombatCalculator', () {
|
||||||
|
group('playerAttackMonster', () {
|
||||||
|
test('데미지 = (ATK * variation) - (DEF * 0.4)', () {
|
||||||
|
// 고정 시드로 예측 가능한 결과
|
||||||
|
final rng = DeterministicRandom( 42);
|
||||||
|
final calculator = CombatCalculator(rng: rng);
|
||||||
|
|
||||||
|
final player = CombatStats.empty().copyWith(
|
||||||
|
atk: 100,
|
||||||
|
accuracy: 1.0, // 100% 명중
|
||||||
|
criRate: 0.0, // 크리티컬 없음
|
||||||
|
criDamage: 1.5,
|
||||||
|
);
|
||||||
|
|
||||||
|
final monster = MonsterCombatStats(
|
||||||
|
name: 'Test Monster',
|
||||||
|
level: 1,
|
||||||
|
atk: 10,
|
||||||
|
def: 50, // DEF 50 → 50 * 0.4 = 20 감소
|
||||||
|
hpMax: 100,
|
||||||
|
hpCurrent: 100,
|
||||||
|
criRate: 0.05,
|
||||||
|
criDamage: 1.5,
|
||||||
|
evasion: 0.0, // 회피 없음
|
||||||
|
accuracy: 0.8,
|
||||||
|
attackDelayMs: 1000,
|
||||||
|
expReward: 100,
|
||||||
|
);
|
||||||
|
|
||||||
|
final result = calculator.playerAttackMonster(
|
||||||
|
attacker: player,
|
||||||
|
defender: monster,
|
||||||
|
);
|
||||||
|
|
||||||
|
// ATK 100 * (0.8~1.2) - DEF 50 * 0.4 = 80~120 - 20 = 60~100
|
||||||
|
expect(result.result.damage, greaterThanOrEqualTo(1));
|
||||||
|
expect(result.result.isHit, isTrue);
|
||||||
|
expect(result.result.isEvaded, isFalse);
|
||||||
|
expect(result.updatedDefender.hpCurrent, lessThan(monster.hpCurrent));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('크리티컬 발동 시 데미지 배율 적용', () {
|
||||||
|
// 크리티컬이 항상 발동하도록 criRate = 1.0
|
||||||
|
final rng = DeterministicRandom( 123);
|
||||||
|
final calculator = CombatCalculator(rng: rng);
|
||||||
|
|
||||||
|
final player = CombatStats.empty().copyWith(
|
||||||
|
atk: 100,
|
||||||
|
accuracy: 1.0, // 100% 명중
|
||||||
|
criRate: 1.0, // 100% 크리티컬
|
||||||
|
criDamage: 2.0, // 2배 데미지
|
||||||
|
);
|
||||||
|
|
||||||
|
final monster = MonsterCombatStats(
|
||||||
|
name: 'Test Monster',
|
||||||
|
level: 1,
|
||||||
|
atk: 10,
|
||||||
|
def: 0, // DEF 0
|
||||||
|
hpMax: 500,
|
||||||
|
hpCurrent: 500,
|
||||||
|
criRate: 0.05,
|
||||||
|
criDamage: 1.5,
|
||||||
|
evasion: 0.0, // 회피 없음
|
||||||
|
accuracy: 0.8,
|
||||||
|
attackDelayMs: 1000,
|
||||||
|
expReward: 100,
|
||||||
|
);
|
||||||
|
|
||||||
|
final result = calculator.playerAttackMonster(
|
||||||
|
attacker: player,
|
||||||
|
defender: monster,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 크리티컬 시 데미지가 2배 적용
|
||||||
|
expect(result.result.isCritical, isTrue);
|
||||||
|
// ATK 100 * (0.8~1.2) * 2.0 = 160~240
|
||||||
|
expect(result.result.damage, greaterThanOrEqualTo(160));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('회피 발동 시 0 데미지', () {
|
||||||
|
final rng = DeterministicRandom( 42);
|
||||||
|
final calculator = CombatCalculator(rng: rng);
|
||||||
|
|
||||||
|
final player = CombatStats.empty().copyWith(
|
||||||
|
atk: 100,
|
||||||
|
accuracy: 0.0, // 0% 명중 → 항상 회피
|
||||||
|
criRate: 0.0,
|
||||||
|
criDamage: 1.5,
|
||||||
|
);
|
||||||
|
|
||||||
|
final monster = MonsterCombatStats(
|
||||||
|
name: 'Test Monster',
|
||||||
|
level: 1,
|
||||||
|
atk: 10,
|
||||||
|
def: 0,
|
||||||
|
hpMax: 100,
|
||||||
|
hpCurrent: 100,
|
||||||
|
criRate: 0.05,
|
||||||
|
criDamage: 1.5,
|
||||||
|
evasion: 1.0, // 100% 회피
|
||||||
|
accuracy: 0.8,
|
||||||
|
attackDelayMs: 1000,
|
||||||
|
expReward: 100,
|
||||||
|
);
|
||||||
|
|
||||||
|
final result = calculator.playerAttackMonster(
|
||||||
|
attacker: player,
|
||||||
|
defender: monster,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 회피 시 데미지 0
|
||||||
|
expect(result.result.damage, equals(0));
|
||||||
|
expect(result.result.isHit, isFalse);
|
||||||
|
expect(result.result.isEvaded, isTrue);
|
||||||
|
expect(result.updatedDefender.hpCurrent, equals(monster.hpCurrent));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('monsterAttackPlayer', () {
|
||||||
|
test('블록 발동 시 70% 감소', () {
|
||||||
|
// 블록이 발동하는 시드 찾기
|
||||||
|
// blockRate = 1.0으로 항상 블록
|
||||||
|
final rng = DeterministicRandom( 99);
|
||||||
|
final calculator = CombatCalculator(rng: rng);
|
||||||
|
|
||||||
|
final monster = MonsterCombatStats(
|
||||||
|
name: 'Test Monster',
|
||||||
|
level: 1,
|
||||||
|
atk: 100,
|
||||||
|
def: 0,
|
||||||
|
hpMax: 100,
|
||||||
|
hpCurrent: 100,
|
||||||
|
criRate: 0.0, // 크리티컬 없음
|
||||||
|
criDamage: 1.5,
|
||||||
|
evasion: 0.0,
|
||||||
|
accuracy: 1.0, // 100% 명중
|
||||||
|
attackDelayMs: 1000,
|
||||||
|
expReward: 100,
|
||||||
|
);
|
||||||
|
|
||||||
|
final player = CombatStats.empty().copyWith(
|
||||||
|
hpMax: 500,
|
||||||
|
hpCurrent: 500,
|
||||||
|
def: 0,
|
||||||
|
evasion: 0.0,
|
||||||
|
blockRate: 1.0, // 100% 블록
|
||||||
|
parryRate: 0.0,
|
||||||
|
);
|
||||||
|
|
||||||
|
final result = calculator.monsterAttackPlayer(
|
||||||
|
attacker: monster,
|
||||||
|
defender: player,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 블록 시 데미지 30% (70% 감소)
|
||||||
|
// ATK 100 * (0.8~1.2) * 0.3 = 24~36
|
||||||
|
expect(result.result.isBlocked, isTrue);
|
||||||
|
expect(result.result.damage, lessThanOrEqualTo(40));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('패리 발동 시 50% 감소', () {
|
||||||
|
final rng = DeterministicRandom( 77);
|
||||||
|
final calculator = CombatCalculator(rng: rng);
|
||||||
|
|
||||||
|
final monster = MonsterCombatStats(
|
||||||
|
name: 'Test Monster',
|
||||||
|
level: 1,
|
||||||
|
atk: 100,
|
||||||
|
def: 0,
|
||||||
|
hpMax: 100,
|
||||||
|
hpCurrent: 100,
|
||||||
|
criRate: 0.0,
|
||||||
|
criDamage: 1.5,
|
||||||
|
evasion: 0.0,
|
||||||
|
accuracy: 1.0,
|
||||||
|
attackDelayMs: 1000,
|
||||||
|
expReward: 100,
|
||||||
|
);
|
||||||
|
|
||||||
|
final player = CombatStats.empty().copyWith(
|
||||||
|
hpMax: 500,
|
||||||
|
hpCurrent: 500,
|
||||||
|
def: 0,
|
||||||
|
evasion: 0.0,
|
||||||
|
blockRate: 0.0, // 블록 없음
|
||||||
|
parryRate: 1.0, // 100% 패리
|
||||||
|
);
|
||||||
|
|
||||||
|
final result = calculator.monsterAttackPlayer(
|
||||||
|
attacker: monster,
|
||||||
|
defender: player,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 패리 시 데미지 50%
|
||||||
|
// ATK 100 * (0.8~1.2) * 0.5 = 40~60
|
||||||
|
expect(result.result.isParried, isTrue);
|
||||||
|
expect(result.result.damage, lessThanOrEqualTo(65));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('estimateCombatDurationMs', () {
|
||||||
|
test('범위 2000~30000ms 내 반환', () {
|
||||||
|
final rng = DeterministicRandom( 42);
|
||||||
|
final calculator = CombatCalculator(rng: rng);
|
||||||
|
|
||||||
|
final player = CombatStats.empty().copyWith(
|
||||||
|
atk: 50,
|
||||||
|
accuracy: 0.9,
|
||||||
|
attackDelayMs: 1000,
|
||||||
|
);
|
||||||
|
|
||||||
|
final monster = MonsterCombatStats(
|
||||||
|
name: 'Test Monster',
|
||||||
|
level: 1,
|
||||||
|
atk: 10,
|
||||||
|
def: 10,
|
||||||
|
hpMax: 100,
|
||||||
|
hpCurrent: 100,
|
||||||
|
criRate: 0.05,
|
||||||
|
criDamage: 1.5,
|
||||||
|
evasion: 0.0,
|
||||||
|
accuracy: 0.8,
|
||||||
|
attackDelayMs: 1000,
|
||||||
|
expReward: 100,
|
||||||
|
);
|
||||||
|
|
||||||
|
final duration = calculator.estimateCombatDurationMs(
|
||||||
|
player: player,
|
||||||
|
monster: monster,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(duration, greaterThanOrEqualTo(2000));
|
||||||
|
expect(duration, lessThanOrEqualTo(30000));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('고레벨 몬스터는 더 긴 전투 시간', () {
|
||||||
|
final rng = DeterministicRandom( 42);
|
||||||
|
final calculator = CombatCalculator(rng: rng);
|
||||||
|
|
||||||
|
final player = CombatStats.empty().copyWith(
|
||||||
|
atk: 50,
|
||||||
|
accuracy: 0.9,
|
||||||
|
attackDelayMs: 1000,
|
||||||
|
);
|
||||||
|
|
||||||
|
final weakMonster = MonsterCombatStats(
|
||||||
|
name: 'Weak Monster',
|
||||||
|
level: 1,
|
||||||
|
atk: 10,
|
||||||
|
def: 5,
|
||||||
|
hpMax: 50,
|
||||||
|
hpCurrent: 50,
|
||||||
|
criRate: 0.05,
|
||||||
|
criDamage: 1.5,
|
||||||
|
evasion: 0.0,
|
||||||
|
accuracy: 0.8,
|
||||||
|
attackDelayMs: 1000,
|
||||||
|
expReward: 50,
|
||||||
|
);
|
||||||
|
|
||||||
|
final strongMonster = MonsterCombatStats(
|
||||||
|
name: 'Strong Monster',
|
||||||
|
level: 10,
|
||||||
|
atk: 50,
|
||||||
|
def: 20,
|
||||||
|
hpMax: 500,
|
||||||
|
hpCurrent: 500,
|
||||||
|
criRate: 0.05,
|
||||||
|
criDamage: 1.5,
|
||||||
|
evasion: 0.0,
|
||||||
|
accuracy: 0.8,
|
||||||
|
attackDelayMs: 1000,
|
||||||
|
expReward: 200,
|
||||||
|
);
|
||||||
|
|
||||||
|
final weakDuration = calculator.estimateCombatDurationMs(
|
||||||
|
player: player,
|
||||||
|
monster: weakMonster,
|
||||||
|
);
|
||||||
|
|
||||||
|
final strongDuration = calculator.estimateCombatDurationMs(
|
||||||
|
player: player,
|
||||||
|
monster: strongMonster,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 강한 몬스터가 더 긴 전투 시간
|
||||||
|
expect(strongDuration, greaterThan(weakDuration));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('evaluateDifficulty', () {
|
||||||
|
test('범위 0.0~1.0 내 반환', () {
|
||||||
|
final rng = DeterministicRandom( 42);
|
||||||
|
final calculator = CombatCalculator(rng: rng);
|
||||||
|
|
||||||
|
final player = CombatStats.empty().copyWith(
|
||||||
|
hpMax: 100,
|
||||||
|
hpCurrent: 100,
|
||||||
|
atk: 50,
|
||||||
|
def: 20,
|
||||||
|
accuracy: 0.9,
|
||||||
|
attackDelayMs: 1000,
|
||||||
|
);
|
||||||
|
|
||||||
|
final monster = MonsterCombatStats(
|
||||||
|
name: 'Test Monster',
|
||||||
|
level: 1,
|
||||||
|
atk: 30,
|
||||||
|
def: 10,
|
||||||
|
hpMax: 80,
|
||||||
|
hpCurrent: 80,
|
||||||
|
criRate: 0.05,
|
||||||
|
criDamage: 1.5,
|
||||||
|
evasion: 0.0,
|
||||||
|
accuracy: 0.8,
|
||||||
|
attackDelayMs: 1000,
|
||||||
|
expReward: 100,
|
||||||
|
);
|
||||||
|
|
||||||
|
final difficulty = calculator.evaluateDifficulty(
|
||||||
|
player: player,
|
||||||
|
monster: monster,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(difficulty, greaterThanOrEqualTo(0.0));
|
||||||
|
expect(difficulty, lessThanOrEqualTo(1.0));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,63 +1,18 @@
|
|||||||
import 'package:asciineverdie/src/core/engine/game_mutations.dart';
|
|
||||||
import 'package:asciineverdie/src/core/engine/progress_loop.dart';
|
import 'package:asciineverdie/src/core/engine/progress_loop.dart';
|
||||||
import 'package:asciineverdie/src/core/engine/progress_service.dart';
|
|
||||||
import 'package:asciineverdie/src/core/engine/reward_service.dart';
|
|
||||||
import 'package:asciineverdie/src/core/model/combat_state.dart';
|
import 'package:asciineverdie/src/core/model/combat_state.dart';
|
||||||
import 'package:asciineverdie/src/core/model/combat_stats.dart';
|
import 'package:asciineverdie/src/core/model/combat_stats.dart';
|
||||||
import 'package:asciineverdie/src/core/model/game_state.dart';
|
import 'package:asciineverdie/src/core/model/game_state.dart';
|
||||||
import 'package:asciineverdie/src/core/model/monster_combat_stats.dart';
|
import 'package:asciineverdie/src/core/model/monster_combat_stats.dart';
|
||||||
import 'package:asciineverdie/src/core/model/pq_config.dart';
|
|
||||||
import 'package:asciineverdie/src/core/storage/save_manager.dart';
|
|
||||||
import 'package:asciineverdie/src/core/storage/save_repository.dart';
|
|
||||||
import 'package:asciineverdie/src/core/storage/save_service.dart';
|
|
||||||
import 'package:asciineverdie/src/core/util/balance_constants.dart';
|
import 'package:asciineverdie/src/core/util/balance_constants.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
class _FakeSaveManager implements SaveManager {
|
import '../../helpers/mock_factories.dart';
|
||||||
final List<GameState> savedStates = [];
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<SaveOutcome> saveState(
|
|
||||||
GameState state, {
|
|
||||||
String? fileName,
|
|
||||||
bool cheatsEnabled = false,
|
|
||||||
}) async {
|
|
||||||
savedStates.add(state);
|
|
||||||
return const SaveOutcome.success();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<(SaveOutcome, GameState?, bool)> loadState({String? fileName}) async {
|
|
||||||
return (const SaveOutcome.success(), null, false);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<List<SaveFileInfo>> listSaves() async => [];
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<SaveOutcome> deleteSave({String? fileName}) async {
|
|
||||||
return const SaveOutcome.success();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<bool> saveExists({String? fileName}) async => false;
|
|
||||||
}
|
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
late ProgressService service;
|
late final service = MockFactories.createProgressService();
|
||||||
|
|
||||||
setUp(() {
|
|
||||||
const config = PqConfig();
|
|
||||||
final mutations = GameMutations(config);
|
|
||||||
service = ProgressService(
|
|
||||||
config: config,
|
|
||||||
mutations: mutations,
|
|
||||||
rewards: RewardService(mutations, config),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('autosaves on level-up and stop when configured', () async {
|
test('autosaves on level-up and stop when configured', () async {
|
||||||
final saveManager = _FakeSaveManager();
|
final saveManager = FakeSaveManager();
|
||||||
|
|
||||||
// 레벨 1에서 레벨업에 필요한 경험치
|
// 레벨 1에서 레벨업에 필요한 경험치
|
||||||
final requiredExp = ExpConstants.requiredExp(1);
|
final requiredExp = ExpConstants.requiredExp(1);
|
||||||
|
|||||||
679
test/core/engine/skill_service_test.dart
Normal file
679
test/core/engine/skill_service_test.dart
Normal file
@@ -0,0 +1,679 @@
|
|||||||
|
import 'package:asciineverdie/data/skill_data.dart';
|
||||||
|
import 'package:asciineverdie/src/core/engine/skill_service.dart';
|
||||||
|
import 'package:asciineverdie/src/core/model/combat_stats.dart';
|
||||||
|
import 'package:asciineverdie/src/core/model/game_state.dart';
|
||||||
|
import 'package:asciineverdie/src/core/model/monster_combat_stats.dart';
|
||||||
|
import 'package:asciineverdie/src/core/model/skill.dart';
|
||||||
|
import 'package:asciineverdie/src/core/util/deterministic_random.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('SkillService', () {
|
||||||
|
group('canUseSkill', () {
|
||||||
|
test('GCD 활성화 시 onGlobalCooldown 반환', () {
|
||||||
|
final rng = DeterministicRandom(42);
|
||||||
|
final service = SkillService(rng: rng);
|
||||||
|
|
||||||
|
const skill = SkillData.stackTrace;
|
||||||
|
final skillSystem = SkillSystemState.empty().copyWith(
|
||||||
|
elapsedMs: 1000,
|
||||||
|
globalCooldownEndMs: 2500, // GCD 활성화 중
|
||||||
|
);
|
||||||
|
|
||||||
|
final result = service.canUseSkill(
|
||||||
|
skill: skill,
|
||||||
|
currentMp: 100,
|
||||||
|
skillSystem: skillSystem,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result, equals(SkillFailReason.onGlobalCooldown));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('MP 부족 시 notEnoughMp 반환', () {
|
||||||
|
final rng = DeterministicRandom(42);
|
||||||
|
final service = SkillService(rng: rng);
|
||||||
|
|
||||||
|
const skill = SkillData.stackTrace; // MP 10 필요
|
||||||
|
final skillSystem = SkillSystemState.empty().copyWith(elapsedMs: 5000);
|
||||||
|
|
||||||
|
final result = service.canUseSkill(
|
||||||
|
skill: skill,
|
||||||
|
currentMp: 5, // MP 부족
|
||||||
|
skillSystem: skillSystem,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result, equals(SkillFailReason.notEnoughMp));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('쿨타임 중 onCooldown 반환', () {
|
||||||
|
final rng = DeterministicRandom(42);
|
||||||
|
final service = SkillService(rng: rng);
|
||||||
|
|
||||||
|
const skill = SkillData.stackTrace; // 쿨타임 3000ms
|
||||||
|
final skillSystem = SkillSystemState.empty().copyWith(
|
||||||
|
elapsedMs: 2000,
|
||||||
|
skillStates: [
|
||||||
|
const SkillState(
|
||||||
|
skillId: 'stack_trace',
|
||||||
|
lastUsedMs: 1000, // 1초 전 사용
|
||||||
|
rank: 1,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
final result = service.canUseSkill(
|
||||||
|
skill: skill,
|
||||||
|
currentMp: 100,
|
||||||
|
skillSystem: skillSystem,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result, equals(SkillFailReason.onCooldown));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('모든 조건 충족 시 null 반환', () {
|
||||||
|
final rng = DeterministicRandom(42);
|
||||||
|
final service = SkillService(rng: rng);
|
||||||
|
|
||||||
|
const skill = SkillData.stackTrace;
|
||||||
|
final skillSystem = SkillSystemState.empty().copyWith(elapsedMs: 5000);
|
||||||
|
|
||||||
|
final result = service.canUseSkill(
|
||||||
|
skill: skill,
|
||||||
|
currentMp: 100,
|
||||||
|
skillSystem: skillSystem,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result, isNull);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('useAttackSkill', () {
|
||||||
|
test('기본 공격 데미지 계산', () {
|
||||||
|
final rng = DeterministicRandom(42);
|
||||||
|
final service = SkillService(rng: rng);
|
||||||
|
|
||||||
|
const skill = SkillData.stackTrace; // 2.0x 배율
|
||||||
|
final player = CombatStats.empty().copyWith(
|
||||||
|
atk: 100,
|
||||||
|
mpCurrent: 50,
|
||||||
|
mpMax: 100,
|
||||||
|
);
|
||||||
|
final monster = MonsterCombatStats(
|
||||||
|
name: 'Test Monster',
|
||||||
|
level: 1,
|
||||||
|
atk: 10,
|
||||||
|
def: 50, // DEF 50 → 50 * 0.3 = 15 감소
|
||||||
|
hpMax: 500,
|
||||||
|
hpCurrent: 500,
|
||||||
|
criRate: 0.05,
|
||||||
|
criDamage: 1.5,
|
||||||
|
evasion: 0.0,
|
||||||
|
accuracy: 0.8,
|
||||||
|
attackDelayMs: 1000,
|
||||||
|
expReward: 100,
|
||||||
|
);
|
||||||
|
final skillSystem = SkillSystemState.empty().copyWith(elapsedMs: 5000);
|
||||||
|
|
||||||
|
final result = service.useAttackSkill(
|
||||||
|
skill: skill,
|
||||||
|
player: player,
|
||||||
|
monster: monster,
|
||||||
|
skillSystem: skillSystem,
|
||||||
|
);
|
||||||
|
|
||||||
|
// ATK 100 * 2.0 - DEF 50 * 0.3 = 200 - 15 = 185
|
||||||
|
expect(result.result.success, isTrue);
|
||||||
|
expect(result.result.damage, equals(185));
|
||||||
|
expect(result.updatedPlayer.mpCurrent, equals(40)); // 50 - 10
|
||||||
|
expect(result.updatedMonster.hpCurrent, equals(315)); // 500 - 185
|
||||||
|
});
|
||||||
|
|
||||||
|
test('버프 적용 시 데미지 증가', () {
|
||||||
|
final rng = DeterministicRandom(42);
|
||||||
|
final service = SkillService(rng: rng);
|
||||||
|
|
||||||
|
const skill = SkillData.stackTrace;
|
||||||
|
final player = CombatStats.empty().copyWith(
|
||||||
|
atk: 100,
|
||||||
|
mpCurrent: 50,
|
||||||
|
mpMax: 100,
|
||||||
|
);
|
||||||
|
final monster = MonsterCombatStats(
|
||||||
|
name: 'Test Monster',
|
||||||
|
level: 1,
|
||||||
|
atk: 10,
|
||||||
|
def: 0,
|
||||||
|
hpMax: 500,
|
||||||
|
hpCurrent: 500,
|
||||||
|
criRate: 0.05,
|
||||||
|
criDamage: 1.5,
|
||||||
|
evasion: 0.0,
|
||||||
|
accuracy: 0.8,
|
||||||
|
attackDelayMs: 1000,
|
||||||
|
expReward: 100,
|
||||||
|
);
|
||||||
|
final skillSystem = SkillSystemState.empty().copyWith(
|
||||||
|
elapsedMs: 5000,
|
||||||
|
activeBuffs: [
|
||||||
|
const ActiveBuff(
|
||||||
|
effect: BuffEffect(
|
||||||
|
id: 'test_buff',
|
||||||
|
name: 'Test Buff',
|
||||||
|
durationMs: 10000,
|
||||||
|
atkModifier: 0.5, // +50% ATK
|
||||||
|
),
|
||||||
|
startedMs: 4000,
|
||||||
|
sourceSkillId: 'test',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
final result = service.useAttackSkill(
|
||||||
|
skill: skill,
|
||||||
|
player: player,
|
||||||
|
monster: monster,
|
||||||
|
skillSystem: skillSystem,
|
||||||
|
);
|
||||||
|
|
||||||
|
// ATK 100 * 2.0 * 1.5 = 300
|
||||||
|
expect(result.result.damage, equals(300));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('useAttackSkillWithRank', () {
|
||||||
|
test('랭크 1 데미지 (기본 배율)', () {
|
||||||
|
final rng = DeterministicRandom(42);
|
||||||
|
final service = SkillService(rng: rng);
|
||||||
|
|
||||||
|
const skill = SkillData.stackTrace;
|
||||||
|
final player = CombatStats.empty().copyWith(
|
||||||
|
atk: 100,
|
||||||
|
mpCurrent: 50,
|
||||||
|
mpMax: 100,
|
||||||
|
);
|
||||||
|
final monster = MonsterCombatStats(
|
||||||
|
name: 'Test Monster',
|
||||||
|
level: 1,
|
||||||
|
atk: 10,
|
||||||
|
def: 0,
|
||||||
|
hpMax: 500,
|
||||||
|
hpCurrent: 500,
|
||||||
|
criRate: 0.05,
|
||||||
|
criDamage: 1.5,
|
||||||
|
evasion: 0.0,
|
||||||
|
accuracy: 0.8,
|
||||||
|
attackDelayMs: 1000,
|
||||||
|
expReward: 100,
|
||||||
|
);
|
||||||
|
final skillSystem = SkillSystemState.empty().copyWith(elapsedMs: 5000);
|
||||||
|
|
||||||
|
final result = service.useAttackSkillWithRank(
|
||||||
|
skill: skill,
|
||||||
|
player: player,
|
||||||
|
monster: monster,
|
||||||
|
skillSystem: skillSystem,
|
||||||
|
rank: 1,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 랭크 1: 1.0x → ATK 100 * 2.0 * 1.0 = 200
|
||||||
|
expect(result.result.damage, equals(200));
|
||||||
|
expect(result.updatedPlayer.mpCurrent, equals(40)); // MP 10 소모
|
||||||
|
});
|
||||||
|
|
||||||
|
test('랭크 5 데미지 스케일링', () {
|
||||||
|
final rng = DeterministicRandom(42);
|
||||||
|
final service = SkillService(rng: rng);
|
||||||
|
|
||||||
|
const skill = SkillData.stackTrace;
|
||||||
|
final player = CombatStats.empty().copyWith(
|
||||||
|
atk: 100,
|
||||||
|
mpCurrent: 50,
|
||||||
|
mpMax: 100,
|
||||||
|
);
|
||||||
|
final monster = MonsterCombatStats(
|
||||||
|
name: 'Test Monster',
|
||||||
|
level: 1,
|
||||||
|
atk: 10,
|
||||||
|
def: 0,
|
||||||
|
hpMax: 500,
|
||||||
|
hpCurrent: 500,
|
||||||
|
criRate: 0.05,
|
||||||
|
criDamage: 1.5,
|
||||||
|
evasion: 0.0,
|
||||||
|
accuracy: 0.8,
|
||||||
|
attackDelayMs: 1000,
|
||||||
|
expReward: 100,
|
||||||
|
);
|
||||||
|
final skillSystem = SkillSystemState.empty().copyWith(elapsedMs: 5000);
|
||||||
|
|
||||||
|
final result = service.useAttackSkillWithRank(
|
||||||
|
skill: skill,
|
||||||
|
player: player,
|
||||||
|
monster: monster,
|
||||||
|
skillSystem: skillSystem,
|
||||||
|
rank: 5,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 랭크 5: 1.6x (1.0 + 4 * 0.15)
|
||||||
|
// ATK 100 * 2.0 * 1.6 = 320
|
||||||
|
expect(result.result.damage, equals(320));
|
||||||
|
|
||||||
|
// MP 비용: 10 * (1.0 - 4 * 0.03) = 10 * 0.88 = 9 (반올림)
|
||||||
|
expect(result.updatedPlayer.mpCurrent, equals(41)); // 50 - 9
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('useHealSkill', () {
|
||||||
|
test('고정 회복량', () {
|
||||||
|
final rng = DeterministicRandom(42);
|
||||||
|
final service = SkillService(rng: rng);
|
||||||
|
|
||||||
|
const skill = SkillData.hotReload; // healAmount: 30
|
||||||
|
final player = CombatStats.empty().copyWith(
|
||||||
|
hpMax: 200,
|
||||||
|
hpCurrent: 100,
|
||||||
|
mpMax: 100,
|
||||||
|
mpCurrent: 50,
|
||||||
|
);
|
||||||
|
final skillSystem = SkillSystemState.empty().copyWith(elapsedMs: 5000);
|
||||||
|
|
||||||
|
final result = service.useHealSkill(
|
||||||
|
skill: skill,
|
||||||
|
player: player,
|
||||||
|
skillSystem: skillSystem,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.result.success, isTrue);
|
||||||
|
expect(result.result.healedAmount, equals(30));
|
||||||
|
expect(result.updatedPlayer.hpCurrent, equals(130)); // 100 + 30
|
||||||
|
expect(result.updatedPlayer.mpCurrent, equals(35)); // 50 - 15
|
||||||
|
});
|
||||||
|
|
||||||
|
test('퍼센트 회복량', () {
|
||||||
|
final rng = DeterministicRandom(42);
|
||||||
|
final service = SkillService(rng: rng);
|
||||||
|
|
||||||
|
const skill = SkillData.garbageCollection; // healPercent: 0.3
|
||||||
|
final player = CombatStats.empty().copyWith(
|
||||||
|
hpMax: 200,
|
||||||
|
hpCurrent: 100,
|
||||||
|
mpMax: 100,
|
||||||
|
mpCurrent: 50,
|
||||||
|
);
|
||||||
|
final skillSystem = SkillSystemState.empty().copyWith(elapsedMs: 5000);
|
||||||
|
|
||||||
|
final result = service.useHealSkill(
|
||||||
|
skill: skill,
|
||||||
|
player: player,
|
||||||
|
skillSystem: skillSystem,
|
||||||
|
);
|
||||||
|
|
||||||
|
// healPercent 0.3 * hpMax 200 = 60
|
||||||
|
expect(result.result.healedAmount, equals(60));
|
||||||
|
expect(result.updatedPlayer.hpCurrent, equals(160)); // 100 + 60
|
||||||
|
});
|
||||||
|
|
||||||
|
test('HP 캡 적용', () {
|
||||||
|
final rng = DeterministicRandom(42);
|
||||||
|
final service = SkillService(rng: rng);
|
||||||
|
|
||||||
|
const skill = SkillData.snapshotRestore; // healPercent: 0.5
|
||||||
|
final player = CombatStats.empty().copyWith(
|
||||||
|
hpMax: 200,
|
||||||
|
hpCurrent: 180, // 거의 만피
|
||||||
|
mpMax: 100,
|
||||||
|
mpCurrent: 80,
|
||||||
|
);
|
||||||
|
final skillSystem = SkillSystemState.empty().copyWith(elapsedMs: 5000);
|
||||||
|
|
||||||
|
final result = service.useHealSkill(
|
||||||
|
skill: skill,
|
||||||
|
player: player,
|
||||||
|
skillSystem: skillSystem,
|
||||||
|
);
|
||||||
|
|
||||||
|
// healPercent 0.5 * hpMax 200 = 100
|
||||||
|
// 하지만 hpMax 캡으로 200까지만
|
||||||
|
expect(result.updatedPlayer.hpCurrent, equals(200));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('useDotSkill', () {
|
||||||
|
test('DOT 효과 생성', () {
|
||||||
|
final rng = DeterministicRandom(42);
|
||||||
|
final service = SkillService(rng: rng);
|
||||||
|
|
||||||
|
const skill = SkillData.memoryDump;
|
||||||
|
// baseDotDamage: 10, baseDotDurationMs: 6000, baseDotTickMs: 1000
|
||||||
|
final player = CombatStats.empty().copyWith(
|
||||||
|
mpMax: 100,
|
||||||
|
mpCurrent: 50,
|
||||||
|
);
|
||||||
|
final skillSystem = SkillSystemState.empty().copyWith(elapsedMs: 5000);
|
||||||
|
|
||||||
|
final result = service.useDotSkill(
|
||||||
|
skill: skill,
|
||||||
|
player: player,
|
||||||
|
skillSystem: skillSystem,
|
||||||
|
playerInt: 10, // 기본
|
||||||
|
playerWis: 10, // 기본
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.result.success, isTrue);
|
||||||
|
expect(result.dotEffect, isNotNull);
|
||||||
|
expect(result.dotEffect!.baseDamage, equals(10));
|
||||||
|
expect(result.dotEffect!.damagePerTick, equals(10)); // INT 10 → 1.0x
|
||||||
|
expect(result.dotEffect!.tickIntervalMs, equals(1000)); // WIS 10 → 1.0x
|
||||||
|
expect(result.dotEffect!.totalDurationMs, equals(6000));
|
||||||
|
|
||||||
|
// 예상 총 데미지: 6틱 × 10 = 60
|
||||||
|
expect(result.result.damage, equals(60));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('INT 보정 적용 (데미지 증가)', () {
|
||||||
|
final rng = DeterministicRandom(42);
|
||||||
|
final service = SkillService(rng: rng);
|
||||||
|
|
||||||
|
const skill = SkillData.memoryDump;
|
||||||
|
final player = CombatStats.empty().copyWith(
|
||||||
|
mpMax: 100,
|
||||||
|
mpCurrent: 50,
|
||||||
|
);
|
||||||
|
final skillSystem = SkillSystemState.empty().copyWith(elapsedMs: 5000);
|
||||||
|
|
||||||
|
final result = service.useDotSkill(
|
||||||
|
skill: skill,
|
||||||
|
player: player,
|
||||||
|
skillSystem: skillSystem,
|
||||||
|
playerInt: 20, // INT +10 → +30% 데미지
|
||||||
|
playerWis: 10,
|
||||||
|
);
|
||||||
|
|
||||||
|
// INT 20: 1.0 + (20-10) * 0.03 = 1.3x
|
||||||
|
// baseDotDamage 10 * 1.3 = 13
|
||||||
|
expect(result.dotEffect!.damagePerTick, equals(13));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('WIS 보정 적용 (틱 간격 감소)', () {
|
||||||
|
final rng = DeterministicRandom(42);
|
||||||
|
final service = SkillService(rng: rng);
|
||||||
|
|
||||||
|
const skill = SkillData.memoryDump;
|
||||||
|
final player = CombatStats.empty().copyWith(
|
||||||
|
mpMax: 100,
|
||||||
|
mpCurrent: 50,
|
||||||
|
);
|
||||||
|
final skillSystem = SkillSystemState.empty().copyWith(elapsedMs: 5000);
|
||||||
|
|
||||||
|
final result = service.useDotSkill(
|
||||||
|
skill: skill,
|
||||||
|
player: player,
|
||||||
|
skillSystem: skillSystem,
|
||||||
|
playerInt: 10,
|
||||||
|
playerWis: 20, // WIS +10 → 틱 간격 감소
|
||||||
|
);
|
||||||
|
|
||||||
|
// WIS 20: 1.0 + (20-10) * 0.02 = 1.2x
|
||||||
|
// baseDotTickMs 1000 / 1.2 = 833ms
|
||||||
|
expect(result.dotEffect!.tickIntervalMs, equals(833));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('calculateMpRegen', () {
|
||||||
|
test('비전투 중 MP 회복 (50ms당 1)', () {
|
||||||
|
final rng = DeterministicRandom(42);
|
||||||
|
final service = SkillService(rng: rng);
|
||||||
|
|
||||||
|
final result = service.calculateMpRegen(
|
||||||
|
elapsedMs: 1000, // 1초
|
||||||
|
isInCombat: false,
|
||||||
|
wis: 10,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 1000ms / 50ms = 20
|
||||||
|
expect(result, equals(20));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('전투 중 MP 회복 (WIS 기반)', () {
|
||||||
|
final rng = DeterministicRandom(42);
|
||||||
|
final service = SkillService(rng: rng);
|
||||||
|
|
||||||
|
final result = service.calculateMpRegen(
|
||||||
|
elapsedMs: 1000, // 1초
|
||||||
|
isInCombat: true,
|
||||||
|
wis: 10,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 전투 중: 500ms당 (1 + WIS/20)
|
||||||
|
// WIS 10: 1 + 10/20 = 1 (정수 연산)
|
||||||
|
// 1000ms / 500ms = 2틱
|
||||||
|
// 2틱 * 1 = 2
|
||||||
|
expect(result, equals(2));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('높은 WIS 전투 중 MP 회복', () {
|
||||||
|
final rng = DeterministicRandom(42);
|
||||||
|
final service = SkillService(rng: rng);
|
||||||
|
|
||||||
|
final result = service.calculateMpRegen(
|
||||||
|
elapsedMs: 1000,
|
||||||
|
isInCombat: true,
|
||||||
|
wis: 40,
|
||||||
|
);
|
||||||
|
|
||||||
|
// WIS 40: 1 + 40/20 = 3
|
||||||
|
// 2틱 * 3 = 6
|
||||||
|
expect(result, equals(6));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('selectAutoSkill', () {
|
||||||
|
test('MP 부족 시 null 반환 (일반 공격)', () {
|
||||||
|
final rng = DeterministicRandom(42);
|
||||||
|
final service = SkillService(rng: rng);
|
||||||
|
|
||||||
|
final player = CombatStats.empty().copyWith(
|
||||||
|
hpMax: 100,
|
||||||
|
hpCurrent: 100,
|
||||||
|
mpMax: 100,
|
||||||
|
mpCurrent: 10, // 10% MP
|
||||||
|
);
|
||||||
|
final monster = MonsterCombatStats(
|
||||||
|
name: 'Test Monster',
|
||||||
|
level: 1,
|
||||||
|
atk: 10,
|
||||||
|
def: 10,
|
||||||
|
hpMax: 100,
|
||||||
|
hpCurrent: 100,
|
||||||
|
criRate: 0.05,
|
||||||
|
criDamage: 1.5,
|
||||||
|
evasion: 0.0,
|
||||||
|
accuracy: 0.8,
|
||||||
|
attackDelayMs: 1000,
|
||||||
|
expReward: 100,
|
||||||
|
);
|
||||||
|
final skillSystem = SkillSystemState.empty().copyWith(elapsedMs: 5000);
|
||||||
|
|
||||||
|
final result = service.selectAutoSkill(
|
||||||
|
player: player,
|
||||||
|
monster: monster,
|
||||||
|
skillSystem: skillSystem,
|
||||||
|
availableSkillIds: ['stack_trace', 'garbage_collection'],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('HP 30% 미만 시 회복 스킬 우선', () {
|
||||||
|
// 시드 조정하여 일반 공격 확률을 넘어가도록
|
||||||
|
final rng = DeterministicRandom(999);
|
||||||
|
final service = SkillService(rng: rng);
|
||||||
|
|
||||||
|
final player = CombatStats.empty().copyWith(
|
||||||
|
hpMax: 100,
|
||||||
|
hpCurrent: 20, // 20% HP
|
||||||
|
mpMax: 100,
|
||||||
|
mpCurrent: 80,
|
||||||
|
);
|
||||||
|
final monster = MonsterCombatStats(
|
||||||
|
name: 'Test Monster',
|
||||||
|
level: 1,
|
||||||
|
atk: 10,
|
||||||
|
def: 10,
|
||||||
|
hpMax: 100,
|
||||||
|
hpCurrent: 100,
|
||||||
|
criRate: 0.05,
|
||||||
|
criDamage: 1.5,
|
||||||
|
evasion: 0.0,
|
||||||
|
accuracy: 0.8,
|
||||||
|
attackDelayMs: 1000,
|
||||||
|
expReward: 100,
|
||||||
|
);
|
||||||
|
final skillSystem = SkillSystemState.empty().copyWith(elapsedMs: 5000);
|
||||||
|
|
||||||
|
final result = service.selectAutoSkill(
|
||||||
|
player: player,
|
||||||
|
monster: monster,
|
||||||
|
skillSystem: skillSystem,
|
||||||
|
availableSkillIds: ['stack_trace', 'garbage_collection'],
|
||||||
|
);
|
||||||
|
|
||||||
|
// HP < 30%이면 회복 스킬 반환 (garbage_collection)
|
||||||
|
expect(result?.type, equals(SkillType.heal));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('useBuffSkill', () {
|
||||||
|
test('버프 적용', () {
|
||||||
|
final rng = DeterministicRandom(42);
|
||||||
|
final service = SkillService(rng: rng);
|
||||||
|
|
||||||
|
const skill = SkillData.debugMode; // ATK +25% 버프
|
||||||
|
final player = CombatStats.empty().copyWith(
|
||||||
|
mpMax: 100,
|
||||||
|
mpCurrent: 50,
|
||||||
|
);
|
||||||
|
final skillSystem = SkillSystemState.empty().copyWith(elapsedMs: 5000);
|
||||||
|
|
||||||
|
final result = service.useBuffSkill(
|
||||||
|
skill: skill,
|
||||||
|
player: player,
|
||||||
|
skillSystem: skillSystem,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.result.success, isTrue);
|
||||||
|
expect(result.result.appliedBuff, isNotNull);
|
||||||
|
expect(
|
||||||
|
result.result.appliedBuff!.effect.atkModifier,
|
||||||
|
equals(0.25),
|
||||||
|
);
|
||||||
|
expect(result.updatedSkillSystem.activeBuffs.length, equals(1));
|
||||||
|
expect(result.updatedPlayer.mpCurrent, equals(30)); // 50 - 20
|
||||||
|
});
|
||||||
|
|
||||||
|
test('중복 버프 제거 후 새 버프 적용', () {
|
||||||
|
final rng = DeterministicRandom(42);
|
||||||
|
final service = SkillService(rng: rng);
|
||||||
|
|
||||||
|
const skill = SkillData.debugMode;
|
||||||
|
final player = CombatStats.empty().copyWith(
|
||||||
|
mpMax: 100,
|
||||||
|
mpCurrent: 50,
|
||||||
|
);
|
||||||
|
final existingBuff = const ActiveBuff(
|
||||||
|
effect: BuffEffect(
|
||||||
|
id: 'debug_mode_buff',
|
||||||
|
name: 'Debug Mode',
|
||||||
|
durationMs: 10000,
|
||||||
|
atkModifier: 0.25,
|
||||||
|
),
|
||||||
|
startedMs: 1000,
|
||||||
|
sourceSkillId: 'debug_mode',
|
||||||
|
);
|
||||||
|
final skillSystem = SkillSystemState.empty().copyWith(
|
||||||
|
elapsedMs: 5000,
|
||||||
|
activeBuffs: [existingBuff],
|
||||||
|
);
|
||||||
|
|
||||||
|
final result = service.useBuffSkill(
|
||||||
|
skill: skill,
|
||||||
|
player: player,
|
||||||
|
skillSystem: skillSystem,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 기존 버프 제거 후 새 버프 추가 → 총 1개
|
||||||
|
expect(result.updatedSkillSystem.activeBuffs.length, equals(1));
|
||||||
|
expect(
|
||||||
|
result.updatedSkillSystem.activeBuffs.first.startedMs,
|
||||||
|
equals(5000), // 새 버프
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('cleanupExpiredBuffs', () {
|
||||||
|
test('만료된 버프 제거', () {
|
||||||
|
final rng = DeterministicRandom(42);
|
||||||
|
final service = SkillService(rng: rng);
|
||||||
|
|
||||||
|
final skillSystem = SkillSystemState.empty().copyWith(
|
||||||
|
elapsedMs: 20000,
|
||||||
|
activeBuffs: [
|
||||||
|
const ActiveBuff(
|
||||||
|
effect: BuffEffect(
|
||||||
|
id: 'buff1',
|
||||||
|
name: 'Active Buff',
|
||||||
|
durationMs: 15000, // 아직 활성
|
||||||
|
),
|
||||||
|
startedMs: 10000,
|
||||||
|
sourceSkillId: 'test1',
|
||||||
|
),
|
||||||
|
const ActiveBuff(
|
||||||
|
effect: BuffEffect(
|
||||||
|
id: 'buff2',
|
||||||
|
name: 'Expired Buff',
|
||||||
|
durationMs: 5000, // 만료됨 (시작 5000 + 지속 5000 = 10000 < 20000)
|
||||||
|
),
|
||||||
|
startedMs: 5000,
|
||||||
|
sourceSkillId: 'test2',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
final result = service.cleanupExpiredBuffs(skillSystem);
|
||||||
|
|
||||||
|
expect(result.activeBuffs.length, equals(1));
|
||||||
|
expect(result.activeBuffs.first.effect.id, equals('buff1'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('getRankMultiplier', () {
|
||||||
|
test('랭크별 배율 계산', () {
|
||||||
|
expect(getRankMultiplier(1), equals(1.0));
|
||||||
|
expect(getRankMultiplier(2), closeTo(1.15, 0.001));
|
||||||
|
expect(getRankMultiplier(3), closeTo(1.30, 0.001));
|
||||||
|
expect(getRankMultiplier(5), closeTo(1.60, 0.001));
|
||||||
|
expect(getRankMultiplier(10), closeTo(2.35, 0.001));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('getRankCooldownMultiplier', () {
|
||||||
|
test('랭크별 쿨타임 감소율', () {
|
||||||
|
expect(getRankCooldownMultiplier(1), equals(1.0));
|
||||||
|
expect(getRankCooldownMultiplier(2), closeTo(0.95, 0.001));
|
||||||
|
expect(getRankCooldownMultiplier(5), closeTo(0.80, 0.001));
|
||||||
|
// 최대 50% 감소
|
||||||
|
expect(getRankCooldownMultiplier(20), closeTo(0.5, 0.001));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('getRankMpMultiplier', () {
|
||||||
|
test('랭크별 MP 비용 감소율', () {
|
||||||
|
expect(getRankMpMultiplier(1), equals(1.0));
|
||||||
|
expect(getRankMpMultiplier(2), closeTo(0.97, 0.001));
|
||||||
|
expect(getRankMpMultiplier(5), closeTo(0.88, 0.001));
|
||||||
|
// 최대 30% 감소
|
||||||
|
expect(getRankMpMultiplier(20), closeTo(0.7, 0.001));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
356
test/core/engine/stat_calculator_test.dart
Normal file
356
test/core/engine/stat_calculator_test.dart
Normal file
@@ -0,0 +1,356 @@
|
|||||||
|
import 'package:asciineverdie/src/core/engine/stat_calculator.dart';
|
||||||
|
import 'package:asciineverdie/src/core/model/class_traits.dart';
|
||||||
|
import 'package:asciineverdie/src/core/model/combat_stats.dart';
|
||||||
|
import 'package:asciineverdie/src/core/model/game_state.dart';
|
||||||
|
import 'package:asciineverdie/src/core/model/race_traits.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
const calculator = StatCalculator();
|
||||||
|
|
||||||
|
group('StatCalculator', () {
|
||||||
|
group('applyModifiers', () {
|
||||||
|
test('종족 스탯 보정 적용', () {
|
||||||
|
final baseStats = const Stats(
|
||||||
|
str: 10,
|
||||||
|
con: 10,
|
||||||
|
dex: 10,
|
||||||
|
intelligence: 10,
|
||||||
|
wis: 10,
|
||||||
|
cha: 10,
|
||||||
|
hpMax: 100,
|
||||||
|
mpMax: 50,
|
||||||
|
);
|
||||||
|
|
||||||
|
// +2 STR, -1 INT 종족
|
||||||
|
final race = RaceTraits(
|
||||||
|
raceId: 'test_race',
|
||||||
|
name: 'Test Race',
|
||||||
|
statModifiers: const {
|
||||||
|
StatType.str: 2,
|
||||||
|
StatType.intelligence: -1,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// 보정 없는 클래스
|
||||||
|
const klass = ClassTraits(
|
||||||
|
classId: 'test_class',
|
||||||
|
name: 'Test Class',
|
||||||
|
statModifiers: {},
|
||||||
|
);
|
||||||
|
|
||||||
|
final result = calculator.applyModifiers(
|
||||||
|
baseStats: baseStats,
|
||||||
|
race: race,
|
||||||
|
klass: klass,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.str, equals(12)); // 10 + 2
|
||||||
|
expect(result.intelligence, equals(9)); // 10 - 1
|
||||||
|
expect(result.con, equals(10)); // 변화 없음
|
||||||
|
});
|
||||||
|
|
||||||
|
test('클래스 스탯 보정 적용', () {
|
||||||
|
final baseStats = const Stats(
|
||||||
|
str: 10,
|
||||||
|
con: 10,
|
||||||
|
dex: 10,
|
||||||
|
intelligence: 10,
|
||||||
|
wis: 10,
|
||||||
|
cha: 10,
|
||||||
|
hpMax: 100,
|
||||||
|
mpMax: 50,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 보정 없는 종족
|
||||||
|
const race = RaceTraits(
|
||||||
|
raceId: 'test_race',
|
||||||
|
name: 'Test Race',
|
||||||
|
statModifiers: {},
|
||||||
|
);
|
||||||
|
|
||||||
|
// +3 CON, +1 DEX 클래스
|
||||||
|
const klass = ClassTraits(
|
||||||
|
classId: 'test_class',
|
||||||
|
name: 'Test Class',
|
||||||
|
statModifiers: {
|
||||||
|
StatType.con: 3,
|
||||||
|
StatType.dex: 1,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
final result = calculator.applyModifiers(
|
||||||
|
baseStats: baseStats,
|
||||||
|
race: race,
|
||||||
|
klass: klass,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.con, equals(13)); // 10 + 3
|
||||||
|
expect(result.dex, equals(11)); // 10 + 1
|
||||||
|
});
|
||||||
|
|
||||||
|
test('HP 보너스 패시브 적용 (종족)', () {
|
||||||
|
final baseStats = const Stats(
|
||||||
|
str: 10,
|
||||||
|
con: 10,
|
||||||
|
dex: 10,
|
||||||
|
intelligence: 10,
|
||||||
|
wis: 10,
|
||||||
|
cha: 10,
|
||||||
|
hpMax: 100,
|
||||||
|
mpMax: 50,
|
||||||
|
);
|
||||||
|
|
||||||
|
// +20% HP 종족
|
||||||
|
final race = RaceTraits(
|
||||||
|
raceId: 'test_race',
|
||||||
|
name: 'Test Race',
|
||||||
|
statModifiers: const {},
|
||||||
|
passives: const [
|
||||||
|
PassiveAbility(type: PassiveType.hpBonus, value: 0.2),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const klass = ClassTraits(
|
||||||
|
classId: 'test_class',
|
||||||
|
name: 'Test Class',
|
||||||
|
statModifiers: {},
|
||||||
|
);
|
||||||
|
|
||||||
|
final result = calculator.applyModifiers(
|
||||||
|
baseStats: baseStats,
|
||||||
|
race: race,
|
||||||
|
klass: klass,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.hpMax, equals(120)); // 100 * 1.2
|
||||||
|
});
|
||||||
|
|
||||||
|
test('MP 보너스 패시브 적용 (종족)', () {
|
||||||
|
final baseStats = const Stats(
|
||||||
|
str: 10,
|
||||||
|
con: 10,
|
||||||
|
dex: 10,
|
||||||
|
intelligence: 10,
|
||||||
|
wis: 10,
|
||||||
|
cha: 10,
|
||||||
|
hpMax: 100,
|
||||||
|
mpMax: 50,
|
||||||
|
);
|
||||||
|
|
||||||
|
// +20% MP 종족
|
||||||
|
final race = RaceTraits(
|
||||||
|
raceId: 'test_race',
|
||||||
|
name: 'Test Race',
|
||||||
|
statModifiers: const {},
|
||||||
|
passives: const [
|
||||||
|
PassiveAbility(type: PassiveType.mpBonus, value: 0.2),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const klass = ClassTraits(
|
||||||
|
classId: 'test_class',
|
||||||
|
name: 'Test Class',
|
||||||
|
statModifiers: {},
|
||||||
|
);
|
||||||
|
|
||||||
|
final result = calculator.applyModifiers(
|
||||||
|
baseStats: baseStats,
|
||||||
|
race: race,
|
||||||
|
klass: klass,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.mpMax, equals(60)); // 50 * 1.2
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('applyPassives', () {
|
||||||
|
test('크리티컬 보너스 패시브 적용', () {
|
||||||
|
final combatStats = CombatStats.empty().copyWith(
|
||||||
|
atk: 50,
|
||||||
|
def: 20,
|
||||||
|
criRate: 0.1,
|
||||||
|
);
|
||||||
|
|
||||||
|
// +5% 크리티컬 종족
|
||||||
|
final race = RaceTraits(
|
||||||
|
raceId: 'test_race',
|
||||||
|
name: 'Test Race',
|
||||||
|
statModifiers: const {},
|
||||||
|
passives: const [
|
||||||
|
PassiveAbility(type: PassiveType.criticalBonus, value: 0.05),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const klass = ClassTraits(
|
||||||
|
classId: 'test_class',
|
||||||
|
name: 'Test Class',
|
||||||
|
statModifiers: {},
|
||||||
|
);
|
||||||
|
|
||||||
|
final result = calculator.applyPassives(
|
||||||
|
combatStats: combatStats,
|
||||||
|
race: race,
|
||||||
|
klass: klass,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.criRate, closeTo(0.15, 0.001)); // 0.1 + 0.05
|
||||||
|
});
|
||||||
|
|
||||||
|
test('크리티컬 확률 캡 (0.8) 적용', () {
|
||||||
|
final combatStats = CombatStats.empty().copyWith(
|
||||||
|
atk: 50,
|
||||||
|
def: 20,
|
||||||
|
criRate: 0.7,
|
||||||
|
);
|
||||||
|
|
||||||
|
// +20% 크리티컬 종족 (합계 0.9 → 캡 0.8)
|
||||||
|
final race = RaceTraits(
|
||||||
|
raceId: 'test_race',
|
||||||
|
name: 'Test Race',
|
||||||
|
statModifiers: const {},
|
||||||
|
passives: const [
|
||||||
|
PassiveAbility(type: PassiveType.criticalBonus, value: 0.2),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const klass = ClassTraits(
|
||||||
|
classId: 'test_class',
|
||||||
|
name: 'Test Class',
|
||||||
|
statModifiers: {},
|
||||||
|
);
|
||||||
|
|
||||||
|
final result = calculator.applyPassives(
|
||||||
|
combatStats: combatStats,
|
||||||
|
race: race,
|
||||||
|
klass: klass,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.criRate, equals(0.8)); // 캡 적용
|
||||||
|
});
|
||||||
|
|
||||||
|
test('회피율 캡 (0.6) 적용', () {
|
||||||
|
final combatStats = CombatStats.empty().copyWith(
|
||||||
|
atk: 50,
|
||||||
|
def: 20,
|
||||||
|
evasion: 0.5,
|
||||||
|
);
|
||||||
|
|
||||||
|
const race = RaceTraits(
|
||||||
|
raceId: 'test_race',
|
||||||
|
name: 'Test Race',
|
||||||
|
statModifiers: {},
|
||||||
|
);
|
||||||
|
|
||||||
|
// +15% 회피 클래스 (합계 0.65 → 캡 0.6)
|
||||||
|
const klass = ClassTraits(
|
||||||
|
classId: 'test_class',
|
||||||
|
name: 'Test Class',
|
||||||
|
statModifiers: {},
|
||||||
|
passives: [
|
||||||
|
ClassPassive(
|
||||||
|
type: ClassPassiveType.evasionBonus,
|
||||||
|
value: 0.15,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
final result = calculator.applyPassives(
|
||||||
|
combatStats: combatStats,
|
||||||
|
race: race,
|
||||||
|
klass: klass,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.evasion, equals(0.6)); // 캡 적용
|
||||||
|
});
|
||||||
|
|
||||||
|
test('물리 공격력 보너스 적용', () {
|
||||||
|
final combatStats = CombatStats.empty().copyWith(
|
||||||
|
atk: 100,
|
||||||
|
def: 20,
|
||||||
|
);
|
||||||
|
|
||||||
|
const race = RaceTraits(
|
||||||
|
raceId: 'test_race',
|
||||||
|
name: 'Test Race',
|
||||||
|
statModifiers: {},
|
||||||
|
);
|
||||||
|
|
||||||
|
// +20% 물리 공격 클래스
|
||||||
|
const klass = ClassTraits(
|
||||||
|
classId: 'test_class',
|
||||||
|
name: 'Test Class',
|
||||||
|
statModifiers: {},
|
||||||
|
passives: [
|
||||||
|
ClassPassive(
|
||||||
|
type: ClassPassiveType.physicalDamageBonus,
|
||||||
|
value: 0.2,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
final result = calculator.applyPassives(
|
||||||
|
combatStats: combatStats,
|
||||||
|
race: race,
|
||||||
|
klass: klass,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.atk, equals(120)); // 100 * 1.2
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('calculateExpMultiplier', () {
|
||||||
|
test('경험치 배율 반환', () {
|
||||||
|
final race = RaceTraits(
|
||||||
|
raceId: 'test_race',
|
||||||
|
name: 'Test Race',
|
||||||
|
statModifiers: const {},
|
||||||
|
expMultiplier: 1.10, // +10%
|
||||||
|
);
|
||||||
|
|
||||||
|
final result = calculator.calculateExpMultiplier(race);
|
||||||
|
|
||||||
|
expect(result, equals(1.10));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('calculatePostCombatHeal', () {
|
||||||
|
test('전투 후 회복량 계산', () {
|
||||||
|
// +5% 전투 후 회복 클래스
|
||||||
|
const klass = ClassTraits(
|
||||||
|
classId: 'test_class',
|
||||||
|
name: 'Test Class',
|
||||||
|
statModifiers: {},
|
||||||
|
passives: [
|
||||||
|
ClassPassive(
|
||||||
|
type: ClassPassiveType.postCombatHeal,
|
||||||
|
value: 0.05,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
final result = calculator.calculatePostCombatHeal(
|
||||||
|
klass: klass,
|
||||||
|
maxHp: 200,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result, equals(10)); // 200 * 0.05
|
||||||
|
});
|
||||||
|
|
||||||
|
test('패시브 없으면 0 반환', () {
|
||||||
|
const klass = ClassTraits(
|
||||||
|
classId: 'test_class',
|
||||||
|
name: 'Test Class',
|
||||||
|
statModifiers: {},
|
||||||
|
);
|
||||||
|
|
||||||
|
final result = calculator.calculatePostCombatHeal(
|
||||||
|
klass: klass,
|
||||||
|
maxHp: 200,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result, equals(0));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,17 +1,12 @@
|
|||||||
import 'package:asciineverdie/l10n/app_localizations.dart';
|
import 'package:asciineverdie/l10n/app_localizations.dart';
|
||||||
import 'package:asciineverdie/src/core/engine/game_mutations.dart';
|
|
||||||
import 'package:asciineverdie/src/core/engine/progress_service.dart';
|
|
||||||
import 'package:asciineverdie/src/core/engine/reward_service.dart';
|
|
||||||
import 'package:asciineverdie/src/core/model/game_state.dart';
|
import 'package:asciineverdie/src/core/model/game_state.dart';
|
||||||
import 'package:asciineverdie/src/core/model/pq_config.dart';
|
|
||||||
import 'package:asciineverdie/src/core/storage/save_manager.dart';
|
|
||||||
import 'package:asciineverdie/src/core/storage/save_repository.dart';
|
|
||||||
import 'package:asciineverdie/src/core/storage/save_service.dart';
|
|
||||||
import 'package:asciineverdie/src/features/game/game_play_screen.dart';
|
import 'package:asciineverdie/src/features/game/game_play_screen.dart';
|
||||||
import 'package:asciineverdie/src/features/game/game_session_controller.dart';
|
import 'package:asciineverdie/src/features/game/game_session_controller.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import '../helpers/mock_factories.dart';
|
||||||
|
|
||||||
/// 테스트용 MaterialApp 래퍼 (localization 포함)
|
/// 테스트용 MaterialApp 래퍼 (localization 포함)
|
||||||
Widget _buildTestApp(Widget child) {
|
Widget _buildTestApp(Widget child) {
|
||||||
return MaterialApp(
|
return MaterialApp(
|
||||||
@@ -21,33 +16,6 @@ Widget _buildTestApp(Widget child) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
class _FakeSaveManager implements SaveManager {
|
|
||||||
@override
|
|
||||||
Future<SaveOutcome> saveState(
|
|
||||||
GameState state, {
|
|
||||||
String? fileName,
|
|
||||||
bool cheatsEnabled = false,
|
|
||||||
}) async {
|
|
||||||
return const SaveOutcome.success();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<(SaveOutcome, GameState?, bool)> loadState({String? fileName}) async {
|
|
||||||
return (const SaveOutcome.success(), null, false);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<List<SaveFileInfo>> listSaves() async => [];
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<SaveOutcome> deleteSave({String? fileName}) async {
|
|
||||||
return const SaveOutcome.success();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<bool> saveExists({String? fileName}) async => false;
|
|
||||||
}
|
|
||||||
|
|
||||||
GameState _createTestState() {
|
GameState _createTestState() {
|
||||||
return GameState.withSeed(
|
return GameState.withSeed(
|
||||||
seed: 42,
|
seed: 42,
|
||||||
@@ -83,15 +51,9 @@ GameState _createTestState() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
GameSessionController _createController() {
|
GameSessionController _createController() {
|
||||||
const config = PqConfig();
|
|
||||||
final mutations = GameMutations(config);
|
|
||||||
return GameSessionController(
|
return GameSessionController(
|
||||||
progressService: ProgressService(
|
progressService: MockFactories.createProgressService(),
|
||||||
config: config,
|
saveManager: FakeSaveManager(),
|
||||||
mutations: mutations,
|
|
||||||
rewards: RewardService(mutations, config),
|
|
||||||
),
|
|
||||||
saveManager: _FakeSaveManager(),
|
|
||||||
tickInterval: const Duration(seconds: 10), // 느린 틱
|
tickInterval: const Duration(seconds: 10), // 느린 틱
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,58 +1,12 @@
|
|||||||
import 'package:asciineverdie/src/core/engine/game_mutations.dart';
|
|
||||||
import 'package:asciineverdie/src/core/engine/progress_service.dart';
|
|
||||||
import 'package:asciineverdie/src/core/engine/reward_service.dart';
|
|
||||||
import 'package:asciineverdie/src/core/model/game_state.dart';
|
import 'package:asciineverdie/src/core/model/game_state.dart';
|
||||||
import 'package:asciineverdie/src/core/model/pq_config.dart';
|
|
||||||
import 'package:asciineverdie/src/core/storage/save_manager.dart';
|
|
||||||
import 'package:asciineverdie/src/core/storage/save_repository.dart';
|
|
||||||
import 'package:asciineverdie/src/core/storage/save_service.dart';
|
|
||||||
import 'package:asciineverdie/src/features/game/game_session_controller.dart';
|
import 'package:asciineverdie/src/features/game/game_session_controller.dart';
|
||||||
import 'package:fake_async/fake_async.dart';
|
import 'package:fake_async/fake_async.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
class FakeSaveManager implements SaveManager {
|
import '../helpers/mock_factories.dart';
|
||||||
final List<GameState> savedStates = [];
|
|
||||||
(SaveOutcome, GameState?, bool) Function(String?)? onLoad;
|
|
||||||
SaveOutcome saveOutcome = const SaveOutcome.success();
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<SaveOutcome> saveState(
|
|
||||||
GameState state, {
|
|
||||||
String? fileName,
|
|
||||||
bool cheatsEnabled = false,
|
|
||||||
}) async {
|
|
||||||
savedStates.add(state);
|
|
||||||
return saveOutcome;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<(SaveOutcome, GameState?, bool)> loadState({String? fileName}) async {
|
|
||||||
if (onLoad != null) {
|
|
||||||
return onLoad!(fileName);
|
|
||||||
}
|
|
||||||
return (const SaveOutcome.success(), null, false);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<List<SaveFileInfo>> listSaves() async => [];
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<SaveOutcome> deleteSave({String? fileName}) async {
|
|
||||||
return const SaveOutcome.success();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<bool> saveExists({String? fileName}) async => false;
|
|
||||||
}
|
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
const config = PqConfig();
|
final progressService = MockFactories.createProgressService();
|
||||||
final mutations = GameMutations(config);
|
|
||||||
final progressService = ProgressService(
|
|
||||||
config: config,
|
|
||||||
mutations: mutations,
|
|
||||||
rewards: RewardService(mutations, config),
|
|
||||||
);
|
|
||||||
|
|
||||||
GameSessionController buildController(
|
GameSessionController buildController(
|
||||||
FakeAsync async,
|
FakeAsync async,
|
||||||
|
|||||||
195
test/helpers/mock_factories.dart
Normal file
195
test/helpers/mock_factories.dart
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
import 'package:asciineverdie/src/core/engine/game_mutations.dart';
|
||||||
|
import 'package:asciineverdie/src/core/engine/progress_service.dart';
|
||||||
|
import 'package:asciineverdie/src/core/engine/reward_service.dart';
|
||||||
|
import 'package:asciineverdie/src/core/model/combat_state.dart';
|
||||||
|
import 'package:asciineverdie/src/core/model/combat_stats.dart';
|
||||||
|
import 'package:asciineverdie/src/core/model/game_state.dart';
|
||||||
|
import 'package:asciineverdie/src/core/model/monster_combat_stats.dart';
|
||||||
|
import 'package:asciineverdie/src/core/model/pq_config.dart';
|
||||||
|
import 'package:asciineverdie/src/core/storage/save_manager.dart';
|
||||||
|
import 'package:asciineverdie/src/core/storage/save_repository.dart';
|
||||||
|
import 'package:asciineverdie/src/core/storage/save_service.dart';
|
||||||
|
import 'package:asciineverdie/src/core/util/balance_constants.dart';
|
||||||
|
|
||||||
|
export 'package:asciineverdie/src/core/storage/save_repository.dart'
|
||||||
|
show SaveOutcome;
|
||||||
|
export 'package:asciineverdie/src/core/storage/save_service.dart'
|
||||||
|
show SaveFileInfo;
|
||||||
|
|
||||||
|
/// 테스트용 Fake SaveManager
|
||||||
|
///
|
||||||
|
/// 여러 테스트 파일에서 중복되던 Mock을 통합
|
||||||
|
class FakeSaveManager implements SaveManager {
|
||||||
|
final List<GameState> savedStates = [];
|
||||||
|
|
||||||
|
/// 커스텀 로드 동작 설정
|
||||||
|
(SaveOutcome, GameState?, bool) Function(String?)? onLoad;
|
||||||
|
|
||||||
|
/// 저장 결과 설정 (기본: 성공)
|
||||||
|
SaveOutcome saveOutcome = const SaveOutcome.success();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<SaveOutcome> saveState(
|
||||||
|
GameState state, {
|
||||||
|
String? fileName,
|
||||||
|
bool cheatsEnabled = false,
|
||||||
|
}) async {
|
||||||
|
savedStates.add(state);
|
||||||
|
return saveOutcome;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<(SaveOutcome, GameState?, bool)> loadState({String? fileName}) async {
|
||||||
|
if (onLoad != null) {
|
||||||
|
return onLoad!(fileName);
|
||||||
|
}
|
||||||
|
return (const SaveOutcome.success(), null, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<SaveFileInfo>> listSaves() async => [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<SaveOutcome> deleteSave({String? fileName}) async {
|
||||||
|
return const SaveOutcome.success();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> saveExists({String? fileName}) async => false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 테스트용 팩토리 클래스
|
||||||
|
///
|
||||||
|
/// 테스트에서 자주 사용되는 객체 생성을 중앙화
|
||||||
|
class MockFactories {
|
||||||
|
MockFactories._();
|
||||||
|
|
||||||
|
/// 기본 PqConfig 생성
|
||||||
|
static const PqConfig config = PqConfig();
|
||||||
|
|
||||||
|
/// GameMutations 생성
|
||||||
|
static GameMutations createMutations([PqConfig? cfg]) {
|
||||||
|
return GameMutations(cfg ?? config);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ProgressService 생성
|
||||||
|
static ProgressService createProgressService([PqConfig? cfg]) {
|
||||||
|
final c = cfg ?? config;
|
||||||
|
final mutations = GameMutations(c);
|
||||||
|
return ProgressService(
|
||||||
|
config: c,
|
||||||
|
mutations: mutations,
|
||||||
|
rewards: RewardService(mutations, c),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 기본 GameState 생성
|
||||||
|
///
|
||||||
|
/// [seed]: 결정론적 랜덤 시드
|
||||||
|
/// [level]: 캐릭터 레벨
|
||||||
|
static GameState createGameState({
|
||||||
|
int seed = 42,
|
||||||
|
int level = 1,
|
||||||
|
}) {
|
||||||
|
return GameState.withSeed(seed: seed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 테스트용 CombatState 생성
|
||||||
|
static CombatState createCombat({
|
||||||
|
int playerHpMax = 100,
|
||||||
|
int playerHpCurrent = 100,
|
||||||
|
int monsterHpMax = 50,
|
||||||
|
int monsterHpCurrent = 50,
|
||||||
|
int monsterLevel = 1,
|
||||||
|
String monsterName = 'Test Monster',
|
||||||
|
}) {
|
||||||
|
final playerStats = CombatStats.empty().copyWith(
|
||||||
|
hpMax: playerHpMax,
|
||||||
|
hpCurrent: playerHpCurrent,
|
||||||
|
mpMax: 50,
|
||||||
|
mpCurrent: 50,
|
||||||
|
atk: 20,
|
||||||
|
def: 10,
|
||||||
|
attackDelayMs: 1000,
|
||||||
|
);
|
||||||
|
|
||||||
|
final monsterStats = MonsterCombatStats(
|
||||||
|
name: monsterName,
|
||||||
|
level: monsterLevel,
|
||||||
|
atk: 10,
|
||||||
|
def: 5,
|
||||||
|
hpMax: monsterHpMax,
|
||||||
|
hpCurrent: monsterHpCurrent,
|
||||||
|
criRate: 0.05,
|
||||||
|
criDamage: 1.5,
|
||||||
|
evasion: 0.0,
|
||||||
|
accuracy: 0.8,
|
||||||
|
attackDelayMs: 1000,
|
||||||
|
expReward: 100,
|
||||||
|
);
|
||||||
|
|
||||||
|
return CombatState(
|
||||||
|
playerStats: playerStats,
|
||||||
|
monsterStats: monsterStats,
|
||||||
|
playerAttackAccumulatorMs: 0,
|
||||||
|
monsterAttackAccumulatorMs: 0,
|
||||||
|
totalDamageDealt: 0,
|
||||||
|
totalDamageTaken: 0,
|
||||||
|
turnsElapsed: 0,
|
||||||
|
isActive: true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 테스트용 MonsterCombatStats 생성
|
||||||
|
static MonsterCombatStats createMonsterStats({
|
||||||
|
String name = 'Test Monster',
|
||||||
|
int level = 1,
|
||||||
|
int atk = 10,
|
||||||
|
int def = 5,
|
||||||
|
int hpMax = 100,
|
||||||
|
int? hpCurrent,
|
||||||
|
double criRate = 0.05,
|
||||||
|
double criDamage = 1.5,
|
||||||
|
double evasion = 0.0,
|
||||||
|
double accuracy = 0.8,
|
||||||
|
int attackDelayMs = 1000,
|
||||||
|
int expReward = 100,
|
||||||
|
}) {
|
||||||
|
return MonsterCombatStats(
|
||||||
|
name: name,
|
||||||
|
level: level,
|
||||||
|
atk: atk,
|
||||||
|
def: def,
|
||||||
|
hpMax: hpMax,
|
||||||
|
hpCurrent: hpCurrent ?? hpMax,
|
||||||
|
criRate: criRate,
|
||||||
|
criDamage: criDamage,
|
||||||
|
evasion: evasion,
|
||||||
|
accuracy: accuracy,
|
||||||
|
attackDelayMs: attackDelayMs,
|
||||||
|
expReward: expReward,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 밸런스 상수 기반 몬스터 스탯 생성
|
||||||
|
static MonsterCombatStats createBalancedMonsterStats({
|
||||||
|
required int level,
|
||||||
|
MonsterType type = MonsterType.normal,
|
||||||
|
}) {
|
||||||
|
final base = MonsterBaseStats.generate(level, type);
|
||||||
|
return MonsterCombatStats(
|
||||||
|
name: 'Balanced Monster Lv$level',
|
||||||
|
level: level,
|
||||||
|
atk: base.atk,
|
||||||
|
def: base.def,
|
||||||
|
hpMax: base.hp,
|
||||||
|
hpCurrent: base.hp,
|
||||||
|
criRate: 0.05,
|
||||||
|
criDamage: 1.5,
|
||||||
|
evasion: 0.0,
|
||||||
|
accuracy: 0.8,
|
||||||
|
attackDelayMs: 1000,
|
||||||
|
expReward: base.exp,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user