505 lines
29 KiB
C#
505 lines
29 KiB
C#
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<GameObject> 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<Defense25DComparisonSettings>(SettingsPath));
|
|
runDefinition = Object.Instantiate(AssetDatabase.LoadAssetAtPath<Defense25DRunDefinition>(RunPath));
|
|
Assert.That(settings, Is.Not.Null);
|
|
Assert.That(runDefinition, Is.Not.Null);
|
|
world = new GameObject("Full monster roster lifecycle under test").AddComponent<Defense25DComparison>();
|
|
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<string> 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<Defense25DEnemy>(true);
|
|
int activeHostiles = retainedPool.Count(enemy => enemy != null && enemy.gameObject.activeInHierarchy &&
|
|
enemy.IsAvailable);
|
|
int activeActorColliders = retainedPool.Sum(enemy => enemy == null ? 0 :
|
|
enemy.GetComponents<Collider>().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<Defense25DRewardChoice> choices = method.Invoke(world, new object[] { "display-test" })
|
|
as IReadOnlyList<Defense25DRewardChoice>;
|
|
Assert.That(choices, Is.Not.Null.And.Not.Empty);
|
|
|
|
Defense25DRewardChoice choice = choices[0];
|
|
PassiveRelicId relic = Enum.Parse<PassiveRelicId>(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<string> 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<Vector2> 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<string> 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<string> 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<BoxCollider>();
|
|
collider.size = size;
|
|
return wall;
|
|
}
|
|
|
|
private List<string> CheckWallStop(Defense25DEnemy enemy, Defense25DMonsterPoolEntry entry,
|
|
Vector2 start, Vector2 direction, Vector3 wallSize, string label, bool enforceProfileRadius)
|
|
{
|
|
List<string> 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<BoxCollider>(), wall.transform.position,
|
|
wall.transform.rotation, out _, out _))
|
|
failures.Add($"{label}: the runtime CapsuleCollider penetrated the wall after movement.");
|
|
|
|
wall.SetActive(false);
|
|
return failures;
|
|
}
|
|
}
|
|
}
|