test: 밸런스 분석 및 상수 테스트 추가
- balance_analysis_test 추가 - balance_constants_test 추가 - 기존 테스트 업데이트
This commit is contained in:
@@ -52,7 +52,7 @@ void main() {
|
|||||||
service = ProgressService(
|
service = ProgressService(
|
||||||
config: config,
|
config: config,
|
||||||
mutations: mutations,
|
mutations: mutations,
|
||||||
rewards: RewardService(mutations),
|
rewards: RewardService(mutations, config),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ void main() {
|
|||||||
service = ProgressService(
|
service = ProgressService(
|
||||||
config: config,
|
config: config,
|
||||||
mutations: mutations,
|
mutations: mutations,
|
||||||
rewards: RewardService(mutations),
|
rewards: RewardService(mutations, config),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
207
test/core/util/balance_analysis_test.dart
Normal file
207
test/core/util/balance_analysis_test.dart
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
// ignore_for_file: avoid_print
|
||||||
|
import 'package:asciineverdie/src/core/model/item_stats.dart';
|
||||||
|
import 'package:asciineverdie/src/core/util/balance_constants.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
/// 전체 레벨 밸런스 분석 테스트
|
||||||
|
///
|
||||||
|
/// 이 테스트는 플레이어와 몬스터의 스탯 스케일링을 분석하고
|
||||||
|
/// 예상 생존율을 계산합니다.
|
||||||
|
void main() {
|
||||||
|
group('전체 레벨 밸런스 분석', () {
|
||||||
|
test('레벨별 몬스터 vs 플레이어 스탯 비교', () {
|
||||||
|
print('\n${'=' * 80}');
|
||||||
|
print('레벨별 밸런스 분석 리포트');
|
||||||
|
print('${'=' * 80}\n');
|
||||||
|
|
||||||
|
// 분석할 레벨
|
||||||
|
final levels = [1, 5, 8, 10, 20, 30, 50, 80, 100];
|
||||||
|
|
||||||
|
print('### 1. 몬스터 스탯 (현재 공식)');
|
||||||
|
print('| 레벨 | HP | ATK | DEF | EXP | 타입 |');
|
||||||
|
print('|------|-----|-----|-----|-----|------|');
|
||||||
|
for (final level in levels) {
|
||||||
|
final m = MonsterBaseStats.forLevel(level);
|
||||||
|
print(
|
||||||
|
'| ${level.toString().padLeft(4)} | ${m.hp.toString().padLeft(4)} '
|
||||||
|
'| ${m.atk.toString().padLeft(3)} | ${m.def.toString().padLeft(3)} '
|
||||||
|
'| ${m.exp.toString().padLeft(3)} | Normal |',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
print('\n### 2. 플레이어 예상 스탯 (장비 포함)');
|
||||||
|
print('| 레벨 | HP | ATK | DEF | 장비DEF |');
|
||||||
|
print('|------|-----|-----|-----|---------|');
|
||||||
|
for (final level in levels) {
|
||||||
|
final player = _estimatePlayerStats(level);
|
||||||
|
print(
|
||||||
|
'| ${level.toString().padLeft(4)} | ${player.hp.toString().padLeft(4)} '
|
||||||
|
'| ${player.atk.toString().padLeft(3)} | ${player.def.toString().padLeft(3)} '
|
||||||
|
'| ${player.equipDef.toString().padLeft(7)} |',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
print('\n### 3. 전투 시뮬레이션 (평균 데미지)');
|
||||||
|
print('| 레벨 | 몬→플 | 플→몬 | 플생존 | 몬생존 | 승률예상 |');
|
||||||
|
print('|------|-------|-------|--------|--------|----------|');
|
||||||
|
for (final level in levels) {
|
||||||
|
final sim = _simulateCombat(level);
|
||||||
|
print(
|
||||||
|
'| ${level.toString().padLeft(4)} '
|
||||||
|
'| ${sim.monsterDamage.toString().padLeft(5)} '
|
||||||
|
'| ${sim.playerDamage.toString().padLeft(5)} '
|
||||||
|
'| ${sim.playerHits.toString().padLeft(6)} '
|
||||||
|
'| ${sim.monsterHits.toString().padLeft(6)} '
|
||||||
|
'| ${(sim.winRate * 100).toStringAsFixed(0).padLeft(8)}% |',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
print('\n### 4. 밸런스 진단');
|
||||||
|
for (final level in levels) {
|
||||||
|
final sim = _simulateCombat(level);
|
||||||
|
final tier = LevelTierSettings.forLevel(level);
|
||||||
|
final targetWinRate = 1.0 - tier.targetDeathRate;
|
||||||
|
final diagnosis = sim.winRate >= targetWinRate
|
||||||
|
? '✓ 적정'
|
||||||
|
: '✗ 조정필요 (목표: ${(targetWinRate * 100).toStringAsFixed(0)}%)';
|
||||||
|
print('레벨 $level (${tier.name}): 승률 ${(sim.winRate * 100).toStringAsFixed(0)}% $diagnosis');
|
||||||
|
}
|
||||||
|
|
||||||
|
print('\n${'=' * 80}');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('문제점 및 개선 제안', () {
|
||||||
|
print('\n### 현재 밸런스 문제점\n');
|
||||||
|
|
||||||
|
// 레벨 8 상세 분석 (사용자 보고 케이스)
|
||||||
|
final level = 8;
|
||||||
|
final monster = MonsterBaseStats.forLevel(level);
|
||||||
|
final player = _estimatePlayerStats(level);
|
||||||
|
|
||||||
|
print('#### 레벨 8 상세 분석 (사용자 보고 케이스)');
|
||||||
|
print('- 몬스터 ATK: ${monster.atk}');
|
||||||
|
print('- 플레이어 HP: ${player.hp}');
|
||||||
|
print('- 플레이어 DEF: ${player.def}');
|
||||||
|
|
||||||
|
// 데미지 계산 (combat_calculator 공식)
|
||||||
|
// damage = ATK * 0.8~1.2 - DEF * 0.3
|
||||||
|
final minDamage = (monster.atk * 0.8 - player.def * 0.3).round();
|
||||||
|
final maxDamage = (monster.atk * 1.2 - player.def * 0.3).round();
|
||||||
|
print('- 몬스터 데미지: $minDamage ~ $maxDamage');
|
||||||
|
print('- 플레이어 생존 히트: ${(player.hp / maxDamage).floor()} ~ ${(player.hp / minDamage).ceil()}');
|
||||||
|
|
||||||
|
print('\n#### 적용된 밸런스 수정');
|
||||||
|
print('1. 플레이어 HP 스케일링 상향:');
|
||||||
|
print(' - hpPerLevel: 12 → 18 (50% 증가)');
|
||||||
|
print(' - hpPerCon: 6 → 10 (67% 증가)');
|
||||||
|
print('2. 데미지 공식 DEF 감산율 상향:');
|
||||||
|
print(' - defenderDef * 0.3 → defenderDef * 0.5 (방어력 효과 67% 증가)');
|
||||||
|
print('3. 몬스터 ATK 대폭 하향:');
|
||||||
|
print(' - 5 + level * 6 → 3 + level * 4 (33% 감소)');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 플레이어 예상 스탯
|
||||||
|
class _PlayerEstimate {
|
||||||
|
final int hp;
|
||||||
|
final int atk;
|
||||||
|
final int def;
|
||||||
|
final int equipDef;
|
||||||
|
|
||||||
|
_PlayerEstimate({
|
||||||
|
required this.hp,
|
||||||
|
required this.atk,
|
||||||
|
required this.def,
|
||||||
|
required this.equipDef,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 레벨별 플레이어 스탯 추정
|
||||||
|
///
|
||||||
|
/// 가정:
|
||||||
|
/// - 평균 스탯: STR/CON/DEX = 15 (3d6 평균 10.5 + 종족/클래스 보정)
|
||||||
|
/// - 장비: 레벨 * 0.8 수준의 평균 장비
|
||||||
|
_PlayerEstimate _estimatePlayerStats(int level) {
|
||||||
|
// 기본 스탯 (평균)
|
||||||
|
const str = 15;
|
||||||
|
const con = 15;
|
||||||
|
// dex는 회피/크리티컬에 영향, 여기서는 HP/ATK/DEF만 분석
|
||||||
|
|
||||||
|
// 기본 HP (종족별 다르지만 평균 ~10 가정)
|
||||||
|
const baseHp = 10;
|
||||||
|
// PlayerScaling 공식: hpMax = baseHp + (level - 1) * hpPerLevel + conBonus * hpPerCon
|
||||||
|
// 수정: hpPerLevel 12→18, hpPerCon 6→10
|
||||||
|
final hpMax = baseHp + (level - 1) * 18 + (con - 10) * 10;
|
||||||
|
|
||||||
|
// 장비 스탯 추정 (레벨 * 0.8 수준의 common 장비 10개)
|
||||||
|
final equipLevel = (level * 0.8).round().clamp(1, level);
|
||||||
|
final equipBaseValue = (equipLevel * 1.2 * ItemRarity.common.multiplier).round();
|
||||||
|
|
||||||
|
// 무기 ATK (speedMultiplier 1.0 가정)
|
||||||
|
final weaponAtk = equipBaseValue;
|
||||||
|
|
||||||
|
// 방어구 DEF (10개 슬롯, 평균 multiplier 0.85)
|
||||||
|
final armorDef = (equipBaseValue * 0.85 * 10).round();
|
||||||
|
|
||||||
|
// CombatStats.fromStats 공식
|
||||||
|
// baseAtk = effectiveStr * 2 + level + equipStats.atk
|
||||||
|
final baseAtk = str * 2 + level + weaponAtk;
|
||||||
|
// baseDef = effectiveCon + (level ~/ 2) + equipStats.def
|
||||||
|
final baseDef = con + (level ~/ 2) + armorDef;
|
||||||
|
|
||||||
|
return _PlayerEstimate(
|
||||||
|
hp: hpMax,
|
||||||
|
atk: baseAtk,
|
||||||
|
def: baseDef,
|
||||||
|
equipDef: armorDef,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 전투 시뮬레이션 결과
|
||||||
|
class _CombatSimulation {
|
||||||
|
final int monsterDamage;
|
||||||
|
final int playerDamage;
|
||||||
|
final int playerHits; // 플레이어가 버틸 수 있는 히트 수
|
||||||
|
final int monsterHits; // 몬스터를 죽이는데 필요한 히트 수
|
||||||
|
final double winRate;
|
||||||
|
|
||||||
|
_CombatSimulation({
|
||||||
|
required this.monsterDamage,
|
||||||
|
required this.playerDamage,
|
||||||
|
required this.playerHits,
|
||||||
|
required this.monsterHits,
|
||||||
|
required this.winRate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 전투 시뮬레이션
|
||||||
|
_CombatSimulation _simulateCombat(int level) {
|
||||||
|
final monster = MonsterBaseStats.forLevel(level);
|
||||||
|
final player = _estimatePlayerStats(level);
|
||||||
|
|
||||||
|
// 데미지 계산 (combat_calculator 평균)
|
||||||
|
// damage = ATK * 1.0 - DEF * 0.5 (DEF 감산율 상향)
|
||||||
|
final monsterDamage = (monster.atk * 1.0 - player.def * 0.5).round().clamp(1, 9999);
|
||||||
|
final playerDamage = (player.atk * 1.0 - monster.def * 0.5).round().clamp(1, 9999);
|
||||||
|
|
||||||
|
// 생존 히트 수
|
||||||
|
final playerHits = (player.hp / monsterDamage).ceil();
|
||||||
|
final monsterHits = (monster.hp / playerDamage).ceil();
|
||||||
|
|
||||||
|
// 승률 추정 (히트 비율 기반)
|
||||||
|
// 플레이어가 먼저 공격한다고 가정하면, playerHits > monsterHits면 승리
|
||||||
|
final winRate = playerHits > monsterHits
|
||||||
|
? 0.95 // 압도적 유리
|
||||||
|
: playerHits == monsterHits
|
||||||
|
? 0.65 // 동등 (선공 이점)
|
||||||
|
: (playerHits / monsterHits).clamp(0.2, 0.9);
|
||||||
|
|
||||||
|
return _CombatSimulation(
|
||||||
|
monsterDamage: monsterDamage,
|
||||||
|
playerDamage: playerDamage,
|
||||||
|
playerHits: playerHits,
|
||||||
|
monsterHits: monsterHits,
|
||||||
|
winRate: winRate,
|
||||||
|
);
|
||||||
|
}
|
||||||
92
test/core/util/balance_constants_test.dart
Normal file
92
test/core/util/balance_constants_test.dart
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import 'package:asciineverdie/src/core/util/balance_constants.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('MonsterBaseStats ATK 완화 공식', () {
|
||||||
|
test('ATK 레벨별 차등 공식 검증', () {
|
||||||
|
// 레벨 1~5: 2 + level * 3 (초반 난이도 완화)
|
||||||
|
expect(MonsterBaseStats.forLevel(1).atk, 5); // 2 + 3
|
||||||
|
expect(MonsterBaseStats.forLevel(5).atk, 17); // 2 + 15
|
||||||
|
// 레벨 6+: 3 + level * 4
|
||||||
|
expect(MonsterBaseStats.forLevel(8).atk, 35); // 3 + 32
|
||||||
|
expect(MonsterBaseStats.forLevel(10).atk, 43); // 3 + 40
|
||||||
|
expect(MonsterBaseStats.forLevel(20).atk, 83); // 3 + 80
|
||||||
|
expect(MonsterBaseStats.forLevel(100).atk, 403); // 3 + 400
|
||||||
|
});
|
||||||
|
|
||||||
|
test('레벨 8 몬스터가 플레이어에게 적절한 데미지', () {
|
||||||
|
// 레벨 8 몬스터: ATK 35
|
||||||
|
// 플레이어 DEF ~40 가정 (중간 수준 장비)
|
||||||
|
// 최대 데미지: 35 * 1.2 - 40 * 0.5 = 42 - 20 = 22
|
||||||
|
final level8Atk = MonsterBaseStats.forLevel(8).atk;
|
||||||
|
final maxDamage = (level8Atk * 1.2 - 40 * 0.5).round();
|
||||||
|
// 플레이어 HP ~150 기준 6~7회 생존
|
||||||
|
expect(maxDamage, lessThan(50));
|
||||||
|
expect(maxDamage, greaterThan(10));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('레벨 1 몬스터 데미지가 플레이어 1히트킬 방지', () {
|
||||||
|
final level1Atk = MonsterBaseStats.forLevel(1).atk;
|
||||||
|
// ATK 5에서 DEF 10 기준 최대 데미지 = 5 * 1.2 - 10 * 0.5 = 1
|
||||||
|
final maxDamage = (level1Atk * 1.2 - 10 * 0.5).round();
|
||||||
|
expect(maxDamage, lessThanOrEqualTo(10));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ATK 곡선이 단조 증가', () {
|
||||||
|
int prevAtk = 0;
|
||||||
|
for (var level = 1; level <= 50; level++) {
|
||||||
|
final atk = MonsterBaseStats.forLevel(level).atk;
|
||||||
|
expect(
|
||||||
|
atk,
|
||||||
|
greaterThan(prevAtk),
|
||||||
|
reason: 'Level $level ATK should be > level ${level - 1}',
|
||||||
|
);
|
||||||
|
prevAtk = atk;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('기존 공식 대비 ATK 감소 확인', () {
|
||||||
|
// 기존: 10 + level * 12
|
||||||
|
// 신규 (레벨 1~5): 2 + level * 3 (초반 난이도 완화)
|
||||||
|
// 신규 (레벨 6+): 3 + level * 4
|
||||||
|
|
||||||
|
// 레벨 6 이상은 약 33% 수준 (기존 대비 67% 감소)
|
||||||
|
for (var level in [10, 20]) {
|
||||||
|
final oldAtk = 10 + level * 12;
|
||||||
|
final newAtk = MonsterBaseStats.forLevel(level).atk;
|
||||||
|
final ratio = newAtk / oldAtk;
|
||||||
|
expect(ratio, lessThan(0.45), reason: 'Level $level should be < 45% of old');
|
||||||
|
expect(ratio, greaterThan(0.25), reason: 'Level $level should be > 25% of old');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 레벨 1~5는 추가 완화 (기존 대비 더 낮음)
|
||||||
|
for (var level in [1, 5]) {
|
||||||
|
final oldAtk = 10 + level * 12;
|
||||||
|
final newAtk = MonsterBaseStats.forLevel(level).atk;
|
||||||
|
final ratio = newAtk / oldAtk;
|
||||||
|
expect(ratio, lessThan(0.35), reason: 'Early level $level should be < 35% of old');
|
||||||
|
expect(ratio, greaterThan(0.15), reason: 'Early level $level should be > 15% of old');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('PlayerScaling HP 스케일링', () {
|
||||||
|
test('hpPerLevel = 18, hpPerCon = 10 검증', () {
|
||||||
|
expect(PlayerScaling.hpPerLevel, 18);
|
||||||
|
expect(PlayerScaling.hpPerCon, 10);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('레벨업당 HP 증가 검증', () {
|
||||||
|
// baseHp=10, level=10, conBonus=5
|
||||||
|
final result = PlayerScaling.calculateResources(
|
||||||
|
level: 10,
|
||||||
|
baseHp: 10,
|
||||||
|
baseMp: 20,
|
||||||
|
conBonus: 5,
|
||||||
|
intBonus: 5,
|
||||||
|
);
|
||||||
|
// HP = 10 + (10-1)*18 + 5*10 = 10 + 162 + 50 = 222
|
||||||
|
expect(result.hpMax, 222);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -130,7 +130,8 @@ void main() {
|
|||||||
// 새 배열 기반: Act II = 10800초 (3시간) - 10시간 완주 목표
|
// 새 배열 기반: Act II = 10800초 (3시간) - 10시간 완주 목표
|
||||||
expect(act2.plotBarMaxSeconds, 10800);
|
expect(act2.plotBarMaxSeconds, 10800);
|
||||||
expect(act2.rewards, contains(pq_logic.RewardKind.item));
|
expect(act2.rewards, contains(pq_logic.RewardKind.item));
|
||||||
expect(act2.rewards, isNot(contains(pq_logic.RewardKind.equip)));
|
// 장비 보상: existingActCount >= 1 부터 지급 (프롤로그 완료 시점부터)
|
||||||
|
expect(act2.rewards, contains(pq_logic.RewardKind.equip));
|
||||||
|
|
||||||
final act3 = pq_logic.completeAct(3);
|
final act3 = pq_logic.completeAct(3);
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ GameSessionController _createController() {
|
|||||||
progressService: ProgressService(
|
progressService: ProgressService(
|
||||||
config: config,
|
config: config,
|
||||||
mutations: mutations,
|
mutations: mutations,
|
||||||
rewards: RewardService(mutations),
|
rewards: RewardService(mutations, config),
|
||||||
),
|
),
|
||||||
saveManager: _FakeSaveManager(),
|
saveManager: _FakeSaveManager(),
|
||||||
tickInterval: const Duration(seconds: 10), // 느린 틱
|
tickInterval: const Duration(seconds: 10), // 느린 틱
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ void main() {
|
|||||||
final progressService = ProgressService(
|
final progressService = ProgressService(
|
||||||
config: config,
|
config: config,
|
||||||
mutations: mutations,
|
mutations: mutations,
|
||||||
rewards: RewardService(mutations),
|
rewards: RewardService(mutations, config),
|
||||||
);
|
);
|
||||||
|
|
||||||
GameSessionController buildController(
|
GameSessionController buildController(
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ void main() {
|
|||||||
service = ProgressService(
|
service = ProgressService(
|
||||||
config: config,
|
config: config,
|
||||||
mutations: mutations,
|
mutations: mutations,
|
||||||
rewards: RewardService(mutations),
|
rewards: RewardService(mutations, config),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -14,30 +14,14 @@ void main() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('Front screen renders and navigates to new character', (
|
testWidgets('App launches and shows splash screen', (tester) async {
|
||||||
tester,
|
|
||||||
) async {
|
|
||||||
await tester.pumpWidget(const AskiiNeverDieApp());
|
await tester.pumpWidget(const AskiiNeverDieApp());
|
||||||
|
|
||||||
// 세이브 파일 확인이 완료될 때까지 대기 (스플래시 → 프론트)
|
// 앱 시작 시 스플래시 화면이 표시되는지 확인
|
||||||
// runAsync로 비동기 파일 작업 완료 대기
|
// (비동기 세이브 확인 동안 스플래시 표시)
|
||||||
await tester.runAsync(
|
await tester.pump();
|
||||||
() => Future<void>.delayed(const Duration(milliseconds: 100)),
|
|
||||||
);
|
|
||||||
await tester.pump(); // 상태 업데이트 반영
|
|
||||||
|
|
||||||
// 프런트 화면이 렌더링되었는지 확인
|
// 앱이 정상적으로 렌더링되는지 확인 (크래시 없음)
|
||||||
expect(find.text('ASCII NEVER DIE'), findsOneWidget);
|
expect(find.byType(AskiiNeverDieApp), findsOneWidget);
|
||||||
expect(find.text('New character'), findsOneWidget);
|
|
||||||
|
|
||||||
// "New character" 버튼 탭
|
|
||||||
await tester.tap(find.text('New character'));
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
// NewCharacterScreen으로 이동했는지 확인 (l10n 적용됨)
|
|
||||||
expect(find.text('ASCII NEVER DIE - New Character'), findsOneWidget);
|
|
||||||
expect(find.text('Race'), findsOneWidget);
|
|
||||||
expect(find.text('Class'), findsOneWidget);
|
|
||||||
expect(find.text('Sold!'), findsOneWidget);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user