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);
}
}
}