using System; using System.Collections.Generic; using System.Linq; using System.Reflection; using BumpCombat.Constants; using BumpCombat.Defense; using BumpCombat.Defense.Comparison; using BumpCombat.Progression; using NUnit.Framework; using UnityEditor; using UnityEngine; using UnityEngine.TestTools; using Object = UnityEngine.Object; namespace BumpCombat.Tests { public sealed class Defense25DMonsterRosterPlayModeTests { private const string SettingsPath = "Assets/_Project/Constants/Defense25DComparison/ComparisonSettings.asset"; private const string RunPath = "Assets/_Project/Constants/Defense25DComparison/LongDefenseRun.asset"; private Defense25DComparisonSettings settings; private Defense25DRunDefinition runDefinition; private Defense25DComparison world; private float previousTimeScale; private SimulationMode previousPhysicsSimulationMode; private readonly List geometryFixtures = new(); [UnitySetUp] public System.Collections.IEnumerator SetUp() { previousTimeScale = Time.timeScale; previousPhysicsSimulationMode = Physics.simulationMode; Physics.simulationMode = SimulationMode.Script; Time.timeScale = 1f; settings = Object.Instantiate(AssetDatabase.LoadAssetAtPath(SettingsPath)); runDefinition = Object.Instantiate(AssetDatabase.LoadAssetAtPath(RunPath)); Assert.That(settings, Is.Not.Null); Assert.That(runDefinition, Is.Not.Null); world = new GameObject("Full monster roster lifecycle under test").AddComponent(); world.InitializeLongRun(settings, runDefinition); world.AutoSpawn = false; world.ReadInput = false; world.SetPaused(false); Time.timeScale = 0f; world.AdvanceRunClock(runDefinition.PreparationSeconds); Assert.That(world.RunPhase, Is.EqualTo(Defense25DRunPhase.Running)); yield return null; } [UnityTearDown] public System.Collections.IEnumerator TearDown() { foreach (GameObject fixture in geometryFixtures) if (fixture != null) Object.Destroy(fixture); geometryFixtures.Clear(); if (world != null) Object.Destroy(world.gameObject); if (settings != null) Object.Destroy(settings); if (runDefinition != null) Object.Destroy(runDefinition); Time.timeScale = previousTimeScale; Physics.simulationMode = previousPhysicsSimulationMode; yield return null; } [UnityTest] public System.Collections.IEnumerator AllTwentyEightPoolSpeciesUseFactoryMovementCombatResolutionAndRestart() { Defense25DMonsterPoolDefinition pool = runDefinition.MonsterPoolDefinition; Assert.That(pool, Is.Not.Null); Assert.That(pool.TryValidate(out string poolError), Is.True, poolError); Assert.That(runDefinition.MonsterSourceMotionCatalog.TryValidateAgainst(pool, out string motionError), Is.True, motionError); Defense25DMonsterPoolEntry[] entries = pool.Tiers.SelectMany(tier => tier.Entries).ToArray(); Assert.That(entries, Has.Length.EqualTo(28)); Assert.That(entries.Select(entry => entry.EnemyDefinitionId).Distinct().Count(), Is.EqualTo(28)); List failures = new(); int arrivalIndex = entries.Length - 1; int firstGeneration = -1; for (int index = 0; index < entries.Length; index++) { Defense25DMonsterPoolEntry entry = entries[index]; try { world.ResetRun(); world.AdvanceRunClock(runDefinition.PreparationSeconds); Assert.That(world.RunPhase, Is.EqualTo(Defense25DRunPhase.Running)); int generation = world.RunGeneration; if (index == 0) firstGeneration = generation; int portalId = index % 2; int enemiesBeforeSpawn = world.Enemies.Count; int maxSpawnIdBefore = world.Enemies .Where(candidate => candidate.SpawnContext.RunGeneration == generation) .Select(candidate => candidate.SpawnContext.SpawnId).DefaultIfEmpty(-1).Max(); int suppliedBeforeSpawn = world.Ledger.SuppliedCount; int resolvedBeforeSpawn = world.Ledger.ResolvedCount; world.SpawnScheduledEnemy(CreateNormalSpawnRequest(entry, generation, portalId, index)); Assert.That(world.Enemies.Count, Is.InRange(enemiesBeforeSpawn, enemiesBeforeSpawn + 1), $"{entry.EnemyDefinitionId} factory either dropped a spawn or created more than one actor."); Defense25DEnemy enemy = world.Enemies.Single(candidate => candidate.SpawnContext.RunGeneration == generation && candidate.SpawnContext.SpawnId > maxSpawnIdBefore); Assert.That(enemy.IsAvailable, Is.True, $"{entry.EnemyDefinitionId} did not become available."); Assert.That(enemy.SpawnContext.Source, Is.EqualTo(Defense25DSpawnSource.Normal)); Assert.That(enemy.LongRunDefinition, Is.SameAs(entry.Definition)); Assert.That(enemy.MonsterCombat, Is.Not.Null); Assert.That(enemy.MonsterCombat.IsBound, Is.True); Assert.That(enemy.MonsterCombat.EnemyDefinitionId, Is.EqualTo(entry.EnemyDefinitionId)); Assert.That(enemy.MonsterCombat.SourceCharacterId, Is.EqualTo(entry.SourceCharacterId)); Assert.That(enemy.MonsterCombat.AttackAction, Is.EqualTo(entry.CombatProfile.AttackAction)); Assert.That(enemy.MonsterCombat.CurrentSourceSprite, Is.Not.Null, $"{entry.EnemyDefinitionId} did not bind its exact authored source frames."); Vector2 start = enemy.Actor.Position; bool movedAlongRoute = false; for (int step = 0; step < 24 && !movedAlongRoute; step++) { enemy.Step(.1f); Physics.SyncTransforms(); Physics.Simulate(.02f); Physics.SyncTransforms(); movedAlongRoute = Vector2.Distance(start, enemy.Actor.Position) > .01f || enemy.WaypointIndex > 1; } Assert.That(movedAlongRoute, Is.True, $"{entry.EnemyDefinitionId} did not move along portal {portalId}'s authored route."); world.Player.ResetAt(enemy.Actor.Position + Vector2.right * .05f); Physics.SyncTransforms(); int playerDamageEvents = world.Player.DamageEvents; for (int step = 0; step < 600 && world.Player.DamageEvents == playerDamageEvents; step++) { enemy.Step(.05f); Physics.SyncTransforms(); } Assert.That(world.Player.DamageEvents, Is.GreaterThan(playerDamageEvents), $"{entry.EnemyDefinitionId} did not complete its authored attack against the player."); float healthBeforeHit = enemy.Actor.CurrentHealth; Assert.That(enemy.Actor.TakeDamage(1f, Vector2.zero), Is.True, $"{entry.EnemyDefinitionId} did not accept an actual actor hit."); Assert.That(enemy.Actor.CurrentHealth, Is.LessThan(healthBeforeHit)); float baseHealthBeforeResolution = world.BaseHealth; if (index == arrivalIndex) { world.ResolveEnemy(enemy, DefenseEnemyResolution.Arrival); Assert.That(world.BaseHealth, Is.LessThan(baseHealthBeforeResolution), $"{entry.EnemyDefinitionId} arrival did not damage the shared base."); } else { Assert.That(enemy.Actor.TakeDamage(enemy.Actor.CurrentHealth + 1f, Vector2.zero), Is.True, $"{entry.EnemyDefinitionId} did not accept a lethal actor hit."); } Assert.That(enemy.IsAvailable, Is.False, $"{entry.EnemyDefinitionId} remained available after its terminal resolution."); Assert.That(world.Ledger.SuppliedCount, Is.EqualTo(suppliedBeforeSpawn + 1)); Assert.That(world.Ledger.ResolvedCount, Is.EqualTo(resolvedBeforeSpawn + 1)); } catch (Exception exception) { failures.Add($"{entry.EnemyDefinitionId}: {exception.GetType().Name}: {exception.Message}"); } } try { world.ResetRun(); world.AdvanceRunClock(runDefinition.PreparationSeconds); Defense25DMonsterPoolEntry firstEntry = entries[0]; int restartedGeneration = world.RunGeneration; Assert.That(restartedGeneration, Is.GreaterThan(firstGeneration)); Defense25DEnemy firstScheduled = world.Enemies.Single(enemy => enemy.SpawnContext.RunGeneration == restartedGeneration && enemy.SpawnContext.SpawnId == 0); Assert.That(firstScheduled.SpawnContext.RunGeneration, Is.EqualTo(restartedGeneration)); int enemiesBeforeRestartedManualSpawn = world.Enemies.Count; int maxRestartedSpawnIdBefore = world.Enemies .Where(candidate => candidate.SpawnContext.RunGeneration == restartedGeneration) .Select(candidate => candidate.SpawnContext.SpawnId).DefaultIfEmpty(-1).Max(); int suppliedBeforeRestartedManualSpawn = world.Ledger.SuppliedCount; world.SpawnScheduledEnemy(CreateNormalSpawnRequest(firstEntry, restartedGeneration, 0, 0)); Assert.That(world.Enemies.Count, Is.InRange(enemiesBeforeRestartedManualSpawn, enemiesBeforeRestartedManualSpawn + 1)); Defense25DEnemy restarted = world.Enemies.Single(candidate => candidate.SpawnContext.RunGeneration == restartedGeneration && candidate.SpawnContext.SpawnId > maxRestartedSpawnIdBefore); Assert.That(restarted.SpawnContext.RunGeneration, Is.EqualTo(restartedGeneration)); Assert.That(firstScheduled.SpawnContext.SpawnId, Is.Zero, "Reset clears dynamic run actors and restarts the factory spawn ID for the new generation."); Assert.That(restarted.SpawnContext.SpawnId, Is.GreaterThan(firstScheduled.SpawnContext.SpawnId)); Assert.That(restarted.MonsterCombat.IsBound, Is.True); Assert.That(restarted.MonsterCombat.EnemyDefinitionId, Is.EqualTo(firstEntry.EnemyDefinitionId)); Assert.That(world.Ledger.SuppliedCount, Is.EqualTo(suppliedBeforeRestartedManualSpawn + 1)); } catch (Exception exception) { failures.Add($"restart: {exception.GetType().Name}: {exception.Message}"); } yield return null; Assert.That(failures, Is.Empty, string.Join("\n", failures)); } [UnityTest] public System.Collections.IEnumerator FactoryReusesPoolEnemyInstanceAfterArrivalAndDeathRemoval() { Defense25DMonsterPoolEntry entry = runDefinition.MonsterPoolDefinition.Tiers[0].Entries[0]; Defense25DEnemy first = SpawnManualPoolEntry(entry, 1); int firstInstanceId = first.gameObject.GetInstanceID(); world.ResolveEnemy(first, DefenseEnemyResolution.Arrival); Assert.That(first.gameObject.activeSelf, Is.False); Defense25DEnemy afterArrival = SpawnManualPoolEntry(entry, 2); Assert.That(afterArrival, Is.SameAs(first), "Arrival should return this exact species instance to its pool."); Assert.That(afterArrival.gameObject.GetInstanceID(), Is.EqualTo(firstInstanceId)); Assert.That(afterArrival.Actor.TakeDamage(afterArrival.Actor.CurrentHealth + 1f, Vector2.zero), Is.True); Assert.That(afterArrival.IsAvailable, Is.False); float deathRemovalDelay = GameplayConstants.Current.Enemies.DefaultDeathDuration + GameplayConstants.Current.Enemies.DeathRemovalPadding + .1f; world.AdvanceRunClock(deathRemovalDelay); yield return null; Assert.That(afterArrival.gameObject.activeSelf, Is.False, "The death visual must finish before the instance becomes reusable."); Defense25DEnemy afterDeath = SpawnManualPoolEntry(entry, 3); Assert.That(afterDeath, Is.SameAs(first), "Death removal should return the same instance to its pool."); Assert.That(afterDeath.Actor.CurrentHealth, Is.EqualTo(afterDeath.Actor.MaxHealth)); } [UnityTest] public System.Collections.IEnumerator ResetLeavesReturnedPoolInstancesDormantAndClearsActiveLedger() { world.ResetRun(); world.AdvanceRunClock(runDefinition.PreparationSeconds + 18f); int firstGeneration = world.RunGeneration; Defense25DEnemy firstRunEnemy = world.Enemies.Single(enemy => enemy.SpawnContext.RunGeneration == firstGeneration && enemy.SpawnContext.SpawnId == 0); world.ResetRun(); Defense25DEnemy[] retainedPool = world.GetComponentsInChildren(true); int activeHostiles = retainedPool.Count(enemy => enemy != null && enemy.gameObject.activeInHierarchy && enemy.IsAvailable); int activeActorColliders = retainedPool.Sum(enemy => enemy == null ? 0 : enemy.GetComponents().Count(collider => collider.enabled && collider.gameObject.activeInHierarchy)); Debug.Log($"Pool reset evidence: retained={retainedPool.Length}, activeHostiles={activeHostiles}, " + $"activeActorColliders={activeActorColliders}, supplied={world.Ledger.SuppliedCount}, " + $"resolved={world.Ledger.ResolvedCount}."); Assert.That(retainedPool, Is.Not.Empty); Assert.That(world.Enemies.All(enemy => enemy == null || !enemy.IsAvailable), Is.True, "The active enemy registry must not expose a hostile after reset."); Assert.That(activeHostiles, Is.Zero); Assert.That(activeActorColliders, Is.Zero); Assert.That(world.Ledger.SuppliedCount, Is.Zero); Assert.That(world.Ledger.ResolvedCount, Is.Zero); Assert.That(firstRunEnemy, Is.Not.Null, "Pooled enemy instances must survive reset."); Assert.That(firstRunEnemy.gameObject.activeSelf, Is.False); Assert.That(retainedPool.Any(enemy => enemy == firstRunEnemy), Is.True); Assert.That(world.RunGeneration, Is.EqualTo(firstGeneration + 1)); yield return null; } [UnityTest] public System.Collections.IEnumerator PersonalMissionRelicCandidatesUseAuthoredDisplayNameAndDescription() { MethodInfo method = typeof(Defense25DComparison).GetMethod("GetMissionRelicCandidates", BindingFlags.Instance | BindingFlags.NonPublic); Assert.That(method, Is.Not.Null); IReadOnlyList choices = method.Invoke(world, new object[] { "display-test" }) as IReadOnlyList; Assert.That(choices, Is.Not.Null.And.Not.Empty); Defense25DRewardChoice choice = choices[0]; PassiveRelicId relic = Enum.Parse(choice.DefinitionId); MethodInfo displayMethod = typeof(ArtifactRewardController).GetMethod("GetPassiveRelicName", BindingFlags.Static | BindingFlags.NonPublic); Assert.That(displayMethod, Is.Not.Null); string authoredDisplay = (string)displayMethod.Invoke(null, new object[] { relic }); int descriptionStart = authoredDisplay.IndexOf('\n'); string expectedTitle = descriptionStart >= 0 ? authoredDisplay.Substring(0, descriptionStart) : authoredDisplay; string expectedDescription = descriptionStart >= 0 ? authoredDisplay.Substring(descriptionStart + 1) : string.Empty; Assert.That(choice.DisplayData.Title, Is.EqualTo(expectedTitle)); Assert.That(choice.DisplayData.Description, Is.EqualTo(expectedDescription)); Assert.That(choice.DisplayData.Title, Is.Not.EqualTo(choice.DefinitionId)); Assert.That(choice.DisplayData.Description, Does.Not.Contain("Acquire the")); yield return null; } [UnityTest] public System.Collections.IEnumerator RangedFlyingAndLargeActorsRespectPortalBoundsAndStopAtXzWalls() { Defense25DMonsterPoolEntry[] entries = GetGeometryCriticalEntries(); Assert.That(entries.Any(entry => entry.CombatProfile.Archetype == Defense25DMonsterAttackArchetype.Projectile), Is.True); Assert.That(entries.Any(entry => entry.CombatProfile.IsFlying || entry.Definition.IsFlying), Is.True); Assert.That(entries.Any(entry => entry.CombatProfile.BodyRadius >= .55f), Is.True); List failures = new(); Rect bounds = world.RunLayout.Bounds; Defense25DMonsterPoolEntry[] allEntries = runDefinition.MonsterPoolDefinition.Tiers .SelectMany(tier => tier.Entries).ToArray(); float maximumBodyRadius = allEntries.Max(entry => entry.CombatProfile.BodyRadius); IReadOnlyList portalPositions = world.RunLayout.PortalPositions; for (int portalIndex = 0; portalIndex < portalPositions.Count; portalIndex++) { Vector2 portal = portalPositions[portalIndex]; float edgeClearance = Mathf.Min(portal.x - bounds.xMin, bounds.xMax - portal.x, portal.y - bounds.yMin, bounds.yMax - portal.y); if (edgeClearance + .001f < maximumBodyRadius + settings.MovementSkin) failures.Add($"Portal SpawnNode {portalIndex} has {edgeClearance:0.000}m layout-edge margin; " + $"the maximum authored radius needs {maximumBodyRadius + settings.MovementSkin:0.000}m."); for (int otherIndex = portalIndex + 1; otherIndex < portalPositions.Count; otherIndex++) { float centerDistance = Vector2.Distance(portal, portalPositions[otherIndex]); float requiredDistance = 2f * maximumBodyRadius + settings.MovementSkin; if (centerDistance + .001f < requiredDistance) failures.Add($"SpawnNode pair {portalIndex}/{otherIndex} is {centerDistance:0.000}m apart; " + $"two maximum-radius actors need {requiredDistance:0.000}m center spacing."); } } for (int index = 0; index < entries.Length; index++) { Defense25DMonsterPoolEntry entry = entries[index]; Defense25DEnemy enemy = SpawnGeometryEntry(entry, index, out int portalId); bool expectedFlying = entry.CombatProfile.IsFlying || entry.Definition.IsFlying; if (enemy.MonsterCombat == null || enemy.MonsterCombat.IsFlying != expectedFlying) failures.Add($"{entry.EnemyDefinitionId}: flight profile did not reach the bound runtime adapter."); Vector2 portal = world.RunLayout.PortalPositions[portalId]; Vector2 spawnedAt = enemy.Actor.Position; float edgeClearance = Mathf.Min(spawnedAt.x - bounds.xMin, bounds.xMax - spawnedAt.x, spawnedAt.y - bounds.yMin, bounds.yMax - spawnedAt.y); if (Vector2.Distance(portal, spawnedAt) > .002f) failures.Add($"{entry.EnemyDefinitionId}: factory spawn was not centered on portal {portalId}."); if (edgeClearance + .001f < entry.CombatProfile.BodyRadius + settings.MovementSkin) failures.Add($"{entry.EnemyDefinitionId}: SpawnNode has only {edgeClearance:0.000}m to layout edge; " + $"authored radius+skin is {entry.CombatProfile.BodyRadius + settings.MovementSkin:0.000}m."); Vector2 route = world.RunLayout.Routes[portalId][1] - world.RunLayout.Routes[portalId][0]; if (route.sqrMagnitude < .0001f) { failures.Add($"{entry.EnemyDefinitionId}: SpawnNode {portalId} has no usable exit direction."); continue; } Vector2 wallTestStart = new(120f + index * 8f, 120f); failures.AddRange(CheckWallStop(enemy, entry, wallTestStart, Vector2.right, new Vector3(.2f, 2f, 6f), $"{entry.EnemyDefinitionId} X wall", false)); failures.AddRange(CheckWallStop(enemy, entry, wallTestStart, Vector2.up, new Vector3(6f, 2f, .2f), $"{entry.EnemyDefinitionId} Z/depth wall", false)); } yield return null; Assert.That(failures, Is.Empty, string.Join("\n", failures)); } [UnityTest] public System.Collections.IEnumerator FactoryCapsuleUsesAuthoredBodyRadiusForGeometryCriticalSpecies() { Defense25DMonsterPoolEntry[] entries = GetGeometryCriticalEntries(); List mismatches = new(); for (int index = 0; index < entries.Length; index++) { Defense25DMonsterPoolEntry entry = entries[index]; Defense25DEnemy enemy = SpawnGeometryEntry(entry, index, out _); float authoredRadius = entry.CombatProfile.BodyRadius; float actorRadius = enemy.Actor.Radius; float capsuleRadius = enemy.Actor.BodyCollider.radius; if (Mathf.Abs(authoredRadius - actorRadius) > .001f || Mathf.Abs(authoredRadius - capsuleRadius) > .001f) mismatches.Add($"{entry.EnemyDefinitionId}: profile={authoredRadius:0.000}, actor={actorRadius:0.000}, CapsuleCollider={capsuleRadius:0.000}"); } yield return null; Assert.That(mismatches, Is.Empty, "The factory must apply each profile's ground-plane footprint before testing clearance:\n" + string.Join("\n", mismatches)); } [UnityTest] public System.Collections.IEnumerator WallStopsRespectAuthoredFootprintOnBothGroundPlaneAxes() { Defense25DMonsterPoolEntry[] entries = GetGeometryCriticalEntries(); List failures = new(); for (int index = 0; index < entries.Length; index++) { Defense25DMonsterPoolEntry entry = entries[index]; Defense25DEnemy enemy = SpawnGeometryEntry(entry, index, out _); Vector2 start = new(220f + index * 8f, 220f); failures.AddRange(CheckWallStop(enemy, entry, start, Vector2.right, new Vector3(.2f, 2f, 6f), $"{entry.EnemyDefinitionId} X wall", true)); failures.AddRange(CheckWallStop(enemy, entry, start, Vector2.up, new Vector3(6f, 2f, .2f), $"{entry.EnemyDefinitionId} Z/depth wall", true)); } yield return null; Assert.That(failures, Is.Empty, "Wall stops must leave at least the authored body radius in both XZ axes:\n" + string.Join("\n", failures)); } private static Defense25DSpawnRequest CreateNormalSpawnRequest( Defense25DMonsterPoolEntry entry, int generation, int portalId, int ordinal) { Type[] parameterTypes = { typeof(int), typeof(int), typeof(int), typeof(Defense25DSpawnSource), typeof(string), typeof(float), typeof(float), typeof(float), typeof(float), typeof(float), typeof(int), }; ConstructorInfo constructor = typeof(Defense25DSpawnRequest).GetConstructor( BindingFlags.Instance | BindingFlags.NonPublic, null, parameterTypes, null); Assert.That(constructor, Is.Not.Null, "The scheduled-spawn test seam changed."); return (Defense25DSpawnRequest)constructor.Invoke(new object[] { generation, 1, portalId, Defense25DSpawnSource.Normal, entry.EnemyDefinitionId, entry.ThreatCost, entry.ThreatCost, 0f, 0f, 0f, ordinal, }); } private Defense25DMonsterPoolEntry[] GetGeometryCriticalEntries() { Defense25DMonsterPoolDefinition pool = runDefinition.MonsterPoolDefinition; return pool.Tiers.SelectMany(tier => tier.Entries) .Where(entry => entry.CombatProfile.Archetype == Defense25DMonsterAttackArchetype.Projectile || entry.CombatProfile.IsFlying || entry.Definition.IsFlying || entry.CombatProfile.BodyRadius >= .55f) .ToArray(); } private Defense25DEnemy SpawnGeometryEntry( Defense25DMonsterPoolEntry entry, int ordinal, out int portalId) { world.ResetRun(); world.AdvanceRunClock(runDefinition.PreparationSeconds); portalId = ordinal % world.PortalCount; int previousCount = world.Enemies.Count; int generation = world.RunGeneration; world.SpawnScheduledEnemy(CreateNormalSpawnRequest(entry, generation, portalId, ordinal)); Assert.That(world.Enemies.Count, Is.EqualTo(previousCount + 1), $"{entry.EnemyDefinitionId} geometry fixture did not create one factory actor."); return world.Enemies[previousCount]; } private Defense25DEnemy SpawnManualPoolEntry(Defense25DMonsterPoolEntry entry, int ordinal) { int generation = world.RunGeneration; int maxSpawnIdBefore = world.Enemies .Where(candidate => candidate.SpawnContext.RunGeneration == generation) .Select(candidate => candidate.SpawnContext.SpawnId).DefaultIfEmpty(-1).Max(); world.SpawnScheduledEnemy(CreateNormalSpawnRequest(entry, generation, ordinal % world.PortalCount, ordinal)); return world.Enemies.Single(candidate => candidate.SpawnContext.RunGeneration == generation && candidate.SpawnContext.SpawnId > maxSpawnIdBefore); } private GameObject CreateGeometryWall(string label, Vector2 position, Vector3 size, Quaternion rotation) { GameObject wall = new(label); geometryFixtures.Add(wall); wall.transform.SetParent(world.transform, false); wall.layer = Defense25DComparisonSettings.TerrainLayer; wall.transform.position = Defense25DActor.OnGround(position, settings.GroundY + size.y * .5f); wall.transform.rotation = rotation; BoxCollider collider = wall.AddComponent(); collider.size = size; return wall; } private List CheckWallStop(Defense25DEnemy enemy, Defense25DMonsterPoolEntry entry, Vector2 start, Vector2 direction, Vector3 wallSize, string label, bool enforceProfileRadius) { List failures = new(); enemy.Actor.ResetAt(start); Vector2 colliderCenter = enemy.Actor.Center; Vector2 wallCenter = colliderCenter + direction; GameObject wall = CreateGeometryWall(label, wallCenter, wallSize, Quaternion.identity); Physics.SyncTransforms(); enemy.Actor.Move(direction * 2f); Physics.SyncTransforms(); Physics.Simulate(.02f); Physics.SyncTransforms(); float wallFaceDistance = 1f - (direction.x != 0f ? wallSize.x : wallSize.z) * .5f; float maximumProfileTravel = wallFaceDistance - entry.CombatProfile.BodyRadius + .04f; if (!enemy.Actor.MovementBlocked || enemy.Actor.LastMove.magnitude <= .05f) failures.Add($"{label}: actor did not stop at the terrain collider."); if (enforceProfileRadius && enemy.Actor.LastMove.magnitude > maximumProfileTravel) failures.Add($"{label}: root advanced {enemy.Actor.LastMove.magnitude:0.000}m past the profile-radius clearance limit {maximumProfileTravel:0.000}m."); if (Physics.ComputePenetration(enemy.Actor.BodyCollider, enemy.Actor.transform.position, enemy.Actor.transform.rotation, wall.GetComponent(), wall.transform.position, wall.transform.rotation, out _, out _)) failures.Add($"{label}: the runtime CapsuleCollider penetrated the wall after movement."); wall.SetActive(false); return failures; } } }