using System; using System.Collections; using System.Collections.Generic; using System.Reflection; using BumpCombat.Core; using BumpCombat.Enemies; using BumpCombat.Player; using BumpCombat.Progression; using BumpCombat.Spawning; using NUnit.Framework; using UnityEngine; using UnityEngine.SceneManagement; using UnityEngine.TestTools; namespace BumpCombat.PlayModeTests { public sealed class NecromancerSummonPlayModeRegressionTests { private const float ExpectedSummonSpawnRadius = 2.5f; private const float ExpectedSummonSpawnMinimumSeparation = 1.5f; private const float ExpectedSummonPlayerClearance = 1.25f; private static readonly BindingFlags NonPublicInstance = BindingFlags.Instance | BindingFlags.NonPublic; private bool previousGrantCatalogForTests; private bool previousProductionModeForTests; private float previousTimeScale; [SetUp] public void SetUp() { previousGrantCatalogForTests = ArtifactRewardController.GrantCatalogForTests; previousProductionModeForTests = RunManager.ForceProductionModeForTests; previousTimeScale = Time.timeScale; ArtifactRewardController.GrantCatalogForTests = true; RunManager.ForceProductionModeForTests = false; Time.timeScale = 1f; } [TearDown] public void TearDown() { ArtifactRewardController.GrantCatalogForTests = previousGrantCatalogForTests; RunManager.ForceProductionModeForTests = previousProductionModeForTests; Time.timeScale = previousTimeScale; } [UnityTest] public IEnumerator AmbientSummonsWaitForWarningActiveAndRecoveryToFinish() { yield return LoadCombatScene(); SpawnDirector director = UnityEngine.Object.FindAnyObjectByType(); PlayerController player = UnityEngine.Object.FindAnyObjectByType(); Assert.That(director, Is.Not.Null); Assert.That(player, Is.Not.Null); director.enabled = false; player.GetComponent().GrantInvulnerability(60f); EnemyController boss = UnityEngine.Object.Instantiate( FindCatalogPrefab(director, EnemyKind.Necromancer), player.transform.position + Vector3.right * 7f, Quaternion.identity); yield return WaitForReady(boss); NecromancerBossController necromancer = boss.GetComponent(); Assert.That(necromancer, Is.Not.Null); float generalDueTime = Time.time - 0.1f; float crowdDueTime = Time.time - 0.1f; SetPrivateField(necromancer, "nextGeneralSummonTime", generalDueTime); SetPrivateField(necromancer, "nextCrowdSummonTime", crowdDueTime); MethodInfo enterState = typeof(EnemyController).GetMethod( "EnterState", NonPublicInstance); MethodInfo update = typeof(NecromancerBossController).GetMethod( "Update", NonPublicInstance); Assert.That(enterState, Is.Not.Null); Assert.That(update, Is.Not.Null); foreach (EnemyState attackState in new[] { EnemyState.Warning, EnemyState.Active, EnemyState.Recovery, }) { enterState.Invoke(boss, new object[] { attackState, 5f }); Assert.That(boss.IsAttackSequenceInProgress, Is.True); update.Invoke(necromancer, null); Assert.That(boss.State, Is.EqualTo(attackState)); Assert.That(GetPrivateFloat(necromancer, "nextGeneralSummonTime"), Is.EqualTo(generalDueTime)); Assert.That(GetPrivateFloat(necromancer, "nextCrowdSummonTime"), Is.EqualTo(crowdDueTime)); Assert.That(CountOwnedSummons(boss), Is.Zero); yield return null; } enterState.Invoke(boss, new object[] { EnemyState.Chase, 0f }); update.Invoke(necromancer, null); Assert.That(CountOwnedSummons(boss), Is.EqualTo(1), "A due general summon should start once the attack sequence is safe."); float firstSummonStartedAt = Time.time; Assert.That(GetPrivateFloat(necromancer, "nextGeneralSummonTime"), Is.GreaterThan(Time.time)); Assert.That(GetPrivateFloat(necromancer, "nextCrowdSummonTime"), Is.LessThanOrEqualTo(Time.time), "The other due timer must remain pending after a successful summon."); Assert.That(boss.GetSummonAnimationDuration(), Is.EqualTo(1.25f).Within(0.005f)); Assert.That(GetPrivateFloat(necromancer, "summonLockUntil") - firstSummonStartedAt, Is.EqualTo(1.25f).Within(0.03f), "The caster's movement lock must cover the full Summon clip."); update.Invoke(necromancer, null); Assert.That(CountOwnedSummons(boss), Is.EqualTo(1), "A single Update must not start both due ambient summon types."); yield return new WaitForSeconds(1f); Assert.That(CountOwnedSummons(boss), Is.EqualTo(1), "The due crowd summon must stay pending during the 1.25-second caster clip."); Assert.That(boss.GetComponent() .GetCurrentAnimatorStateInfo(0).IsName("Summon"), Is.True, "The caster must still show its Summon animation at one second."); float secondSummonDeadline = Time.realtimeSinceStartup + 3f; while (Time.realtimeSinceStartup < secondSummonDeadline && CountOwnedSummons(boss) < 4) { yield return null; } Assert.That(CountOwnedSummons(boss), Is.EqualTo(4), "The deferred crowd summon should start after the first summon lock ends."); } [UnityTest] public IEnumerator SummonedRiseClipsReachTheirLastSpriteBeforeChase() { yield return LoadCombatScene(); SpawnDirector director = UnityEngine.Object.FindAnyObjectByType(); PlayerController player = UnityEngine.Object.FindAnyObjectByType(); Assert.That(director, Is.Not.Null); Assert.That(player, Is.Not.Null); director.enabled = false; player.GetComponent().GrantInvulnerability(60f); EnemyKind[] kinds = { EnemyKind.Bat, EnemyKind.Slime, EnemyKind.Skeleton, EnemyKind.GreatswordSkeleton, EnemyKind.ArmoredSkeleton, EnemyKind.SkeletonArcher, EnemyKind.Necrofire, EnemyKind.Werewolf, EnemyKind.Werebear, EnemyKind.NecroGolem, }; EnemyController[] summoned = new EnemyController[kinds.Length]; Animator[] animators = new Animator[kinds.Length]; SpriteRenderer[] renderers = new SpriteRenderer[kinds.Length]; AnimationClip[] summonClips = new AnimationClip[kinds.Length]; float[] expectedDurations = new float[kinds.Length]; Sprite[] expectedFirstSprites = new Sprite[kinds.Length]; Sprite[] expectedFinalSprites = new Sprite[kinds.Length]; bool[] sawFinalSpriteDuringSpawn = new bool[kinds.Length]; for (int i = 0; i < kinds.Length; i++) { EnemyController prefab = FindCatalogPrefab(director, kinds[i]); Animator prefabAnimator = prefab.GetComponent(); AnimationClip summonClip = FindSummonClip(prefabAnimator); Assert.That(summonClip, Is.Not.Null, $"{kinds[i]} must have a dedicated Summon clip."); expectedDurations[i] = ExpectedSummonDuration(kinds[i]); Assert.That(summonClip.length, Is.EqualTo(expectedDurations[i]).Within(0.005f)); summonClips[i] = summonClip; expectedFirstSprites[i] = SampleSprite(summonClip, 0f); expectedFinalSprites[i] = SampleSprite( summonClip, summonClip.length - 0.001f); summoned[i] = UnityEngine.Object.Instantiate( prefab, player.transform.position + new Vector3(1.5f + i, 2f, 0f), Quaternion.identity); summoned[i].ConfigureSummonedEnemy( null, CreateNeutralTuning(prefab), i % 2 == 0); animators[i] = summoned[i].GetComponent(); renderers[i] = summoned[i].GetComponent(); } yield return null; yield return null; for (int i = 0; i < summoned.Length; i++) { Assert.That(summoned[i].MaximumHealth, Is.GreaterThan(0f)); Assert.That(summoned[i].State, Is.EqualTo(EnemyState.Spawn)); Assert.That(summoned[i].IsPhaseSummon, Is.EqualTo(i % 2 == 0)); Assert.That(summoned[i].StateDuration, Is.EqualTo(expectedDurations[i]).Within(0.005f)); Assert.That(summoned[i].GetSummonAnimationDuration(), Is.EqualTo(expectedDurations[i]).Within(0.005f)); Assert.That(animators[i].GetCurrentAnimatorStateInfo(0).IsName("Summon"), Is.True, DescribeAnimator(kinds[i], animators[i], renderers[i], "did not enter its rise animation")); } yield return new WaitForSeconds(0.3f); for (int i = 0; i < summoned.Length; i++) { Assert.That(summoned[i].State, Is.EqualTo(EnemyState.Spawn)); Assert.That(animators[i].GetCurrentAnimatorStateInfo(0).IsName("Summon"), Is.True, DescribeAnimator(kinds[i], animators[i], renderers[i], "left the rise animation before its clip completed")); AnimatorStateInfo state = animators[i].GetCurrentAnimatorStateInfo(0); float elapsed = Mathf.Min( state.normalizedTime * summonClips[i].length, summonClips[i].length - 0.001f); Sprite expectedCurrentSprite = SampleSprite(summonClips[i], elapsed); Assert.That(renderers[i].sprite, Is.SameAs(expectedCurrentSprite), $"{kinds[i]} sprite did not match the sampled rise frame."); Assert.That(renderers[i].sprite, Is.Not.SameAs(expectedFirstSprites[i]), $"{kinds[i]} did not advance beyond the first rise frame."); Assert.That(renderers[i].sprite, Is.Not.SameAs(expectedFinalSprites[i]), $"{kinds[i]} must keep rising until the clip completes."); } float finalFrameDeadline = Time.realtimeSinceStartup + 1.5f; while (Time.realtimeSinceStartup < finalFrameDeadline && !AllTrue(sawFinalSpriteDuringSpawn)) { for (int i = 0; i < summoned.Length; i++) { if (summoned[i].State == EnemyState.Spawn && renderers[i].sprite == expectedFinalSprites[i]) { sawFinalSpriteDuringSpawn[i] = true; } } yield return null; } for (int i = 0; i < summoned.Length; i++) { Assert.That(sawFinalSpriteDuringSpawn[i], Is.True, $"{kinds[i]} must display its final rise sprite before Chase."); } float chaseDeadline = Time.realtimeSinceStartup + 1f; while (Time.realtimeSinceStartup < chaseDeadline && Array.Exists(summoned, enemy => enemy.State == EnemyState.Spawn)) { yield return null; } for (int i = 0; i < summoned.Length; i++) { Assert.That(summoned[i].State, Is.Not.EqualTo(EnemyState.Spawn), $"{kinds[i]} should resume normal AI only after the rise clip."); UnityEngine.Object.Destroy(summoned[i].gameObject); } EnemyController necrofirePrefab = FindCatalogPrefab( director, EnemyKind.Necrofire); EnemyController necrofireSummon = UnityEngine.Object.Instantiate( necrofirePrefab, player.transform.position + Vector3.left * 7f, Quaternion.identity); necrofireSummon.ConfigureSummonedEnemy( null, CreateNeutralTuning(necrofirePrefab), false); yield return null; yield return null; Assert.That(necrofireSummon.GetSummonAnimationDuration(), Is.EqualTo(0.75f).Within(0.005f)); Assert.That(necrofireSummon.State, Is.EqualTo(EnemyState.Spawn)); Assert.That(necrofireSummon.StateDuration, Is.EqualTo(0.75f).Within(0.005f)); Assert.That(necrofireSummon.GetComponent() .GetCurrentAnimatorStateInfo(0).IsName("Summon"), Is.True, "Necrofire summons must use their authored rise animation."); float summonDeadline = Time.realtimeSinceStartup + 1f; while (Time.realtimeSinceStartup < summonDeadline && necrofireSummon.State == EnemyState.Spawn) { yield return null; } Assert.That(necrofireSummon.State, Is.Not.EqualTo(EnemyState.Spawn)); EnemyController waveEnemy = UnityEngine.Object.Instantiate( necrofirePrefab, player.transform.position + Vector3.left * 8f, Quaternion.identity); yield return null; yield return null; Assert.That(waveEnemy.GetSummonAnimationDuration(), Is.EqualTo(0.75f).Within(0.005f), "The prefab may provide a Summon clip without playing it for a normal wave spawn."); Assert.That(waveEnemy.State, Is.EqualTo(EnemyState.Spawn)); Assert.That(waveEnemy.StateDuration, Is.EqualTo(0.15f).Within(0.005f), "Non-summoned wave enemies keep the existing short Spawn timing."); Assert.That(waveEnemy.GetComponent() .GetCurrentAnimatorStateInfo(0).IsName("Summon"), Is.False, "Normal wave spawns must not play the necromancer rise animation."); UnityEngine.Object.Destroy(necrofireSummon.gameObject); UnityEngine.Object.Destroy(waveEnemy.gameObject); } [UnityTest] public IEnumerator AmbientAndProtectedSummonsRiseNearOwnerAtArenaEdges() { yield return LoadCombatScene(); SpawnDirector director = UnityEngine.Object.FindAnyObjectByType(); PlayerController player = UnityEngine.Object.FindAnyObjectByType(); ArenaBounds bounds = ArenaBounds.Resolve(); Assert.That(director, Is.Not.Null); Assert.That(player, Is.Not.Null); Assert.That(bounds, Is.Not.Null); director.enabled = false; player.GetComponent().GrantInvulnerability(60f); int initialAliveCount = EnemyController.AliveCount; EnemyController necromancerPrefab = FindCatalogPrefab( director, EnemyKind.Necromancer); Vector2 halfExtents = director.SpawnReachableHalfExtents; EnemyController edgeOwner = UnityEngine.Object.Instantiate( necromancerPrefab, Vector2.zero, Quaternion.identity); yield return WaitForReady(edgeOwner); Vector2 edgePosition = new(halfExtents.x, 0f); Assert.That(edgeOwner.TryReposition(edgePosition), Is.True); Assert.That(director.TrySpawnAmbientSummons( edgeOwner, 3, true, out List ambientSummons), Is.True); AssertSummonsAreNearOwner( edgeOwner, ambientSummons, bounds, player, ExpectedSummonSpawnMinimumSeparation); EnemyController cornerOwner = UnityEngine.Object.Instantiate( necromancerPrefab, Vector2.zero, Quaternion.identity); yield return WaitForReady(cornerOwner); Vector2 cornerPosition = new(-halfExtents.x, halfExtents.y); Assert.That(cornerOwner.TryReposition(cornerPosition), Is.True); SetPrivateField( director, "maximumAliveEnemies", EnemyController.AliveCount + 3); Assert.That(director.TrySpawnPhaseSummons( cornerOwner, 2, true, out List phaseSummons), Is.True); AssertSummonsAreNearOwner( cornerOwner, phaseSummons, bounds, player, 0.25f); for (int i = 0; i < phaseSummons.Count; i++) { for (int j = 0; j < ambientSummons.Count; j++) { Assert.That(Vector2.Distance( phaseSummons[i].transform.position, ambientSummons[j].transform.position), Is.GreaterThanOrEqualTo(0.25f - 0.001f), "A later summon must avoid the same position as an existing summon."); } } Assert.That(EnemyController.AliveCount, Is.EqualTo(initialAliveCount + 8), "The two casters and six summons must fit under the boss encounter cap."); SetPrivateField(director, "maximumAliveEnemies", 100); Assert.That(director.TrySpawnAmbientSummons( cornerOwner, 1, true, out List rejectedAmbientSummons), Is.False, "Ambient summons must stop at the boss encounter cap."); Assert.That(rejectedAmbientSummons, Is.Empty); SetPrivateField(director, "maximumAliveEnemies", EnemyController.AliveCount); Assert.That(director.TrySpawnPhaseSummons( cornerOwner, 2, true, out List rejectedSummons), Is.False, "Protected summons must remain blocked at the global hard cap."); Assert.That(rejectedSummons, Is.Empty); Assert.That(ambientSummons.TrueForAll(enemy => enemy != null && !enemy.IsDead), Is.True); Assert.That(phaseSummons.TrueForAll(enemy => enemy != null && !enemy.IsDead), Is.True); } private static IEnumerator LoadCombatScene() { AsyncOperation load = SceneManager.LoadSceneAsync("CombatPrototype"); while (!load.isDone) { yield return null; } yield return null; yield return null; yield return null; } private static IEnumerator WaitForReady(EnemyController enemy) { float deadline = Time.realtimeSinceStartup + 3f; while (Time.realtimeSinceStartup < deadline && enemy != null && (enemy.MaximumHealth <= 0f || enemy.State == EnemyState.Spawn)) { yield return null; } Assert.That(enemy, Is.Not.Null); Assert.That(enemy.MaximumHealth, Is.GreaterThan(0f)); Assert.That(enemy.State, Is.Not.EqualTo(EnemyState.Spawn)); } private static EnemyController FindCatalogPrefab( SpawnDirector director, EnemyKind kind) { string[] fieldNames = { "enemyPrefabs", "elitePrefabs", "midBossPrefabs", "finalBossPrefabs", }; foreach (string fieldName in fieldNames) { FieldInfo field = typeof(SpawnDirector).GetField( fieldName, NonPublicInstance); EnemyController[] prefabs = field?.GetValue(director) as EnemyController[]; if (prefabs == null) { continue; } foreach (EnemyController prefab in prefabs) { if (prefab != null && prefab.Definition != null && prefab.Definition.Kind == kind) { return prefab; } } } Assert.Fail($"Could not find the {kind} catalog prefab."); return null; } private static AnimationClip FindSummonClip(Animator animator) { if (animator == null || animator.runtimeAnimatorController == null) { return null; } AnimationClip[] clips = animator.runtimeAnimatorController.animationClips; for (int i = 0; i < clips.Length; i++) { if (clips[i] != null && clips[i].name.IndexOf("Summon", StringComparison.Ordinal) >= 0) { return clips[i]; } } return null; } private static Sprite SampleSprite(AnimationClip clip, float time) { #if UNITY_EDITOR // Read the authored expectation independently of runtime Animator evaluation. // Sampling an unbound preview object during PlayMode can leave its sprite null. foreach (var binding in UnityEditor.AnimationUtility.GetObjectReferenceCurveBindings(clip)) { if (binding.type != typeof(SpriteRenderer) || binding.propertyName != "m_Sprite") continue; Sprite expected = null; foreach (var key in UnityEditor.AnimationUtility.GetObjectReferenceCurve(clip, binding)) if (key.time <= time) expected = key.value as Sprite; Assert.That(expected, Is.Not.Null, "The authored summon frame must resolve."); return expected; } Assert.Fail("The summon clip has no SpriteRenderer curve."); #else Assert.Ignore("Authored sprite-curve verification requires the Unity Editor."); #endif return null; } private static float ExpectedSummonDuration(EnemyKind kind) { switch (kind) { case EnemyKind.Bat: case EnemyKind.Slime: case EnemyKind.Werewolf: case EnemyKind.Werebear: return 0.5f; case EnemyKind.Necrofire: case EnemyKind.NecroGolem: return 0.75f; default: return 0.625f; } } private static void AssertSummonsAreNearOwner( EnemyController owner, IReadOnlyList summons, ArenaBounds bounds, PlayerController player, float minimumGroupSpacing) { Assert.That(summons.Count, Is.EqualTo(3)); for (int i = 0; i < summons.Count; i++) { EnemyController summon = summons[i]; Vector2 position = summon.transform.position; Assert.That(summon.IsSummoned, Is.True); Assert.That(summon.SummonOwner, Is.SameAs(owner)); Assert.That(Vector2.Distance(owner.transform.position, position), Is.LessThanOrEqualTo(ExpectedSummonSpawnRadius + 0.001f), "A Necromancer summon should appear on the nearby ring around its owner."); Assert.That(bounds.IsInsideReachableArena(position, 0.25f), Is.True, "A nearby summon must remain on the reachable arena floor."); Assert.That(Vector2.Distance(position, player.transform.position), Is.GreaterThanOrEqualTo(ExpectedSummonPlayerClearance - 0.001f), "A nearby summon must not overlap the player."); for (int j = 0; j < i; j++) { Assert.That(Vector2.Distance(position, summons[j].transform.position), Is.GreaterThanOrEqualTo(minimumGroupSpacing - 0.001f), "A summon group must use distinct nearby positions."); } } } private static RunEventEnemyTuning CreateNeutralTuning(EnemyController prefab) { return RunEventEnemyTuning.Create( prefab.Definition.Kind, 1f, prefab.Definition.ExperienceValue, 1f, Color.white, 1f, 1f, 1f, 1f, 1f, 1f, 1f, 1f, 1f, 1, 1f, 1f, 1, 1f, true); } private static void SetPrivateField( object target, string fieldName, T value) { FieldInfo field = target.GetType().GetField( fieldName, NonPublicInstance); Assert.That(field, Is.Not.Null, $"Could not find field {fieldName}."); field.SetValue(target, value); } private static float GetPrivateFloat(object target, string fieldName) { FieldInfo field = target.GetType().GetField( fieldName, NonPublicInstance); Assert.That(field, Is.Not.Null, $"Could not find field {fieldName}."); return (float)field.GetValue(target); } private static int CountOwnedSummons(EnemyController owner) { int count = 0; EnemyController[] enemies = UnityEngine.Object.FindObjectsByType( FindObjectsSortMode.None); for (int i = 0; i < enemies.Length; i++) { if (enemies[i].IsSummoned && enemies[i].SummonOwner == owner) { count++; } } return count; } private static bool AllTrue(bool[] values) { for (int i = 0; i < values.Length; i++) { if (!values[i]) { return false; } } return true; } private static string DescribeAnimator( EnemyKind kind, Animator animator, SpriteRenderer renderer, string reason) { AnimatorStateInfo state = animator.GetCurrentAnimatorStateInfo(0); return $"{kind} {reason}: state={state.fullPathHash}, " + $"normalizedTime={state.normalizedTime:0.000}, " + $"sprite={renderer.sprite?.name ?? ""}."; } } }