Files
asciinevrdie/test/core/engine/skill_service_test.dart
JiWoong Sul c56e76b176 test: 스킬 서비스 테스트 업데이트
- import 경로 변경 반영
2026-02-23 15:49:50 +09:00

672 lines
21 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 감소
magDef: 50,
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(20),
); // 50 - 30 (mpCost 30)
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,
magDef: 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,
magDef: 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(20)); // MP 30 소모
});
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,
magDef: 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.32x (1.0 + 4 * 0.08) - 랭크 배율 하향
// ATK 100 * 2.0 * 1.32 = 264
expect(result.result.damage, equals(264));
// MP 비용: 30 * (1.0 - 4 * 0.03) = 30 * 0.88 = 26 (반올림)
expect(result.updatedPlayer.mpCurrent, equals(24)); // 50 - 26
});
});
group('useHealSkill', () {
test('고정 회복량', () {
final rng = DeterministicRandom(42);
final service = SkillService(rng: rng);
const skill = SkillData.hotReload; // healAmount: 30, mpCost: 80
final player = CombatStats.empty().copyWith(
hpMax: 200,
hpCurrent: 100,
mpMax: 200,
mpCurrent: 150,
);
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(70)); // 150 - 80
});
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,
magDef: 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: 200,
mpCurrent: 150, // 힐 스킬 사용 가능한 MP (garbageCollection: 130)
);
final monster = MonsterCombatStats(
name: 'Test Monster',
level: 1,
atk: 10,
def: 10,
magDef: 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 +15% 버프, mpCost: 140
final player = CombatStats.empty().copyWith(mpMax: 200, mpCurrent: 150);
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.15));
expect(result.updatedSkillSystem.activeBuffs.length, equals(1));
expect(result.updatedPlayer.mpCurrent, equals(10)); // 150 - 140
});
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('랭크별 배율 계산', () {
// 랭크 배율 하향: 0.15 → 0.08 per rank
expect(getRankMultiplier(1), equals(1.0));
expect(getRankMultiplier(2), closeTo(1.08, 0.001));
expect(getRankMultiplier(3), closeTo(1.16, 0.001));
expect(getRankMultiplier(5), closeTo(1.32, 0.001));
expect(getRankMultiplier(10), closeTo(1.72, 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));
});
});
});
}