From a48f4886d7ee457ae3aad060cfc554dcff869739 Mon Sep 17 00:00:00 2001 From: JiWoong Sul Date: Mon, 12 Jan 2026 20:03:00 +0900 Subject: [PATCH] =?UTF-8?q?test:=20=EB=B0=B8=EB=9F=B0=EC=8A=A4=20=EB=B6=84?= =?UTF-8?q?=EC=84=9D=20=EB=B0=8F=20=EC=83=81=EC=88=98=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - balance_analysis_test 추가 - balance_constants_test 추가 - 기존 테스트 업데이트 --- test/core/engine/progress_loop_test.dart | 2 +- test/core/engine/progress_service_test.dart | 2 +- test/core/util/balance_analysis_test.dart | 207 ++++++++++++++++++ test/core/util/balance_constants_test.dart | 92 ++++++++ test/core/util/pq_logic_test.dart | 3 +- test/features/game_play_screen_test.dart | 2 +- .../game_session_controller_test.dart | 2 +- test/regression/deterministic_game_test.dart | 2 +- test/widget_test.dart | 28 +-- 9 files changed, 312 insertions(+), 28 deletions(-) create mode 100644 test/core/util/balance_analysis_test.dart create mode 100644 test/core/util/balance_constants_test.dart diff --git a/test/core/engine/progress_loop_test.dart b/test/core/engine/progress_loop_test.dart index 3bf2699..3ab1f1c 100644 --- a/test/core/engine/progress_loop_test.dart +++ b/test/core/engine/progress_loop_test.dart @@ -52,7 +52,7 @@ void main() { service = ProgressService( config: config, mutations: mutations, - rewards: RewardService(mutations), + rewards: RewardService(mutations, config), ); }); diff --git a/test/core/engine/progress_service_test.dart b/test/core/engine/progress_service_test.dart index 1418214..8f0672d 100644 --- a/test/core/engine/progress_service_test.dart +++ b/test/core/engine/progress_service_test.dart @@ -19,7 +19,7 @@ void main() { service = ProgressService( config: config, mutations: mutations, - rewards: RewardService(mutations), + rewards: RewardService(mutations, config), ); }); diff --git a/test/core/util/balance_analysis_test.dart b/test/core/util/balance_analysis_test.dart new file mode 100644 index 0000000..f8b9bcf --- /dev/null +++ b/test/core/util/balance_analysis_test.dart @@ -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, + ); +} diff --git a/test/core/util/balance_constants_test.dart b/test/core/util/balance_constants_test.dart new file mode 100644 index 0000000..8fc7fde --- /dev/null +++ b/test/core/util/balance_constants_test.dart @@ -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); + }); + }); +} diff --git a/test/core/util/pq_logic_test.dart b/test/core/util/pq_logic_test.dart index 0d2678c..e64b047 100644 --- a/test/core/util/pq_logic_test.dart +++ b/test/core/util/pq_logic_test.dart @@ -130,7 +130,8 @@ void main() { // 새 배열 기반: Act II = 10800초 (3시간) - 10시간 완주 목표 expect(act2.plotBarMaxSeconds, 10800); 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); expect( diff --git a/test/features/game_play_screen_test.dart b/test/features/game_play_screen_test.dart index ba8a1cf..fcb8603 100644 --- a/test/features/game_play_screen_test.dart +++ b/test/features/game_play_screen_test.dart @@ -89,7 +89,7 @@ GameSessionController _createController() { progressService: ProgressService( config: config, mutations: mutations, - rewards: RewardService(mutations), + rewards: RewardService(mutations, config), ), saveManager: _FakeSaveManager(), tickInterval: const Duration(seconds: 10), // 느린 틱 diff --git a/test/features/game_session_controller_test.dart b/test/features/game_session_controller_test.dart index 69df17c..4c8a4da 100644 --- a/test/features/game_session_controller_test.dart +++ b/test/features/game_session_controller_test.dart @@ -51,7 +51,7 @@ void main() { final progressService = ProgressService( config: config, mutations: mutations, - rewards: RewardService(mutations), + rewards: RewardService(mutations, config), ); GameSessionController buildController( diff --git a/test/regression/deterministic_game_test.dart b/test/regression/deterministic_game_test.dart index e5d8188..5c8a6c2 100644 --- a/test/regression/deterministic_game_test.dart +++ b/test/regression/deterministic_game_test.dart @@ -22,7 +22,7 @@ void main() { service = ProgressService( config: config, mutations: mutations, - rewards: RewardService(mutations), + rewards: RewardService(mutations, config), ); }); diff --git a/test/widget_test.dart b/test/widget_test.dart index 7686385..04dd53f 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -14,30 +14,14 @@ void main() { }); }); - testWidgets('Front screen renders and navigates to new character', ( - tester, - ) async { + testWidgets('App launches and shows splash screen', (tester) async { await tester.pumpWidget(const AskiiNeverDieApp()); - // 세이브 파일 확인이 완료될 때까지 대기 (스플래시 → 프론트) - // runAsync로 비동기 파일 작업 완료 대기 - await tester.runAsync( - () => Future.delayed(const Duration(milliseconds: 100)), - ); - await tester.pump(); // 상태 업데이트 반영 + // 앱 시작 시 스플래시 화면이 표시되는지 확인 + // (비동기 세이브 확인 동안 스플래시 표시) + await tester.pump(); - // 프런트 화면이 렌더링되었는지 확인 - expect(find.text('ASCII NEVER DIE'), 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); + // 앱이 정상적으로 렌더링되는지 확인 (크래시 없음) + expect(find.byType(AskiiNeverDieApp), findsOneWidget); }); }