using System; using System.Collections; using System.Collections.Generic; using System.Reflection; using NUnit.Framework; using UnityEngine; using UnityEngine.SceneManagement; using UnityEngine.TestTools; using Unity.Netcode; using Colosseum.AI; using Colosseum.Enemy; using Colosseum.Player; using Colosseum.Skills; namespace Colosseum.Tests { /// /// 드로그 BT가 사양대로 분기되는지 검증하는 PlayMode 테스트입니다. /// public class DrogBehaviorTreeTests { private const string DrogSceneName = "Drog"; private const string PatternPunish = "밟기"; private const string PatternSignature = "집행"; private const string PatternGroundShake = "땅 울리기"; private const string PatternLeap = "도약"; private const string PatternThrow = "투척"; private const string PatternCombo = "콤보-강타"; private const string PatternPressure = "콤보-발구르기"; private const string PatternTertiary = "콤보-기본기3"; private const string PatternSecondary = "콤보-기본기2"; private const string PatternPrimary = "콤보-기본기1"; private const float DefaultTimeout = 12f; private const float LongPatternTimeout = 18f; private const float TransitionDelayBuffer = 8f; private BossEnemy boss; private BossBehaviorRuntimeState runtimeState; private SkillController bossSkillController; private Behaviour behaviorGraphAgent; private PlayerNetworkController player; private HitReactionController hitReactionController; private PlayerMovement playerMovement; private readonly List capturedLogs = new List(); [UnitySetUp] public IEnumerator SetUp() { Application.logMessageReceived += HandleLogMessage; yield return LoadScene(DrogSceneName); yield return WaitForCondition( () => NetworkManager.Singleton != null, 5f, "Drog 씬에서 NetworkManager를 찾지 못했습니다."); if (!NetworkManager.Singleton.IsListening) { Assert.IsTrue(NetworkManager.Singleton.StartHost(), "테스트용 호스트 시작에 실패했습니다."); } yield return WaitForCondition( () => NetworkManager.Singleton.IsListening && NetworkManager.Singleton.IsHost, 5f, "호스트가 시작되지 않았습니다."); yield return WaitForCondition( () => BossEnemy.ActiveBoss != null || UnityEngine.Object.FindFirstObjectByType() != null, 5f, "드로그 보스를 찾지 못했습니다."); yield return WaitForCondition( () => FindPrimaryPlayer() != null, 5f, "플레이어를 찾지 못했습니다."); boss = BossEnemy.ActiveBoss != null ? BossEnemy.ActiveBoss : UnityEngine.Object.FindFirstObjectByType(); runtimeState = boss != null ? boss.GetComponent() : null; bossSkillController = boss != null ? boss.GetComponent() : null; behaviorGraphAgent = boss != null ? boss.GetComponent("BehaviorGraphAgent") as Behaviour : null; player = FindPrimaryPlayer(); hitReactionController = player != null ? player.GetComponent() : null; playerMovement = player != null ? player.GetComponent() : null; Assert.NotNull(boss, "보스를 찾지 못했습니다."); Assert.NotNull(runtimeState, "BossBehaviorRuntimeState를 찾지 못했습니다."); Assert.NotNull(bossSkillController, "보스 SkillController를 찾지 못했습니다."); Assert.NotNull(behaviorGraphAgent, "BehaviorGraphAgent를 찾지 못했습니다."); Assert.NotNull(player, "플레이어를 찾지 못했습니다."); Assert.NotNull(hitReactionController, "플레이어 HitReactionController를 찾지 못했습니다."); Assert.NotNull(playerMovement, "플레이어 PlayerMovement를 찾지 못했습니다."); yield return ResetBossAndPlayerState(phase: 1, phaseElapsedTime: 0f, basicLoopCount: 0); } [UnityTearDown] public IEnumerator TearDown() { LogAssert.ignoreFailingMessages = true; if (behaviorGraphAgent != null) behaviorGraphAgent.enabled = false; if (bossSkillController != null) bossSkillController.CancelSkill(); if (boss != null && boss.TryGetComponent(out UnityEngine.AI.NavMeshAgent navMeshAgent)) navMeshAgent.enabled = false; if (NetworkManager.Singleton != null && NetworkManager.Singleton.IsListening) { NetworkManager.Singleton.Shutdown(); } yield return null; string activeSceneName = SceneManager.GetActiveScene().name; if (!string.IsNullOrEmpty(activeSceneName) && SceneManager.GetSceneByName(activeSceneName).isLoaded) { Scene cleanupScene = SceneManager.CreateScene($"DrogBehaviorTreeTests_Cleanup_{Guid.NewGuid():N}"); SceneManager.SetActiveScene(cleanupScene); AsyncOperation unloadOperation = SceneManager.UnloadSceneAsync(activeSceneName); if (unloadOperation != null) { yield return unloadOperation; } } Application.logMessageReceived -= HandleLogMessage; boss = null; runtimeState = null; bossSkillController = null; behaviorGraphAgent = null; player = null; hitReactionController = null; playerMovement = null; capturedLogs.Clear(); LogAssert.ignoreFailingMessages = false; } [UnityTest] public IEnumerator Phase2_HealthThreshold_TransitionsWithinDelay() { yield return ResetBossAndPlayerState(phase: 1, phaseElapsedTime: 0f, basicLoopCount: 0); SetBossHealthPercent(0.74f); ResumeBehavior(); yield return WaitForCondition( () => runtimeState.CurrentPatternPhase == 2, TransitionDelayBuffer, "HP 74%에서 Phase 2 전환이 발생하지 않았습니다."); } [UnityTest] public IEnumerator Phase3_Transition_LeadsIntoImmediateSignature() { yield return ResetBossAndPlayerState(phase: 2, phaseElapsedTime: 0f, basicLoopCount: 0); SetPlayerOffset(2.2f); SetBossHealthPercent(0.39f); ResumeBehavior(); yield return WaitForCondition( () => runtimeState.CurrentPatternPhase == 3, TransitionDelayBuffer, "HP 39%에서 Phase 3 전환이 발생하지 않았습니다."); yield return WaitForPatternLog( PatternSignature, TransitionDelayBuffer, "Phase 3 전환 직후 집행 패턴이 실행되지 않았습니다."); } [UnityTest] public IEnumerator Phase2_DownedNearbyTarget_PrioritizesPunish() { yield return ResetBossAndPlayerState(phase: 2, phaseElapsedTime: 0f, basicLoopCount: 0); SetPlayerOffset(1.6f); hitReactionController.ApplyDown(5f); yield return null; ResumeBehavior(); Assert.IsTrue(hitReactionController.IsDowned, "테스트용 다운 적용에 실패했습니다."); yield return WaitForPatternLog( PatternPunish, DefaultTimeout, "가까운 다운 대상이 있어도 밟기 패턴이 선택되지 않았습니다."); } [UnityTest] public IEnumerator Phase2_RecentReviveTrigger_PrioritizesThrow() { yield return ResetBossAndPlayerState(phase: 2, phaseElapsedTime: 0f, basicLoopCount: 0); SetPlayerOffset(4f); BossBehaviorRuntimeState.ReportPlayerRevivedBySkill(player.gameObject, player.gameObject); yield return null; ResumeBehavior(); Assert.IsTrue(runtimeState.HasRecentReviveTrigger(4f), "부활 트리거 기록이 유지되지 않았습니다."); yield return WaitForPatternLog( PatternThrow, DefaultTimeout, "최근 부활 트리거가 있어도 투척 패턴이 선택되지 않았습니다."); } [UnityTest] public IEnumerator Phase2_AfterNamedPatternInterval_UsesGroundShake() { yield return ResetBossAndPlayerState(phase: 2, phaseElapsedTime: 13f, basicLoopCount: 2); SetPlayerOffset(2.4f); ResumeBehavior(); yield return WaitForPatternLog( PatternGroundShake, DefaultTimeout, "Phase 2에서 주기 도래 후 땅 울리기 패턴이 실행되지 않았습니다."); } [UnityTest] [Ignore("원거리 9.26m에서도 콤보-강타가 먼저 실행되어 도약 분기 재현이 불안정합니다. 별도 조사 필요.")] public IEnumerator Phase2_FarTarget_PrioritizesLeap() { yield return ResetBossAndPlayerState(phase: 2, phaseElapsedTime: 0f, basicLoopCount: 2); SetPlayerOffset(9.5f); ResumeBehavior(); yield return WaitForPatternLog( PatternLeap, DefaultTimeout, "원거리 대상 상황에서 도약 패턴이 선택되지 않았습니다."); } [UnityTest] public IEnumerator Phase3_DoesNotUseGroundShakeAsIndependentPattern() { yield return ResetBossAndPlayerState(phase: 3, phaseElapsedTime: 13f, basicLoopCount: 2); SetPlayerOffset(2.4f); ResumeBehavior(); yield return new WaitForSeconds(4f); Assert.IsFalse( HasPatternLog(PatternGroundShake), "Phase 3에서 땅 울리기가 독립 네임드 패턴으로 실행되었습니다."); } [UnityTest] public IEnumerator Phase1_MeleeBaseline_StartsWithWeightedMeleePattern() { yield return ResetBossAndPlayerState(phase: 1, phaseElapsedTime: 0f, basicLoopCount: 0); SetPlayerOffset(2f); ResumeBehavior(); yield return WaitForAnyPatternLog( new[] { PatternCombo, PatternPressure, PatternTertiary, PatternSecondary, PatternPrimary }, DefaultTimeout, "근접 기본 루프에서 가중치 기본기 패턴이 실행되지 않았습니다."); } [UnityTest] public IEnumerator Phase1_MeleeWeightedSelector_SkipsPatternsOnCooldown() { yield return ResetBossAndPlayerState(phase: 1, phaseElapsedTime: 0f, basicLoopCount: 0); SetPlayerOffset(2f); SetPatternCooldown(PatternCombo); SetPatternCooldown(PatternPressure); SetPatternCooldown(PatternTertiary); SetPatternCooldown(PatternSecondary); ResumeBehavior(); yield return WaitForPatternLog( PatternPrimary, DefaultTimeout, "상위 기본기들이 쿨다운이어도 콤보-기본기1로 폴백하지 않았습니다."); } [UnityTest] public IEnumerator Phase1_AfterPatternEnds_WaitsForCommonPatternInterval() { const float commonPatternInterval = 1.2f; yield return ResetBossAndPlayerState(phase: 1, phaseElapsedTime: 0f, basicLoopCount: 0); SetRuntimeField("commonPatternInterval", commonPatternInterval); SetPlayerOffset(2f); ResumeBehavior(); yield return WaitForCondition( () => runtimeState.LastExecutedPattern != null && runtimeState.LastPatternExecutionResult == BossPatternExecutionResult.Running, DefaultTimeout, "첫 패턴 시작을 확인하지 못했습니다."); BossPatternData firstPattern = runtimeState.LastExecutedPattern; yield return WaitForCondition( () => runtimeState.LastPatternExecutionResult != BossPatternExecutionResult.Running, LongPatternTimeout, "첫 패턴 종료를 확인하지 못했습니다."); Assert.AreSame(firstPattern, runtimeState.LastExecutedPattern, "첫 패턴 종료 전에 다른 패턴이 시작되었습니다."); yield return new WaitForSeconds(commonPatternInterval * 0.5f); Assert.AreNotEqual( BossPatternExecutionResult.Running, runtimeState.LastPatternExecutionResult, "공통 텀 전에 다음 패턴이 시작되었습니다."); yield return WaitForCondition( () => runtimeState.LastPatternExecutionResult == BossPatternExecutionResult.Running, commonPatternInterval, "공통 텀 이후 다음 패턴이 다시 시작되지 않았습니다."); } [UnityTest] public IEnumerator Phase3_After90Seconds_ReusesSignature() { yield return ResetBossAndPlayerState(phase: 3, phaseElapsedTime: 91f, basicLoopCount: 2); SetPlayerOffset(2.2f); ResumeBehavior(); yield return WaitForPatternLog( PatternSignature, DefaultTimeout, "Phase 3에서 90초 경과 후 집행 재사용이 발생하지 않았습니다."); } [UnityTest] public IEnumerator Signature_WhenUnbroken_CompletesSuccessfully() { yield return ResetBossAndPlayerState(phase: 3, phaseElapsedTime: 91f, basicLoopCount: 2); SetPlayerOffset(2.2f); ResumeBehavior(); yield return WaitForPatternRunning( PatternSignature, DefaultTimeout, "집행 패턴 시작을 확인하지 못했습니다."); yield return WaitForLogContaining( "[UsePatternByRoleAction] 패턴 실행: 집행 / Step=2 / Skill=집행 연타1", LongPatternTimeout, "집행 성공 경로의 후속 연타 진입을 확인하지 못했습니다."); Assert.IsFalse(runtimeState.WasChargeBroken, "차단이 없는데 집행 차단 플래그가 켜졌습니다."); } [UnityTest] public IEnumerator Signature_WhenBroken_EndsAsFailedAndSetsChargeBroken() { yield return ResetBossAndPlayerState(phase: 3, phaseElapsedTime: 91f, basicLoopCount: 2); SetPlayerOffset(2.2f); ResumeBehavior(); yield return WaitForPatternRunning( PatternSignature, DefaultTimeout, "집행 패턴 시작을 확인하지 못했습니다."); yield return DealBossDamageDuringSignature(maxDuration: 3f, damagePerTickRatio: 0.02f); yield return WaitForCondition( () => runtimeState.LastExecutedPattern != null && runtimeState.LastExecutedPattern.PatternName == PatternSignature && runtimeState.LastPatternExecutionResult != BossPatternExecutionResult.Running, LongPatternTimeout, "집행 차단 후 결과 종료를 확인하지 못했습니다."); Assert.AreEqual( BossPatternExecutionResult.Failed, runtimeState.LastPatternExecutionResult, "집행 차단 시 결과가 Failed로 기록되지 않았습니다."); Assert.IsTrue(runtimeState.WasChargeBroken, "집행 차단 성공 플래그가 기록되지 않았습니다."); Assert.Greater(runtimeState.LastChargeStaggerDuration, 0f, "집행 차단 시 경직 시간이 기록되지 않았습니다."); } private IEnumerator ResetBossAndPlayerState(int phase, float phaseElapsedTime, int basicLoopCount) { behaviorGraphAgent.enabled = false; bossSkillController.CancelSkill(); yield return null; boss.Respawn(); yield return null; player.Respawn(); hitReactionController.ClearHitReactionState(); playerMovement.ClearForcedMovement(); playerMovement.enabled = false; yield return null; runtimeState.ResetPhaseState(); runtimeState.SetCurrentPatternPhase(phase, resetTimer: true); SetRuntimeField("currentPhaseStartTime", Time.time - Mathf.Max(0f, phaseElapsedTime)); SetRuntimeField("basicLoopCountSinceLastBigPattern", Mathf.Max(0, basicLoopCount)); SetRuntimeField("meleePatternCounter", 0); SetRuntimeField("lastPatternExecutionResult", BossPatternExecutionResult.None); SetRuntimeField("lastExecutedPattern", null); SetRuntimeField("lastReviveCaster", null); SetRuntimeField("lastRevivedTarget", null); SetRuntimeField("lastReviveEventTime", float.NegativeInfinity); ClearRuntimeDictionary("patternCooldownTracker"); ClearRuntimeDictionary("customPhaseConditions"); runtimeState.WasChargeBroken = false; runtimeState.LastChargeStaggerDuration = 0f; runtimeState.SetCurrentTarget(player.gameObject); SetPlayerOffset(2f); SetBossHealthPercent(1f); yield return null; ClearCapturedLogs(); } private IEnumerator DealBossDamageDuringSignature(float maxDuration, float damagePerTickRatio) { float maxHealth = boss.MaxHealth; float startTime = Time.time; yield return new WaitForSeconds(0.9f); while (Time.time - startTime <= maxDuration) { if (runtimeState.LastPatternExecutionResult != BossPatternExecutionResult.Running || runtimeState.LastExecutedPattern == null || runtimeState.LastExecutedPattern.PatternName != PatternSignature) { yield break; } float burst = maxHealth * Mathf.Clamp(damagePerTickRatio, 0.005f, 0.1f); boss.TakeDamage(burst); yield return new WaitForSeconds(0.1f); } } private IEnumerator LoadScene(string sceneName) { SceneManager.LoadScene(sceneName, LoadSceneMode.Single); yield return WaitForCondition( () => SceneManager.GetActiveScene().name == sceneName, 10f, $"씬 로드 실패: {sceneName}"); yield return null; } private IEnumerator WaitForPatternRunning(string patternName, float timeout, string failureMessage) { yield return WaitForCondition( () => runtimeState.LastExecutedPattern != null && runtimeState.LastExecutedPattern.PatternName == patternName && runtimeState.LastPatternExecutionResult == BossPatternExecutionResult.Running, timeout, failureMessage); } private IEnumerator WaitForPatternResolved(string patternName, float timeout, string failureMessage) { yield return WaitForCondition( () => runtimeState.LastExecutedPattern != null && runtimeState.LastExecutedPattern.PatternName == patternName && runtimeState.LastPatternExecutionResult != BossPatternExecutionResult.Running, timeout, failureMessage); } private IEnumerator WaitForPatternLog(string patternName, float timeout, string failureMessage) { yield return WaitForCondition( () => HasPatternLog(patternName), timeout, failureMessage); } private IEnumerator WaitForAnyPatternLog(IReadOnlyList patternNames, float timeout, string failureMessage) { yield return WaitForCondition( () => HasAnyPatternLog(patternNames), timeout, failureMessage); } private IEnumerator WaitForLogContaining(string marker, float timeout, string failureMessage) { yield return WaitForCondition( () => HasLogContaining(marker), timeout, failureMessage); } private IEnumerator WaitForCondition(Func predicate, float timeout, string failureMessage) { float startTime = Time.time; while (Time.time - startTime <= timeout) { if (predicate.Invoke()) yield break; yield return null; } Assert.Fail(failureMessage); } private PlayerNetworkController FindPrimaryPlayer() { PlayerNetworkController[] players = UnityEngine.Object.FindObjectsByType(FindObjectsSortMode.None); for (int i = 0; i < players.Length; i++) { if (players[i] != null) return players[i]; } return null; } private void SetPlayerOffset(float horizontalDistance) { Vector3 origin = boss.transform.position; Vector3 offset = boss.transform.forward; offset.y = 0f; if (offset.sqrMagnitude < 0.01f) offset = Vector3.forward; offset.Normalize(); Vector3 targetPosition = origin + (offset * horizontalDistance); targetPosition.y = origin.y; CharacterController characterController = player.GetComponent(); bool restoreCharacterController = characterController != null && characterController.enabled; if (restoreCharacterController) characterController.enabled = false; player.transform.position = targetPosition; player.transform.rotation = Quaternion.LookRotation((origin - targetPosition).normalized); if (restoreCharacterController) characterController.enabled = true; runtimeState.SetCurrentTarget(player.gameObject); } private void SetBossHealthPercent(float healthPercent) { float targetHealth = boss.MaxHealth * Mathf.Clamp01(healthPercent); float delta = boss.CurrentHealth - targetHealth; if (delta > 0f) { boss.TakeDamage(delta); } else if (delta < 0f) { boss.Heal(-delta); } } private void ClearRuntimeDictionary(string fieldName) { FieldInfo field = typeof(BossBehaviorRuntimeState).GetField(fieldName, BindingFlags.Instance | BindingFlags.NonPublic); Assert.NotNull(field, $"BossBehaviorRuntimeState 필드를 찾지 못했습니다: {fieldName}"); object dictionary = field.GetValue(runtimeState); MethodInfo clearMethod = dictionary?.GetType().GetMethod("Clear", BindingFlags.Instance | BindingFlags.Public); Assert.NotNull(clearMethod, $"딕셔너리 Clear 메서드를 찾지 못했습니다: {fieldName}"); clearMethod.Invoke(dictionary, null); } private void SetRuntimeField(string fieldName, object value) { FieldInfo field = typeof(BossBehaviorRuntimeState).GetField(fieldName, BindingFlags.Instance | BindingFlags.NonPublic); Assert.NotNull(field, $"BossBehaviorRuntimeState 필드를 찾지 못했습니다: {fieldName}"); field.SetValue(runtimeState, value); } private void ResumeBehavior() { ClearCapturedLogs(); behaviorGraphAgent.enabled = true; } private void ClearCapturedLogs() { capturedLogs.Clear(); } private bool HasPatternLog(string patternName) { string marker = $"패턴 실행: {patternName} /"; return HasLogContaining(marker); } private bool HasAnyPatternLog(IReadOnlyList patternNames) { if (patternNames == null) return false; for (int i = 0; i < patternNames.Count; i++) { if (HasPatternLog(patternNames[i])) return true; } return false; } private void SetPatternCooldown(string patternName) { BossPatternData pattern = FindPatternAsset(patternName); Assert.NotNull(pattern, $"패턴 에셋을 찾지 못했습니다: {patternName}"); runtimeState.SetPatternCooldown(pattern); } private BossPatternData FindPatternAsset(string patternName) { BossPatternData[] patterns = Resources.FindObjectsOfTypeAll(); for (int i = 0; i < patterns.Length; i++) { BossPatternData pattern = patterns[i]; if (pattern == null || pattern.PatternName != patternName) continue; return pattern; } return null; } private bool HasLogContaining(string marker) { for (int i = 0; i < capturedLogs.Count; i++) { if (capturedLogs[i].Contains(marker, StringComparison.Ordinal)) return true; } return false; } private void HandleLogMessage(string condition, string stackTrace, LogType type) { if (string.IsNullOrEmpty(condition)) return; capturedLogs.Add(condition); if (capturedLogs.Count > 512) capturedLogs.RemoveAt(0); } } }